mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65f20b899b | ||
|
|
3068bd9ffa | ||
|
|
755eab661d |
No files matched your search
@@ -130,7 +130,6 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画
|
||||
- **アーカイブショートカット** — Linux セッションでは、出力された `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz`、`.zip` の名前を Ctrl/Cmd+クリックすると、アーカイブの絶対パスを含む展開コマンドがカーソル位置に挿入されます(入力済みの文字は残ります)。Enter で実行します。カレントディレクトリからの相対名にはディレクトリ追跡(OSC 7、ツールバーの Configure directory tracking)が必要です。絶対パスは常に対象です。Windows、WSL、macOS のセッションは対象外です。
|
||||
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
|
||||
@@ -87,7 +87,6 @@ Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one c
|
||||
- [Split Terminals](#split-terminals)
|
||||
- [Supported Distros](#supported-distros)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Open a local terminal from the command line](#open-a-local-terminal-from-the-command-line)
|
||||
- [Build & Package](#build--package)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Contributing](#contributing)
|
||||
@@ -133,7 +132,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
- **Archive shortcuts** — on Linux sessions, Ctrl/Cmd+click a printed `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz` or `.zip` name to fill an extraction command with the archive's absolute path into the prompt (kept next to any typed text); press Enter to run it. Names relative to the current directory need directory tracking (OSC 7, see the toolbar's Configure directory tracking); absolute paths always work. Windows, WSL, and macOS sessions are excluded.
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
@@ -243,24 +241,6 @@ application and integration are complete.
|
||||
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
<a name="open-a-local-terminal-from-the-command-line"></a>
|
||||
### Open a local terminal from the command line
|
||||
|
||||
`--open-terminal-path <dir>` opens a local terminal in that folder. `--open-terminal-shell <id-or-path>` selects the shell: a discovered id such as `pwsh`, `cmd`, or `wsl-ubuntu`, or a path to a shell executable. Either flag is enough. A shell alone opens a local tab in the default directory. A path alone uses the configured local shell. If Netcatty is already running, the tab opens in that window.
|
||||
|
||||
Windows needs an extra `--` so Electron forwards the flags:
|
||||
|
||||
```bat
|
||||
Netcatty.exe -- --open-terminal-shell=pwsh --open-terminal-path="D:\\proj"
|
||||
```
|
||||
|
||||
On macOS, start a new instance with `open`, or run the binary directly:
|
||||
|
||||
```bash
|
||||
open -n -a Netcatty --args --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
|
||||
/Applications/Netcatty.app/Contents/MacOS/Netcatty --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
|
||||
```
|
||||
|
||||
### Scoop (Windows x64, community-maintained)
|
||||
|
||||
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
|
||||
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
|
||||
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
- **分割窗格** —— 水平與垂直分割,方便多工
|
||||
- **工作階段管理** —— 多條連線並排處理
|
||||
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
|
||||
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
|
||||
|
||||
### 📁 SFTP + 內建編輯器
|
||||
- **檔案工作流** —— 拖放上傳/下載
|
||||
|
||||
@@ -534,10 +534,9 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
const source = sessions?.find((s: { id: string }) => s.id === sessionId);
|
||||
if (!source) return undefined;
|
||||
|
||||
// Freshest cwd: the live value tracked in terminal state (OSC 7 or the
|
||||
// post-command probe, including local shells). lastCwd on the session object
|
||||
// is only the startup snapshot. Falls through to the SSH probe / lastCwd
|
||||
// when absent.
|
||||
// Freshest cwd: the live OSC 7 value tracked in terminal state (the only
|
||||
// source that reflects `cd`s in a running local terminal — lastCwd is a
|
||||
// startup snapshot). Falls through to the SSH probe / lastCwd when absent.
|
||||
const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId);
|
||||
|
||||
const bridge = netcattyBridge?.get?.();
|
||||
|
||||
@@ -15,14 +15,4 @@ test("open-terminal requests wait behind App Lock and resume after unlock", () =
|
||||
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
|
||||
assert.match(handlerSource, /kind: 'open-terminal-path'/);
|
||||
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
|
||||
|
||||
const processStart = source.indexOf("const _processOpenTerminalPath");
|
||||
const processSource = source.slice(processStart, handlerStart);
|
||||
assert.match(processSource, /handleCreateLocalTerminal\(undefined, \{ localStartDir \}\)/);
|
||||
assert.match(processSource, /resolveShellSetting\(shellSetting, shells\)/);
|
||||
assert.match(source, /const openTerminalQueueRef = useRef\(createOpenTerminalQueue\(\)\)/);
|
||||
const enqueueAt = processSource.indexOf("openTerminalQueueRef.current.enqueue(() => {");
|
||||
const createAt = processSource.indexOf("handleCreateLocalTerminal");
|
||||
assert.ok(enqueueAt >= 0 && createAt > enqueueAt);
|
||||
assert.match(processSource, /await ensureDiscoveredShells\(\)/);
|
||||
});
|
||||
@@ -47,7 +47,6 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { prepareConnectionHost } from '../../domain/legacyAlgorithms';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import {
|
||||
@@ -95,7 +94,6 @@ import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { createOpenTerminalQueue } from './openTerminalQueue';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
|
||||
@@ -1451,15 +1449,11 @@ export function AppSideEffects() {
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
terminalSettings.temporaryConnectionsAllowLegacyAlgorithms,
|
||||
);
|
||||
if (connectionHost.ephemeral) {
|
||||
if (host.ephemeral) {
|
||||
setEphemeralHosts((previous) => {
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === connectionHost.id);
|
||||
if (existingIndex < 0) return [...previous, connectionHost];
|
||||
return previous.map((candidate, index) => index === existingIndex ? connectionHost : candidate);
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
|
||||
if (existingIndex < 0) return [...previous, host];
|
||||
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
|
||||
});
|
||||
}
|
||||
const effectiveHostResolver = alreadyEffective
|
||||
@@ -1469,7 +1463,7 @@ export function AppSideEffects() {
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host: connectionHost,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
@@ -1477,7 +1471,7 @@ export function AppSideEffects() {
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
@@ -1563,7 +1557,7 @@ export function AppSideEffects() {
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string; shell?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
@@ -1727,44 +1721,14 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
// Shell discovery is part of this queue. A later path-only request, including
|
||||
// one drained after App Lock, cannot create its tab ahead of an in-flight shell.
|
||||
const openTerminalQueueRef = useRef(createOpenTerminalQueue());
|
||||
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
const shellSetting = typeof payload?.shell === 'string' ? payload.shell.trim() : '';
|
||||
void openTerminalQueueRef.current.enqueue(() => {
|
||||
if (!shellSetting) {
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
return;
|
||||
}
|
||||
|
||||
return (async () => {
|
||||
const shells = await ensureDiscoveredShells();
|
||||
const resolved = resolveShellSetting(shellSetting, shells);
|
||||
if (!resolved?.command) {
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
return;
|
||||
}
|
||||
const matchedShell = shells.find((shell) => shell.id === shellSetting);
|
||||
handleCreateLocalTerminal(
|
||||
{
|
||||
command: resolved.command,
|
||||
args: resolved.args,
|
||||
name: matchedShell?.name,
|
||||
icon: matchedShell?.icon,
|
||||
},
|
||||
localStartDir.trim() ? { localStartDir } : undefined,
|
||||
);
|
||||
})();
|
||||
});
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
});
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({
|
||||
kind: 'open-terminal-path',
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createOpenTerminalQueue } from './openTerminalQueue';
|
||||
|
||||
test('idle open-terminal work stays in call order on the caller turn', () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
|
||||
queue.enqueue(() => { order.push('path-a'); });
|
||||
queue.enqueue(() => { order.push('path-b'); });
|
||||
|
||||
assert.deepEqual(order, ['path-a', 'path-b']);
|
||||
});
|
||||
|
||||
test('a path-only open waits for an in-flight shell discovery', async () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
let releaseDiscovery: () => void = () => {};
|
||||
const discovery = new Promise<void>((resolve) => {
|
||||
releaseDiscovery = resolve;
|
||||
});
|
||||
|
||||
const shellRequest = queue.enqueue(async () => {
|
||||
order.push('shell');
|
||||
await discovery;
|
||||
order.push('shell-tab');
|
||||
});
|
||||
const pathRequest = queue.enqueue(() => {
|
||||
order.push('path-tab');
|
||||
});
|
||||
|
||||
assert.deepEqual(order, ['shell']);
|
||||
releaseDiscovery();
|
||||
await shellRequest;
|
||||
await pathRequest;
|
||||
assert.deepEqual(order, ['shell', 'shell-tab', 'path-tab']);
|
||||
});
|
||||
|
||||
test('a failed open-terminal request does not block the next one', async () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
|
||||
const failed = queue.enqueue(async () => {
|
||||
order.push('shell');
|
||||
throw new Error('discovery failed');
|
||||
});
|
||||
const next = queue.enqueue(() => {
|
||||
order.push('path-tab');
|
||||
});
|
||||
|
||||
await assert.rejects(failed, /discovery failed/);
|
||||
await next;
|
||||
assert.deepEqual(order, ['shell', 'path-tab']);
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
type OpenTerminalTask = () => Promise<void> | void;
|
||||
|
||||
function isPromise(value: Promise<void> | void): value is Promise<void> {
|
||||
return Boolean(value) && typeof (value as Promise<void>).then === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered launcher queue. An idle queue runs the task in the caller turn so a
|
||||
* path-only open still creates its tab synchronously. A task that awaits (shell
|
||||
* discovery) holds the tail, and later requests cannot pass it.
|
||||
*/
|
||||
export function createOpenTerminalQueue() {
|
||||
let tail: Promise<void> = Promise.resolve();
|
||||
let depth = 0;
|
||||
|
||||
return {
|
||||
enqueue(task: OpenTerminalTask): Promise<void> {
|
||||
depth += 1;
|
||||
let release: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
depth -= 1;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
const previous = tail;
|
||||
tail = finished;
|
||||
|
||||
const run = (): Promise<void> => {
|
||||
try {
|
||||
const result = task();
|
||||
if (isPromise(result)) {
|
||||
return result.then(release, (error: unknown) => {
|
||||
release();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
release();
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (depth === 1) return run();
|
||||
return previous.then(run, run);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -356,14 +356,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.',
|
||||
'ai.externalMcp.approvalAlert': 'Approval alerts',
|
||||
'ai.externalMcp.approvalAlert.description': 'When an external MCP client asks for confirmation, show a system notification. While Netcatty is unfocused, the taskbar or Dock asks for attention. The notification uses the system sound.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Off',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'When unfocused',
|
||||
'ai.externalMcp.approvalAlert.always': 'Always',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'External MCP approval',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} is waiting for approval',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} on {host} is waiting for approval',
|
||||
'ai.externalMcp.silentSessions': 'Silent MCP sessions',
|
||||
'ai.externalMcp.silentSessions.description': 'Sessions opened by AI stay out of your tab bar and are not restored after restart. View them anytime from the tray panel.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout',
|
||||
@@ -376,38 +368,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
'ai.externalMcp.http.port': 'Port',
|
||||
'ai.externalMcp.http.portHint': 'Fixed port, default 47391. Client URLs stay the same across restarts. Allowed range: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Listen on another address',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'By default the endpoint binds to 127.0.0.1. Turn this on for WSL2 NAT and set 0.0.0.0 or the vEthernet IP.',
|
||||
'ai.externalMcp.http.listenAddress': 'Listen address',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Client address',
|
||||
'ai.externalMcp.http.clientHost.description': 'Used in copied configs when the listener is 0.0.0.0 or ::. Enter the IP clients can reach, such as the Windows host IP.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'Client address must be a reachable IP, not 0.0.0.0 or ::.',
|
||||
'ai.externalMcp.http.warning': 'Listening beyond 127.0.0.1 exposes External MCP to other machines that can reach this address. Keep the bearer token private and do not publish this port.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Regenerate',
|
||||
'ai.externalMcp.http.regenerateHint': 'Issues a new bearer token. HTTP clients must use the new token. The stdio discovery file is updated; clients that are already connected over stdio stay connected.',
|
||||
'ai.externalMcp.http.wslHint': 'With WSL mirrored networking, localhost works. Otherwise listen on 0.0.0.0 or the vEthernet IP and set Client address to the Windows host IP.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'Generic JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token env',
|
||||
'ai.externalMcp.http.notListening': 'Not listening',
|
||||
'ai.externalMcp.http.listening': 'Listening',
|
||||
'ai.externalMcp.http.portInUse': 'Port {port} is already in use.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Turn on External MCP before this endpoint will listen.',
|
||||
'ai.externalMcp.http.invalidPort': 'Enter a port from 1024 to 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'Listen address must be an IP, such as 0.0.0.0 or the vEthernet IP.',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP failed to listen.',
|
||||
'ai.externalMcp.http.legacySseNote': 'Legacy HTTP+SSE (2024-11-05) is not offered. Use this Streamable HTTP URL.',
|
||||
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Write permission mode',
|
||||
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
|
||||
|
||||
@@ -836,8 +836,6 @@ Highlight the focused split pane:
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
@@ -847,9 +845,6 @@ Highlight the focused split pane:
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Default proxy',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Saved proxy used when a host has no proxy of its own and does not inherit one from a group. None connects directly.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'None',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Show host information bar',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
|
||||
|
||||
@@ -109,8 +109,6 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.gpu': 'GPU Usage',
|
||||
'terminal.serverStats.gpuDevices': 'GPU Device Usage',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
|
||||
@@ -949,8 +949,6 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.direct': 'Direct (no proxy)',
|
||||
'hostDetails.proxy.directHint': 'Does not use a group or global default proxy',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
|
||||
|
||||
@@ -347,14 +347,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
|
||||
'ai.externalMcp.approvalAlert': 'Alertas de aprobación',
|
||||
'ai.externalMcp.approvalAlert.description': 'Cuando un cliente MCP externo pide confirmación, muestra una notificación del sistema. Si Netcatty no tiene el foco, la barra de tareas o el Dock pide atención. La notificación usa el sonido del sistema.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Desactivado',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'Si no tiene el foco',
|
||||
'ai.externalMcp.approvalAlert.always': 'Siempre',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'Aprobación de MCP externo',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} espera aprobación',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} en {host} espera aprobación',
|
||||
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
|
||||
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
|
||||
@@ -367,38 +359,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
'ai.externalMcp.http.port': 'Puerto',
|
||||
'ai.externalMcp.http.portHint': 'Puerto fijo, predeterminado 47391. Las URL de los clientes se mantienen al reiniciar. Rango permitido: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Escuchar en otra dirección',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'De forma predeterminada el extremo se enlaza a 127.0.0.1. Actívalo para NAT de WSL2 y usa 0.0.0.0 o la IP de vEthernet.',
|
||||
'ai.externalMcp.http.listenAddress': 'Dirección de escucha',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Dirección del cliente',
|
||||
'ai.externalMcp.http.clientHost.description': 'Se usa en las configuraciones copiadas cuando la escucha es 0.0.0.0 o ::. Introduce una IP alcanzable, como la IP del host de Windows.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Introduce la IP a la que deben conectarse los clientes. 0.0.0.0 no es una dirección alcanzable.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'La dirección del cliente debe ser una IP alcanzable, no 0.0.0.0 ni ::.',
|
||||
'ai.externalMcp.http.warning': 'Escuchar fuera de 127.0.0.1 expone el MCP externo a otros equipos que puedan alcanzar esta dirección. Mantén el bearer token en privado y no publiques este puerto.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Regenerar',
|
||||
'ai.externalMcp.http.regenerateHint': 'Emite un bearer token nuevo. Los clientes HTTP deben usar el token nuevo. El archivo de descubrimiento stdio se actualiza; los clientes stdio ya conectados siguen conectados.',
|
||||
'ai.externalMcp.http.wslHint': 'Con la red reflejada de WSL, localhost funciona. Si no, escucha en 0.0.0.0 o la IP de vEthernet y pon la IP del host de Windows en Dirección del cliente.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'JSON genérico',
|
||||
'ai.externalMcp.http.envLine': 'Variable de entorno del token de Codex',
|
||||
'ai.externalMcp.http.notListening': 'Sin escucha',
|
||||
'ai.externalMcp.http.listening': 'Escuchando',
|
||||
'ai.externalMcp.http.portInUse': 'El puerto {port} ya está en uso.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Activa el MCP externo para que este extremo empiece a escuchar.',
|
||||
'ai.externalMcp.http.invalidPort': 'Introduce un puerto entre 1024 y 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'La dirección de escucha debe ser una IP, como 0.0.0.0 o la IP de vEthernet.',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP no pudo escuchar.',
|
||||
'ai.externalMcp.http.legacySseNote': 'No se ofrece el HTTP+SSE heredado (2024-11-05). Usa esta URL de Streamable HTTP.',
|
||||
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
|
||||
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
|
||||
|
||||
@@ -751,8 +751,6 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.section.connection': 'Conexión',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verificar las claves de host SSH',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
|
||||
@@ -762,9 +760,6 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.connection.x11Display': 'Pantalla X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Dirección de pantalla local opcional para el reenvío X11. Déjala vacía para usar el valor predeterminado del sistema.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 o DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Proxy predeterminado',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Proxy guardado que se usa cuando el host no tiene uno propio y no hereda ninguno de un grupo. Ninguno conecta directamente.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'Ninguno',
|
||||
'settings.terminal.section.serverStats': 'Estadísticas del servidor (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Mostrar barra de información del host',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Muestra la dirección del host y la información del servidor sobre la terminal. Los botones de acción siguen disponibles cuando está oculta.',
|
||||
|
||||
@@ -109,8 +109,6 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'terminal.serverStats.gpu': 'Uso de GPU',
|
||||
'terminal.serverStats.gpuDevices': 'Uso de cada GPU',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Uso de memoria',
|
||||
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
|
||||
@@ -939,8 +939,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
|
||||
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
'hostDetails.proxy.none': 'Ninguno',
|
||||
'hostDetails.proxy.direct': 'Directo (sin proxy)',
|
||||
'hostDetails.proxy.directHint': 'No usa el proxy del grupo ni el predeterminado global',
|
||||
'hostDetails.proxy.edit': 'Editar proxy',
|
||||
'hostDetails.proxy.configure': 'Configurar proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'ai.externalMcp.approvalAlert',
|
||||
'ai.externalMcp.approvalAlert.description',
|
||||
'ai.externalMcp.approvalAlert.off',
|
||||
'ai.externalMcp.approvalAlert.unfocused',
|
||||
'ai.externalMcp.approvalAlert.always',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle',
|
||||
'ai.externalMcp.approvalAlert.notificationBody',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost',
|
||||
] as const;
|
||||
|
||||
test('External MCP approval alerts are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing approval alert labels`);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBody'], /\{tool\}/);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{tool\}/);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{host\}/);
|
||||
}
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const KEYS = [
|
||||
"ai.externalMcp.http.title",
|
||||
"ai.externalMcp.http.description",
|
||||
"ai.externalMcp.http.enable",
|
||||
"ai.externalMcp.http.port",
|
||||
"ai.externalMcp.http.portHint",
|
||||
"ai.externalMcp.http.allowCustomBind",
|
||||
"ai.externalMcp.http.allowCustomBind.description",
|
||||
"ai.externalMcp.http.listenAddress",
|
||||
"ai.externalMcp.http.listenAddress.placeholder",
|
||||
"ai.externalMcp.http.clientHost",
|
||||
"ai.externalMcp.http.clientHost.description",
|
||||
"ai.externalMcp.http.clientHost.placeholder",
|
||||
"ai.externalMcp.http.clientHostRequired",
|
||||
"ai.externalMcp.http.invalidClientHost",
|
||||
"ai.externalMcp.http.warning",
|
||||
"ai.externalMcp.http.url",
|
||||
"ai.externalMcp.http.token",
|
||||
"ai.externalMcp.http.regenerate",
|
||||
"ai.externalMcp.http.regenerateHint",
|
||||
"ai.externalMcp.http.wslHint",
|
||||
"ai.externalMcp.http.codexSnippet",
|
||||
"ai.externalMcp.http.claudeCommand",
|
||||
"ai.externalMcp.http.genericSnippet",
|
||||
"ai.externalMcp.http.envLine",
|
||||
"ai.externalMcp.http.notListening",
|
||||
"ai.externalMcp.http.listening",
|
||||
"ai.externalMcp.http.portInUse",
|
||||
"ai.externalMcp.http.requiresExternalMcp",
|
||||
"ai.externalMcp.http.invalidPort",
|
||||
"ai.externalMcp.http.invalidAddress",
|
||||
"ai.externalMcp.http.listenFailed",
|
||||
"ai.externalMcp.http.legacySseNote",
|
||||
] as const;
|
||||
|
||||
test("External MCP HTTP strings exist in every locale", () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, "zh-CN": zhCN, "zh-TW": zhTW, ru })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing HTTP MCP labels`);
|
||||
assert.match(messages["ai.externalMcp.http.portInUse"], /\{port\}/);
|
||||
}
|
||||
assert.match(zhTW["ai.externalMcp.http.port"], /連接埠/);
|
||||
assert.match(zhTW["ai.externalMcp.http.listenAddress"], /位址/);
|
||||
assert.match(zhTW["ai.externalMcp.http.clientHost"], /位址/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.clientHost"], /地址/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.port"], /端口/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.listenAddress"], /地址/);
|
||||
assert.match(zhTW["ai.externalMcp.http.wslHint"], /鏡像網路/);
|
||||
});
|
||||
@@ -303,14 +303,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'мин',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.',
|
||||
'ai.externalMcp.approvalAlert': 'Оповещения об одобрении',
|
||||
'ai.externalMcp.approvalAlert.description': 'Когда внешний MCP-клиент запрашивает подтверждение, показать системное уведомление. Пока Netcatty не в фокусе, панель задач или Dock привлекают внимание. Уведомление использует системный звук.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Выкл.',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'Без фокуса',
|
||||
'ai.externalMcp.approvalAlert.always': 'Всегда',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'Одобрение External MCP',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} ожидает одобрения',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} на {host} ожидает одобрения',
|
||||
'ai.externalMcp.silentSessions': 'Бесшумные сеансы MCP',
|
||||
'ai.externalMcp.silentSessions.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
|
||||
@@ -323,38 +315,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
'ai.externalMcp.http.port': 'Порт',
|
||||
'ai.externalMcp.http.portHint': 'Фиксированный порт, по умолчанию 47391. URL клиентов не меняется после перезапуска. Допустимый диапазон: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Слушать другой адрес',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'По умолчанию endpoint привязан к 127.0.0.1. Включите для WSL2 NAT и укажите 0.0.0.0 или IP vEthernet.',
|
||||
'ai.externalMcp.http.listenAddress': 'Адрес прослушивания',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Адрес клиента',
|
||||
'ai.externalMcp.http.clientHost.description': 'Подставляется в копируемые конфиги, если слушатель 0.0.0.0 или ::. Укажите доступный IP, например IP узла Windows.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Укажите IP, к которому должны подключаться клиенты. 0.0.0.0 недоступен для подключения.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'Адрес клиента должен быть доступным IP, а не 0.0.0.0 или ::.',
|
||||
'ai.externalMcp.http.warning': 'Прослушивание не только 127.0.0.1 открывает External MCP другим машинам, которые могут достичь этого адреса. Храните bearer token в секрете и не публикуйте этот порт.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Создать заново',
|
||||
'ai.externalMcp.http.regenerateHint': 'Выпускает новый bearer token. HTTP-клиенты должны использовать новый token. Файл discovery для stdio обновляется; уже подключенные stdio-клиенты остаются подключенными.',
|
||||
'ai.externalMcp.http.wslHint': 'При mirrored networking в WSL достаточно localhost. Иначе слушайте 0.0.0.0 или IP vEthernet и укажите IP узла Windows как адрес клиента.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'Общий JSON',
|
||||
'ai.externalMcp.http.envLine': 'Переменная окружения token для Codex',
|
||||
'ai.externalMcp.http.notListening': 'Не слушает',
|
||||
'ai.externalMcp.http.listening': 'Слушает',
|
||||
'ai.externalMcp.http.portInUse': 'Порт {port} уже занят.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Сначала включите External MCP, чтобы endpoint начал слушать.',
|
||||
'ai.externalMcp.http.invalidPort': 'Введите порт от 1024 до 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'Адрес прослушивания должен быть IP, например 0.0.0.0 или IP vEthernet.',
|
||||
'ai.externalMcp.http.listenFailed': 'Не удалось начать прослушивание HTTP.',
|
||||
'ai.externalMcp.http.legacySseNote': 'Устаревший HTTP+SSE (2024-11-05) не предоставляется. Используйте этот URL Streamable HTTP.',
|
||||
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
|
||||
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
|
||||
|
||||
@@ -791,8 +791,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
|
||||
@@ -800,9 +798,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display': 'Дисплей X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Прокси по умолчанию',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Сохраненный прокси, если у хоста нет своего и он не наследует прокси группы. «Нет» — прямое подключение.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'Нет',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
|
||||
|
||||
@@ -130,8 +130,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.gpu': 'Использование GPU',
|
||||
'terminal.serverStats.gpuDevices': 'Использование видеокарт',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
|
||||
'terminal.serverStats.memUsed': 'Использовано',
|
||||
|
||||
@@ -987,8 +987,6 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'Нет',
|
||||
'hostDetails.proxy.direct': 'Напрямую (без прокси)',
|
||||
'hostDetails.proxy.directHint': 'Не использует прокси группы или глобальный прокси по умолчанию',
|
||||
'hostDetails.proxy.edit': 'Редактировать прокси',
|
||||
'hostDetails.proxy.configure': 'Настроить прокси',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { esTerminalMessages } from "./es/terminal";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { zhCNVaultMessages } from "./zh-CN/vault";
|
||||
import { zhTWVaultMessages } from "./zh-TW/vault";
|
||||
|
||||
test("per-GPU stats strings exist in every locale", () => {
|
||||
const labels = [
|
||||
[enTerminalMessages, "GPU Device Usage", "GPU {{index}}"],
|
||||
[esTerminalMessages, "Uso de cada GPU", "GPU {{index}}"],
|
||||
[ruTerminalMessages, "Использование видеокарт", "GPU {{index}}"],
|
||||
[zhCNVaultMessages, "GPU 设备使用率", "GPU {{index}}"],
|
||||
[zhTWVaultMessages, "GPU 裝置使用率", "GPU {{index}}"],
|
||||
] as const;
|
||||
|
||||
for (const [messages, devices, indexLabel] of labels) {
|
||||
assert.equal(messages["terminal.serverStats.gpuDevices"], devices);
|
||||
assert.equal(messages["terminal.serverStats.gpuIndex"], indexLabel);
|
||||
}
|
||||
});
|
||||
@@ -47,38 +47,6 @@ test("localized settings include workspace focus indicator labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection legacy algorithm labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryLegacyAlgorithms",
|
||||
"settings.terminal.connection.temporaryLegacyAlgorithms.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary legacy algorithm labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryLegacyAlgorithms"], /临时连接允许旧版算法/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
|
||||
});
|
||||
|
||||
test("localized settings include the global default proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.defaultProxy",
|
||||
"settings.terminal.connection.defaultProxy.desc",
|
||||
"settings.terminal.connection.defaultProxy.none",
|
||||
"hostDetails.proxy.direct",
|
||||
"hostDetails.proxy.directHint",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing global default proxy labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include network proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.system.networkProxy.title",
|
||||
|
||||
@@ -356,14 +356,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': '分钟',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。',
|
||||
'ai.externalMcp.approvalAlert': '审批提醒',
|
||||
'ai.externalMcp.approvalAlert.description': '外部 MCP 客户端请求确认时显示系统通知。Netcatty 未聚焦时,任务栏或 Dock 会请求注意。通知使用系统提示音。',
|
||||
'ai.externalMcp.approvalAlert.off': '关闭',
|
||||
'ai.externalMcp.approvalAlert.unfocused': '未聚焦时',
|
||||
'ai.externalMcp.approvalAlert.always': '始终',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': '对外 MCP 审批',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待审批',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待审批',
|
||||
'ai.externalMcp.silentSessions': 'AI 会话静默运行',
|
||||
'ai.externalMcp.silentSessions.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
|
||||
@@ -376,38 +368,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
'ai.externalMcp.http.port': '端口',
|
||||
'ai.externalMcp.http.portHint': '固定端口,默认 47391。重启后客户端地址保持不变。允许范围:1024–65535。',
|
||||
'ai.externalMcp.http.allowCustomBind': '监听其他地址',
|
||||
'ai.externalMcp.http.allowCustomBind.description': '默认只绑定 127.0.0.1。WSL2 NAT 可改为监听 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenAddress': '监听地址',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': '客户端地址',
|
||||
'ai.externalMcp.http.clientHost.description': '监听 0.0.0.0 或 :: 时,复制的配置会使用这个地址。请填写客户端能访问的 IP,例如 Windows 主机 IP。',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': '请填写客户端要连接的 IP。0.0.0.0 不是可连接的地址。',
|
||||
'ai.externalMcp.http.invalidClientHost': '客户端地址必须是可连接的 IP,不能是 0.0.0.0 或 ::。',
|
||||
'ai.externalMcp.http.warning': '监听 127.0.0.1 以外的地址会让能访问这台电脑的其他设备使用对外 MCP。请保管 bearer token,不要把这个端口暴露到公网。',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': '重新生成',
|
||||
'ai.externalMcp.http.regenerateHint': '生成新的 bearer token。HTTP 客户端必须改用新 token。stdio 的 discovery 文件会一起更新;已经通过 stdio 连上的客户端保持连接。',
|
||||
'ai.externalMcp.http.wslHint': 'WSL 若使用镜像网络,localhost 即可连接。否则请监听 0.0.0.0 或 vEthernet IP,并把客户端地址设为 Windows 主机 IP。',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': '通用 JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token 环境变量',
|
||||
'ai.externalMcp.http.notListening': '未监听',
|
||||
'ai.externalMcp.http.listening': '监听中',
|
||||
'ai.externalMcp.http.portInUse': '端口 {port} 已被占用。',
|
||||
'ai.externalMcp.http.requiresExternalMcp': '请先打开对外 MCP,HTTP 端点才会开始监听。',
|
||||
'ai.externalMcp.http.invalidPort': '请输入 1024 到 65535 的端口。',
|
||||
'ai.externalMcp.http.invalidAddress': '监听地址必须是 IP,例如 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP 监听失败。',
|
||||
'ai.externalMcp.http.legacySseNote': '不提供旧版 HTTP+SSE(2024-11-05)。请使用这个 Streamable HTTP 地址。',
|
||||
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '写操作权限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
|
||||
|
||||
@@ -454,11 +454,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
|
||||
'settings.terminal.connection.defaultProxy': '默认代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
|
||||
'settings.terminal.connection.defaultProxy.none': '无',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
|
||||
@@ -363,8 +363,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.direct': '直连(不使用代理)',
|
||||
'hostDetails.proxy.directHint': '不使用分组或全局默认代理',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
'hostDetails.envVars': '环境变量',
|
||||
@@ -534,8 +532,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.gpuDevices': 'GPU 设备使用率',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
|
||||
@@ -346,14 +346,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': '分鐘',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '當 MCP 用戶端開啟主機連線時,將主視窗切換到前景。關閉後可不受打擾地繼續目前工作。',
|
||||
'ai.externalMcp.approvalAlert': '核准提醒',
|
||||
'ai.externalMcp.approvalAlert.description': '外部 MCP 用戶端請求確認時顯示系統通知。Netcatty 未聚焦時,工作列或 Dock 會要求注意。通知使用系統提示音。',
|
||||
'ai.externalMcp.approvalAlert.off': '關閉',
|
||||
'ai.externalMcp.approvalAlert.unfocused': '未聚焦時',
|
||||
'ai.externalMcp.approvalAlert.always': '一律',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': '對外 MCP 核准',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待核准',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待核准',
|
||||
'ai.externalMcp.silentSessions': 'AI 工作階段靜默執行',
|
||||
'ai.externalMcp.silentSessions.description': 'AI 開啟的工作階段不會出現在分頁列,重新啟動後也不會還原。可隨時在系統匣面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時',
|
||||
@@ -366,38 +358,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
'ai.externalMcp.http.port': '連接埠',
|
||||
'ai.externalMcp.http.portHint': '固定連接埠,預設 47391。重新啟動後用戶端網址維持不變。允許範圍:1024–65535。',
|
||||
'ai.externalMcp.http.allowCustomBind': '監聽其他位址',
|
||||
'ai.externalMcp.http.allowCustomBind.description': '預設只綁定 127.0.0.1。WSL2 NAT 可改為監聽 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenAddress': '監聽位址',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': '用戶端位址',
|
||||
'ai.externalMcp.http.clientHost.description': '監聽 0.0.0.0 或 :: 時,複製的設定會使用這個位址。請填用戶端連得到的 IP,例如 Windows 主機 IP。',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': '請填用戶端要連線的 IP。0.0.0.0 不是可連線的位址。',
|
||||
'ai.externalMcp.http.invalidClientHost': '用戶端位址必須是可連線的 IP,不能是 0.0.0.0 或 ::。',
|
||||
'ai.externalMcp.http.warning': '監聽 127.0.0.1 以外的位址,會讓能連到這台電腦的其他裝置使用對外 MCP。請保管 bearer token,不要把這個連接埠暴露到公開網路。',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': '重新產生',
|
||||
'ai.externalMcp.http.regenerateHint': '產生新的 bearer token。HTTP 用戶端必須改用新 token。stdio 的 discovery 檔會一併更新;已經透過 stdio 連上的用戶端維持連線。',
|
||||
'ai.externalMcp.http.wslHint': 'WSL 若使用鏡像網路,localhost 即可連線。否則請監聽 0.0.0.0 或 vEthernet IP,並把用戶端位址設成 Windows 主機 IP。',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': '通用 JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token 環境變數',
|
||||
'ai.externalMcp.http.notListening': '未監聽',
|
||||
'ai.externalMcp.http.listening': '監聽中',
|
||||
'ai.externalMcp.http.portInUse': '連接埠 {port} 已被占用。',
|
||||
'ai.externalMcp.http.requiresExternalMcp': '請先開啟對外 MCP,HTTP 端點才會開始監聽。',
|
||||
'ai.externalMcp.http.invalidPort': '請輸入 1024 到 65535 的連接埠。',
|
||||
'ai.externalMcp.http.invalidAddress': '監聽位址必須是 IP,例如 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP 監聽失敗。',
|
||||
'ai.externalMcp.http.legacySseNote': '不提供舊版 HTTP+SSE(2024-11-05)。請使用這個 Streamable HTTP 網址。',
|
||||
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '寫入權限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
|
||||
|
||||
@@ -454,11 +454,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
|
||||
'settings.terminal.connection.defaultProxy': '預設代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
|
||||
'settings.terminal.connection.defaultProxy.none': '無',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉分頁或連線恢復。',
|
||||
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
|
||||
|
||||
@@ -363,8 +363,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': '設定代理主機',
|
||||
'hostDetails.proxy': '透過 HTTP/SOCKS5/指令代理',
|
||||
'hostDetails.proxy.none': '無',
|
||||
'hostDetails.proxy.direct': '直連(不使用代理)',
|
||||
'hostDetails.proxy.directHint': '不使用群組或全域預設代理',
|
||||
'hostDetails.proxy.edit': '編輯代理',
|
||||
'hostDetails.proxy.configure': '設定代理',
|
||||
'hostDetails.envVars': '環境變數',
|
||||
@@ -534,8 +532,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.gpuDevices': 'GPU 裝置使用率',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
'terminal.serverStats.memoryDetails': '記憶體詳情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { presentExternalMcpApprovalNotification } from './externalMcpApprovalAlert.ts';
|
||||
|
||||
const t = (key: string, values?: Record<string, string | number | boolean | null | undefined>) => {
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationTitle') return 'External MCP approval';
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationBody') return `${values?.tool} is waiting for approval`;
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationBodyOnHost') {
|
||||
return `${values?.tool} on ${values?.host} is waiting for approval`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
test('approval alerts stay quiet when the preference is off', () => {
|
||||
const calls: unknown[] = [];
|
||||
const shown = presentExternalMcpApprovalNotification({
|
||||
toolName: 'sftp.write',
|
||||
dedupeKey: 'mcp_approval_1',
|
||||
mode: 'off',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('unfocused approval alerts name the tool and host and defer focus to the main process', () => {
|
||||
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
|
||||
const shown = presentExternalMcpApprovalNotification({
|
||||
toolName: 'sftp.write',
|
||||
hostLabel: 'bastion',
|
||||
dedupeKey: 'mcp_approval_2',
|
||||
mode: 'unfocused',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(shown, true);
|
||||
assert.deepEqual(calls, [{
|
||||
title: 'External MCP approval',
|
||||
body: 'sftp.write on bastion is waiting for approval',
|
||||
dedupeKey: 'mcp_approval_2',
|
||||
whenUnfocused: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('approval alerts show the MCP tool name for an internal rpc id', () => {
|
||||
const calls: Array<{ body: string }> = [];
|
||||
const shown = presentExternalMcpApprovalNotification({
|
||||
toolName: 'netcatty/exec',
|
||||
hostLabel: '127.0.0.1',
|
||||
dedupeKey: 'mcp_approval_exec',
|
||||
mode: 'always',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(shown, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].body, /terminal_execute/);
|
||||
assert.equal(calls[0].body, 'terminal_execute on 127.0.0.1 is waiting for approval');
|
||||
});
|
||||
|
||||
test('approval alerts keep an id that has no MCP tool name', () => {
|
||||
const calls: Array<{ body: string }> = [];
|
||||
presentExternalMcpApprovalNotification({
|
||||
toolName: 'netcatty/not-a-tool',
|
||||
dedupeKey: 'mcp_approval_unknown',
|
||||
mode: 'unfocused',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(calls[0].body, 'netcatty/not-a-tool is waiting for approval');
|
||||
});
|
||||
|
||||
test('always approval alerts notify even while a window may be focused', () => {
|
||||
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
|
||||
presentExternalMcpApprovalNotification({
|
||||
toolName: 'terminal.execute',
|
||||
dedupeKey: 'mcp_approval_3',
|
||||
mode: 'always',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.deepEqual(calls, [{
|
||||
title: 'External MCP approval',
|
||||
body: 'terminal.execute is waiting for approval',
|
||||
dedupeKey: 'mcp_approval_3',
|
||||
whenUnfocused: false,
|
||||
}]);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { ApprovalRequest } from '../../infrastructure/ai/shared/approvalGate';
|
||||
import cattyToolSpecs from '../../infrastructure/ai/harness/generated/cattyToolSpecs.json';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
} from './useExternalMcpToggleState';
|
||||
|
||||
type CatalogToolSpec = {
|
||||
toolName?: string;
|
||||
rpcMethod?: string | null;
|
||||
};
|
||||
|
||||
const mcpToolNameByRpcMethod = new Map<string, string>();
|
||||
for (const spec of cattyToolSpecs as CatalogToolSpec[]) {
|
||||
if (!spec.rpcMethod || !spec.toolName || mcpToolNameByRpcMethod.has(spec.rpcMethod)) continue;
|
||||
mcpToolNameByRpcMethod.set(spec.rpcMethod, spec.toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Name shown on the External MCP approval card.
|
||||
* Catalog MCP tool names win over internal RPC ids such as `netcatty/exec`.
|
||||
* An id with no catalog tool name is shown as-is.
|
||||
*/
|
||||
export function externalMcpApprovalToolLabel(toolName: string | undefined): string {
|
||||
const raw = toolName?.trim() || '';
|
||||
if (!raw) return '';
|
||||
return mcpToolNameByRpcMethod.get(raw) || raw;
|
||||
}
|
||||
|
||||
export type ExternalMcpApprovalNotification = {
|
||||
title: string;
|
||||
body: string;
|
||||
dedupeKey: string;
|
||||
whenUnfocused: boolean;
|
||||
};
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
values?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Build the system notification for one External MCP approval.
|
||||
* Returns null when the preference is off. Focus gating stays in the main
|
||||
* process so every Netcatty window is considered, not only the sender.
|
||||
*/
|
||||
export function presentExternalMcpApprovalNotification(options: {
|
||||
toolName: string;
|
||||
hostLabel?: string;
|
||||
dedupeKey: string;
|
||||
mode: ExternalMcpApprovalAlertMode;
|
||||
t: Translate;
|
||||
notify: (payload: ExternalMcpApprovalNotification) => void;
|
||||
}): boolean {
|
||||
if (options.mode === 'off') return false;
|
||||
const tool = externalMcpApprovalToolLabel(options.toolName);
|
||||
const host = options.hostLabel?.trim() || '';
|
||||
const body = host
|
||||
? options.t('ai.externalMcp.approvalAlert.notificationBodyOnHost', { tool, host })
|
||||
: options.t('ai.externalMcp.approvalAlert.notificationBody', { tool });
|
||||
options.notify({
|
||||
title: options.t('ai.externalMcp.approvalAlert.notificationTitle'),
|
||||
body,
|
||||
dedupeKey: options.dedupeKey,
|
||||
whenUnfocused: options.mode === 'unfocused',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function publishExternalMcpApprovalAlertMode(): void {
|
||||
void netcattyBridge.get()?.setExternalMcpApprovalAlertMode?.({
|
||||
mode: readExternalMcpApprovalAlertMode(),
|
||||
});
|
||||
}
|
||||
|
||||
export function notifyExternalMcpApproval(
|
||||
request: Pick<ApprovalRequest, 'toolName' | 'toolCallId' | 'target'>,
|
||||
t: Translate,
|
||||
): boolean {
|
||||
return presentExternalMcpApprovalNotification({
|
||||
toolName: request.toolName,
|
||||
hostLabel: request.target?.label || request.target?.hostname || '',
|
||||
dedupeKey: request.toolCallId,
|
||||
mode: readExternalMcpApprovalAlertMode(),
|
||||
t,
|
||||
notify: (payload) => {
|
||||
void netcattyBridge.get()?.showSystemNotification?.(payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,11 +13,10 @@ type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "last
|
||||
|
||||
export interface CaptureInheritedCwdOptions {
|
||||
/**
|
||||
* The session's live tracked cwd (OSC 7 or the post-command cwd probe),
|
||||
* sourced from the terminal-state cwd map rather than the session object.
|
||||
* This is the freshest value and the one that reflects `cd`s in a running
|
||||
* local terminal. The live value stays off `TerminalSession.lastCwd` until
|
||||
* session restore persists it.
|
||||
* The session's live tracked cwd (OSC 7), sourced from the terminal-state
|
||||
* cwd map rather than the session object. This is the freshest value and the
|
||||
* only one that reflects `cd`s in a running LOCAL terminal (whose live cwd is
|
||||
* never mirrored onto `TerminalSession.lastCwd`).
|
||||
*/
|
||||
liveCwd?: string;
|
||||
/**
|
||||
@@ -36,7 +35,7 @@ export const DEFAULT_INHERITED_CWD_PROBE_TIMEOUT_MS = 1500;
|
||||
/**
|
||||
* Resolve the working directory a clone/split should inherit from its source.
|
||||
*
|
||||
* Priority: live tracked cwd (OSC 7 or post-command probe) -> live SSH `/proc` probe (when allowed)
|
||||
* Priority: live tracked cwd (OSC 7) -> live SSH `/proc` probe (when allowed)
|
||||
* -> tracked `lastCwd` snapshot -> local `localStartDir`. The probe is raced
|
||||
* against a short timeout so a slow/wedged connection can't block tab creation,
|
||||
* and is skipped entirely when `allowSshProbe` is false. Returns undefined when
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import {
|
||||
@@ -59,10 +58,6 @@ import {
|
||||
normalizeHttpNetworkProxySettings,
|
||||
type HttpNetworkProxySettings,
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import {
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
isValidUiFontId,
|
||||
@@ -103,7 +98,6 @@ interface UseSettingsIpcSyncParams {
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setGlobalDefaultProxyProfileIdState: Dispatch<SetStateAction<string | undefined>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
@@ -152,7 +146,6 @@ export function useSettingsIpcSync({
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -299,11 +292,6 @@ export function useSettingsIpcSync({
|
||||
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
|
||||
));
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -366,7 +354,6 @@ export function useSettingsIpcSync({
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
|
||||
@@ -712,127 +712,3 @@ test("owned cancelled directory settles recoverable children without dropping co
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const liveSize of [37, 3, 0]) {
|
||||
test(`same-pane local copy discovers ${liveSize} bytes after the listed file changes`, async () => {
|
||||
const pane = makePane("right");
|
||||
pane.files = [{ ...makePane("left").files[0], name: "edited.txt", size: 15 }];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statLocal: async (path: string) => path.endsWith("edited.txt")
|
||||
? { type: "file", size: liveSize, lastModified: 1758000002000 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => { started.push(options); return {}; },
|
||||
pauseTransfer: async () => ({ success: false }),
|
||||
resumeTransfer: async () => ({ success: false }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: `local-edited-${liveSize}`,
|
||||
getActivePane: () => pane,
|
||||
getPaneByConnectionId: () => pane,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "edited.txt", isDirectory: false }], "right", "right");
|
||||
});
|
||||
assert.equal(ops!.conflicts.length, 1);
|
||||
assert.equal(ops!.conflicts[0].newSize, liveSize, "conflict must show the saved file's current size");
|
||||
assert.equal(ops!.conflicts[0].newModified, 1758000002000);
|
||||
await act(async () => {
|
||||
await ops!.resolveConflict(ops!.conflicts[0].transferId, "duplicate", false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
assert.equal(started.length, 1);
|
||||
assert.equal(started[0].totalBytes, liveSize || undefined, "copy must not retain the cached 15-byte size");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("no-conflict local copy awaits discovery so a late stat cannot overwrite the settled total", async () => {
|
||||
const pane = makePane("right");
|
||||
pane.files = [{ ...makePane("left").files[0], name: "late.txt", size: 15 }];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
// Target conflict stat: missing path means the copy has no conflict.
|
||||
lstatLocal: async () => null,
|
||||
statLocal: async (path: string) => {
|
||||
if (!path.endsWith("late.txt")) return null;
|
||||
// Let the stream finish before the stat resolves — fired-and-forgotten
|
||||
// discovery used to patch the terminal task with this stale total.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return { type: "file", size: 9_999, lastModified: 1758000002000 };
|
||||
},
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: 15,
|
||||
totalBytes: 15,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false }),
|
||||
resumeTransfer: async () => ({ success: false }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "local-late-stat",
|
||||
getActivePane: () => pane,
|
||||
getPaneByConnectionId: () => pane,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "late.txt", isDirectory: false }], "right", "right");
|
||||
});
|
||||
// Give the no longer pending deferred stat time to land if it leaked.
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)); });
|
||||
const row = sftpTransferCenterStore.getSnapshot().tasks.find(
|
||||
(candidate) => candidate.originalFileName === "late.txt",
|
||||
);
|
||||
assert.ok(row);
|
||||
assert.equal(row.status, "completed");
|
||||
assert.equal(
|
||||
row.transferredBytes,
|
||||
row.totalBytes,
|
||||
"a deferred stat must not desync the completed row's total from its transferred bytes",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -811,8 +811,8 @@ export const useSftpTransfers = ({
|
||||
})();
|
||||
|
||||
// For single files: start size discovery without blocking the conflict
|
||||
// check, but always settle the promise before any dialog or transfer
|
||||
// launch (bounded per-task; the task is already visible/cancellable).
|
||||
// check, but keep the promise so an actual conflict can await fresh
|
||||
// size metadata before the dialog is shown.
|
||||
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
|
||||
|
||||
// Only await conflict check (fast single stat call)
|
||||
@@ -823,23 +823,19 @@ export const useSftpTransfers = ({
|
||||
// null means the plan size is still unknown. A verified 0-byte stat is 0,
|
||||
// not null, so an empty file does not pick up a stale listing size.
|
||||
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
|
||||
if (sizeDiscoveryPromise) {
|
||||
if (conflict && sizeDiscoveryPromise) {
|
||||
// The task's totalBytes may still be 0 here because discovery ran
|
||||
// deferred and updateTask never mutates the local task copy. Await
|
||||
// discovery even without a conflict so its statLocal updateTask cannot
|
||||
// resolve after the transfer finished and overwrite the bridge's
|
||||
// authoritative total with a stale late size (the conflict dialog also
|
||||
// needs it: #3559).
|
||||
// discovery (bounded per-task; the task is already visible/cancellable)
|
||||
// so the overwrite dialog shows the real source size, not 0 (#3559).
|
||||
const discovered = await sizeDiscoveryPromise;
|
||||
// Cancel during this stat must win before an apply-to-all Replace
|
||||
// deletes an existing symlink.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (discovered && discovered.size >= 0) {
|
||||
if (conflict) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1271,24 +1267,25 @@ export const useSftpTransfers = ({
|
||||
// Prefer the fresh re-stat for remote files: a cached listing size can
|
||||
// be stale and undersized, which would silently truncate downloads
|
||||
// (#3559). Remote files without a live stat use plan 0 so the bridge
|
||||
// measures the live size. Local files also start unknown so deferred
|
||||
// discovery reads their current size after edits, not the cached listing.
|
||||
// measures the live size; local sources keep the pane metadata.
|
||||
const fileEntry = fileEntryMap?.get(file.name);
|
||||
const freshMetadata = freshRemoteMetadata.get(file.name);
|
||||
const fileSize = file.isDirectory
|
||||
? 0
|
||||
: freshMetadata
|
||||
? freshMetadata.size
|
||||
: 0;
|
||||
// Only keep an mtime paired with a live re-stat or directory totals.
|
||||
// A file without a live stat must stay fully unknown
|
||||
// (size 0 AND mtime 0 in the plan):
|
||||
: sourcePane.connection!.isLocal
|
||||
? (fileEntry?.size ?? 0)
|
||||
: 0;
|
||||
// Only keep an mtime that is paired with trusted metadata (live re-stat,
|
||||
// local source size, or directory totals). A remote file whose re-stat
|
||||
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
|
||||
// discoverTransferSize and the conflict check both treat a nonzero
|
||||
// sourceLastModified as proof the size is already known, so a stale
|
||||
// mtime would suppress the deferred source re-stat. The overwrite
|
||||
// dialog still gets the pane listing's cached entry via
|
||||
// discoverTransferSize's cachedListingStatForDialog fallback.
|
||||
const sourceLastModified = !file.isDirectory && !freshMetadata
|
||||
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
|
||||
? 0
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
|
||||
@@ -3,17 +3,6 @@ import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
readExternalMcpHttpEnabled,
|
||||
readExternalMcpHttpListenAddress,
|
||||
readExternalMcpHttpPort,
|
||||
readExternalMcpHttpSettings,
|
||||
writeExternalMcpHttpAllowCustomBind,
|
||||
writeExternalMcpHttpEnabled,
|
||||
writeExternalMcpHttpListenAddress,
|
||||
writeExternalMcpHttpPort,
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
@@ -22,11 +11,9 @@ import {
|
||||
getExternalMcpStartupReadyWaiterCountForTests,
|
||||
isExternalMcpStartupReady,
|
||||
markExternalMcpStartupReady,
|
||||
normalizeExternalMcpApprovalAlertMode,
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpSilentSessions,
|
||||
readExternalMcpStoredEnabled,
|
||||
@@ -38,7 +25,6 @@ import {
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpSilentSessions,
|
||||
} from './useExternalMcpToggleState.ts';
|
||||
@@ -83,22 +69,6 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode(null), 'unfocused');
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode('always'), 'always');
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode('nope'), 'unfocused');
|
||||
});
|
||||
|
||||
it('stores the approval alert preference and defaults to unfocused', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'unfocused');
|
||||
assert.equal(writeExternalMcpApprovalAlertMode('off'), 'off');
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'off');
|
||||
assert.equal(writeExternalMcpApprovalAlertMode('always'), 'always');
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'always');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
for (const savedMinutes of [null, 30]) {
|
||||
@@ -425,30 +395,3 @@ describe('syncExternalMcpStartupState generation guard', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("external MCP HTTP settings", () => {
|
||||
it("defaults HTTP off on loopback and round-trips the explicit listen option", () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpHttpSettings(), {
|
||||
enabled: false,
|
||||
port: 47391,
|
||||
allowCustomBind: false,
|
||||
listenAddress: "0.0.0.0",
|
||||
clientHost: "",
|
||||
});
|
||||
assert.equal(readExternalMcpHttpEnabled(), false);
|
||||
writeExternalMcpHttpEnabled(true);
|
||||
assert.equal(writeExternalMcpHttpPort(80), 47391);
|
||||
assert.equal(writeExternalMcpHttpPort(48080), 48080);
|
||||
writeExternalMcpHttpAllowCustomBind(true);
|
||||
writeExternalMcpHttpListenAddress("172.22.0.1");
|
||||
assert.equal(readExternalMcpHttpEnabled(), true);
|
||||
assert.equal(readExternalMcpHttpPort(), 48080);
|
||||
assert.equal(readExternalMcpHttpAllowCustomBind(), true);
|
||||
assert.equal(readExternalMcpHttpListenAddress(), "172.22.0.1");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
@@ -15,15 +9,11 @@ import {
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_EXTERNAL_MCP_HTTP_PORT } from '../../domain/externalMcpHttp';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
export type ExternalMcpApprovalAlertMode = 'off' | 'unfocused' | 'always';
|
||||
|
||||
export const DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE: ExternalMcpApprovalAlertMode = 'unfocused';
|
||||
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
@@ -110,23 +100,10 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
allowCustomBind: boolean;
|
||||
listenAddress: string;
|
||||
clientHost: string;
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
httpListenAddress?: string;
|
||||
httpClientHost?: string;
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
@@ -206,26 +183,6 @@ export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN);
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpApprovalAlertMode(value: string | null): ExternalMcpApprovalAlertMode {
|
||||
return value === 'off' || value === 'always' || value === 'unfocused'
|
||||
? value
|
||||
: DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE;
|
||||
}
|
||||
|
||||
/** System notification preference for confirm-mode External MCP approvals. */
|
||||
export function readExternalMcpApprovalAlertMode(): ExternalMcpApprovalAlertMode {
|
||||
return normalizeExternalMcpApprovalAlertMode(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpApprovalAlertMode(mode: ExternalMcpApprovalAlertMode): ExternalMcpApprovalAlertMode {
|
||||
const normalized = normalizeExternalMcpApprovalAlertMode(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */
|
||||
export function readExternalMcpSilentSessions(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false;
|
||||
@@ -260,109 +217,6 @@ export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
|
||||
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
|
||||
|
||||
export function normalizeExternalMcpHttpPort(value: number | null): number {
|
||||
if (!Number.isInteger(value)) return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
|
||||
if (value < MIN_EXTERNAL_MCP_HTTP_PORT || value > MAX_EXTERNAL_MCP_HTTP_PORT) {
|
||||
return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpHttpListenAddress(value: string | null): string {
|
||||
const address = (value ?? '').trim();
|
||||
if (!address) return '0.0.0.0';
|
||||
return address;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpEnabled(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED) ?? false;
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpEnabled(enabled: boolean): boolean {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED, enabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpPort(): number {
|
||||
return normalizeExternalMcpHttpPort(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpPort(port: number): number {
|
||||
const normalized = normalizeExternalMcpHttpPort(port);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpAllowCustomBind(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND) ?? false;
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpAllowCustomBind(allowCustomBind: boolean): boolean {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND, allowCustomBind);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND);
|
||||
return allowCustomBind;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpListenAddress(): string {
|
||||
return normalizeExternalMcpHttpListenAddress(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpListenAddress(listenAddress: string): string {
|
||||
const normalized = normalizeExternalMcpHttpListenAddress(listenAddress);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpHttpClientHost(value: string | null): string {
|
||||
return (value ?? '').trim();
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpClientHost(): string {
|
||||
return normalizeExternalMcpHttpClientHost(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpClientHost(clientHost: string): string {
|
||||
const normalized = normalizeExternalMcpHttpClientHost(clientHost);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpSettings(): ExternalMcpHttpSettings {
|
||||
return {
|
||||
enabled: readExternalMcpHttpEnabled(),
|
||||
port: readExternalMcpHttpPort(),
|
||||
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
};
|
||||
}
|
||||
|
||||
function httpConfigFromSettings(settings: ExternalMcpHttpSettings): Pick<
|
||||
ExternalMcpConfig,
|
||||
'httpEnabled' | 'httpPort' | 'httpAllowCustomBind' | 'httpListenAddress' | 'httpClientHost'
|
||||
> {
|
||||
return {
|
||||
httpEnabled: settings.enabled,
|
||||
httpPort: settings.port,
|
||||
httpAllowCustomBind: settings.allowCustomBind,
|
||||
httpListenAddress: settings.listenAddress,
|
||||
httpClientHost: settings.clientHost,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
@@ -385,19 +239,11 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
allowCustomBind: false,
|
||||
listenAddress: '0.0.0.0',
|
||||
clientHost: '',
|
||||
},
|
||||
}: {
|
||||
enabled: boolean;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -406,7 +252,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
storedEnabled,
|
||||
@@ -420,17 +265,14 @@ export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
const http = readExternalMcpHttpSettings();
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
...httpConfigFromSettings(http),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import {
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
subscribeActiveGlobalDefaultProxyProfileId,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
|
||||
export const useActiveGlobalDefaultProxyProfileId = (): string | undefined => {
|
||||
return useSyncExternalStore(
|
||||
subscribeActiveGlobalDefaultProxyProfileId,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
);
|
||||
};
|
||||
@@ -24,14 +24,6 @@ export interface ProcessInfo {
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface GpuDeviceStats {
|
||||
index: number;
|
||||
name: string;
|
||||
utilization: number | null; // percent, null when nvidia-smi reports N/A
|
||||
memoryUsed: number | null; // MiB
|
||||
memoryTotal: number | null; // MiB
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
hostname?: string; // Remote hostname
|
||||
osName?: string; // Remote operating system name
|
||||
@@ -45,7 +37,6 @@ export interface ServerStats {
|
||||
gpuName: string | null; // First GPU name (tooltip)
|
||||
gpuMemUsed: number | null; // Summed VRAM used in MB
|
||||
gpuMemTotal: number | null; // Summed VRAM total in MB
|
||||
gpus: GpuDeviceStats[]; // Per-GPU utilization and VRAM
|
||||
memTotal: number | null; // Total memory in MB
|
||||
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
|
||||
memFree: number | null; // Free memory in MB
|
||||
@@ -125,7 +116,6 @@ function createEmptyServerStats(): ServerStats {
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
gpus: [],
|
||||
memTotal: null,
|
||||
memUsed: null,
|
||||
memFree: null,
|
||||
@@ -228,7 +218,6 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
gpuName: stats.gpuName ?? null,
|
||||
gpuMemUsed: stats.gpuMemUsed ?? null,
|
||||
gpuMemTotal: stats.gpuMemTotal ?? null,
|
||||
gpus: Array.isArray(stats.gpus) ? stats.gpus : [],
|
||||
memTotal: stats.memTotal ?? null,
|
||||
memUsed: stats.memUsed ?? null,
|
||||
memFree: stats.memFree ?? null,
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from "../../domain/globalDefaultProxy.ts";
|
||||
import { STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { useSettingsState } from "./useSettingsState.ts";
|
||||
|
||||
type Notification = { key: string; value: unknown };
|
||||
|
||||
const withSettingsDom = async (
|
||||
run: (env: { dom: JSDOM; root: Root; notifications: Notification[] }) => Promise<void>,
|
||||
) => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
|
||||
const globalAny = globalThis as Record<string, unknown>;
|
||||
const saved = {
|
||||
window: globalAny.window,
|
||||
document: globalAny.document,
|
||||
localStorage: globalAny.localStorage,
|
||||
actEnv: globalAny.IS_REACT_ACT_ENVIRONMENT,
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
|
||||
const notifications: Notification[] = [];
|
||||
dom.window.netcatty = {
|
||||
notifySettingsChanged: (payload: Notification) => {
|
||||
notifications.push(payload);
|
||||
},
|
||||
} as Window["netcatty"];
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-1");
|
||||
|
||||
const container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await run({ dom, root, notifications });
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
publishActiveGlobalDefaultProxyProfileId(undefined);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
for (const key of ["window", "document", "localStorage"] as const) {
|
||||
if (saved[key] === undefined) delete globalAny[key];
|
||||
else {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: saved[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: saved.actEnv,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const proxyNotifications = (notifications: Notification[]) => (
|
||||
notifications.filter((entry) => entry.key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE)
|
||||
);
|
||||
|
||||
test("closing a second settings instance still persists a cleared global default proxy", async () => {
|
||||
await withSettingsDom(async ({ dom, root, notifications }) => {
|
||||
let showSecond = true;
|
||||
function SettingsProbe() {
|
||||
useSettingsState({ enableSystemEffects: false });
|
||||
return null;
|
||||
}
|
||||
function Harness() {
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(SettingsProbe),
|
||||
showSecond ? React.createElement(SettingsProbe) : null,
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-1");
|
||||
|
||||
showSecond = false;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
notifications.length = 0;
|
||||
await act(async () => {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
});
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
||||
assert.deepEqual(proxyNotifications(notifications), [
|
||||
{ key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, value: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("a peer session window follows global default proxy storage without settings sync", async () => {
|
||||
await withSettingsDom(async ({ dom, root, notifications }) => {
|
||||
function PeerSettings() {
|
||||
useSettingsState({ enableSettingsSync: false, enableSystemEffects: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(PeerSettings));
|
||||
});
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
|
||||
|
||||
notifications.length = 0;
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-2");
|
||||
await act(async () => {
|
||||
dom.window.dispatchEvent(new dom.window.StorageEvent("storage", {
|
||||
key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
newValue: "profile-2",
|
||||
storageArea: dom.window.localStorage,
|
||||
}));
|
||||
});
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-2");
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-2");
|
||||
assert.deepEqual(proxyNotifications(notifications), []);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
@@ -105,11 +104,6 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
registerGlobalDefaultProxyProfileSetter,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
resolveSftpTransferConcurrency,
|
||||
@@ -582,13 +576,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
|
||||
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
|
||||
});
|
||||
const [globalDefaultProxyProfileId, setGlobalDefaultProxyProfileIdState] = useState<string | undefined>(() => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(
|
||||
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
|
||||
);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
return next;
|
||||
});
|
||||
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
|
||||
setHttpNetworkProxyState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
@@ -811,37 +798,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}
|
||||
}, [enableSettingsSync]);
|
||||
|
||||
const setGlobalDefaultProxyProfileId = useCallback((value: string | undefined) => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
setGlobalDefaultProxyProfileIdState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishActiveGlobalDefaultProxyProfileId(globalDefaultProxyProfileId);
|
||||
}, [globalDefaultProxyProfileId]);
|
||||
|
||||
useEffect(
|
||||
() => registerGlobalDefaultProxyProfileSetter(setGlobalDefaultProxyProfileId),
|
||||
[setGlobalDefaultProxyProfileId],
|
||||
);
|
||||
|
||||
// Peer session windows set enableSettingsSync false, which skips IPC sync.
|
||||
// This setting still has to follow localStorage or an already-open session
|
||||
// window keeps the proxy id it read at mount.
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) return;
|
||||
const next = normalizeGlobalDefaultProxyProfileId(event.newValue);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const setSftpTransferConcurrency = useCallback((value: number) => {
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
@@ -1125,11 +1081,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
|
||||
),
|
||||
);
|
||||
const nextGlobalDefaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(
|
||||
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
|
||||
);
|
||||
publishActiveGlobalDefaultProxyProfileId(nextGlobalDefaultProxyProfileId);
|
||||
setGlobalDefaultProxyProfileIdState(nextGlobalDefaultProxyProfileId);
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
@@ -1278,7 +1229,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -2263,8 +2213,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
@@ -2287,7 +2235,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, globalDefaultProxyProfileId,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -454,30 +454,6 @@ test("note appearance settings survive sync and trigger auto-sync", async () =>
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
||||
});
|
||||
|
||||
test("buildSyncPayload round-trips the global default proxy profile", async () => {
|
||||
localStorage.clear();
|
||||
const absent = buildSyncPayload(vault([]));
|
||||
assert.equal(absent.settings?.defaultProxyProfileId, undefined);
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "proxy-1");
|
||||
const selected = buildSyncPayload(vault([]));
|
||||
assert.equal(selected.settings?.defaultProxyProfileId, "proxy-1");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "");
|
||||
const cleared = buildSyncPayload(vault([]));
|
||||
assert.equal(cleared.settings?.defaultProxyProfileId, "");
|
||||
|
||||
localStorage.clear();
|
||||
await applySyncPayload(selected, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "proxy-1");
|
||||
|
||||
await applySyncPayload(cleared, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
||||
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
));
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
|
||||
import { normalizeGlobalDefaultProxyProfileId } from '../domain/globalDefaultProxy';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
|
||||
import {
|
||||
@@ -113,7 +112,6 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
@@ -347,7 +345,6 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
@@ -595,10 +592,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
|
||||
settings.workspaceFocusStyle = workspaceFocusStyle;
|
||||
}
|
||||
const storedDefaultProxyProfileId = localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE);
|
||||
if (storedDefaultProxyProfileId !== null) {
|
||||
settings.defaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(storedDefaultProxyProfileId) ?? '';
|
||||
}
|
||||
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
@@ -913,12 +906,6 @@ async function applySyncableSettings(
|
||||
if (settings.workspaceFocusStyle != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
|
||||
}
|
||||
if (typeof settings.defaultProxyProfileId === 'string') {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
normalizeGlobalDefaultProxyProfileId(settings.defaultProxyProfileId) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
const ai = settings.ai;
|
||||
if (ai) {
|
||||
|
||||
@@ -15,11 +15,6 @@ test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", ()
|
||||
assert.equal(hasGroupSshFields({}), false);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel keeps the SSH section when X11 forwarding is set", () => {
|
||||
assert.equal(hasGroupSshFields({ x11Forwarding: true }), true);
|
||||
assert.equal(hasGroupSshFields({ x11Forwarding: false }), true);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
|
||||
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
|
||||
|
||||
@@ -102,7 +102,7 @@ export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined ||
|
||||
c.agentForwarding !== undefined || c.x11Forwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
@@ -262,7 +262,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
delete next.identityFilePaths;
|
||||
delete next.deviceType;
|
||||
delete next.agentForwarding;
|
||||
delete next.x11Forwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.startupCommandRunMode;
|
||||
delete next.legacyAlgorithms;
|
||||
@@ -500,10 +499,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedX11Forwarding = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).x11Forwarding;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedStartupCommandRunMode = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return "paste";
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste";
|
||||
@@ -573,7 +568,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
|
||||
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.x11Forwarding !== undefined && { x11Forwarding: form.x11Forwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
@@ -784,7 +778,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
setNewKeyFilePath={setNewKeyFilePath}
|
||||
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
|
||||
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
|
||||
inheritedX11Forwarding={inheritedX11Forwarding}
|
||||
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
||||
|
||||
@@ -47,7 +47,6 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
setNewKeyFilePath,
|
||||
inheritedLegacyAlgorithms,
|
||||
inheritedSkipEcdsaHostKey,
|
||||
inheritedX11Forwarding,
|
||||
inheritedStartupCommandRunMode,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
@@ -396,16 +395,6 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={!!(form.x11Forwarding ?? inheritedX11Forwarding)}
|
||||
onToggle={() => update(
|
||||
"x11Forwarding",
|
||||
!(form.x11Forwarding ?? inheritedX11Forwarding),
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Startup Command — Textarea so multi-line sequences are typeable
|
||||
here just like on the per-host details panel (#1083 follow-up). */}
|
||||
<Textarea
|
||||
|
||||
@@ -30,17 +30,6 @@ test('enabling SSH agent login clears an imported none sentinel', () => {
|
||||
assert.match(source, /resolveSshAgentToggleUpdate/);
|
||||
});
|
||||
|
||||
test('X11 forwarding display follows the effective host after group defaults', () => {
|
||||
assert.match(source, /applyGroupDefaults\(form, effectiveGroupDefaults\)/);
|
||||
assert.match(source, /const showX11Forwarding = \(!effectiveConnectionHost\.protocol \|\| effectiveConnectionHost\.protocol === "ssh"\)/);
|
||||
assert.match(source, /!effectiveConnectionHost\.moshEnabled/);
|
||||
assert.match(source, /!effectiveConnectionHost\.etEnabled/);
|
||||
assert.match(source, /const effectiveX11Forwarding = !!effectiveConnectionHost\.x11Forwarding/);
|
||||
assert.match(source, /\{showX11Forwarding && \(/);
|
||||
assert.match(source, /enabled=\{effectiveX11Forwarding\}/);
|
||||
assert.doesNotMatch(source, /effectiveGroupDefaults\?\.x11Forwarding/);
|
||||
});
|
||||
|
||||
test('login and forwarding warnings use independent agent status', () => {
|
||||
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
|
||||
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
|
||||
import { applyGroupDefaults } from "../domain/groupConfig";
|
||||
import React from "react";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
@@ -64,15 +63,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
const systemSshAgentEnabled = effectiveAuthMethod === "auto"
|
||||
? form.useSshAgent !== false
|
||||
: effectiveAuthMethod === "key" && form.useSshAgent === true;
|
||||
// Match the connection: inherited X11 is dropped when the effective transport
|
||||
// is Mosh, Eternal Terminal, or anything other than plain SSH.
|
||||
const effectiveConnectionHost = effectiveGroupDefaults
|
||||
? applyGroupDefaults(form, effectiveGroupDefaults)
|
||||
: form;
|
||||
const showX11Forwarding = (!effectiveConnectionHost.protocol || effectiveConnectionHost.protocol === "ssh")
|
||||
&& !effectiveConnectionHost.moshEnabled
|
||||
&& !effectiveConnectionHost.etEnabled;
|
||||
const effectiveX11Forwarding = !!effectiveConnectionHost.x11Forwarding;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -347,7 +337,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{showX11Forwarding && (
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
@@ -355,8 +345,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={effectiveX11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !effectiveX11Forwarding)}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
@@ -697,32 +687,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.directProxy ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t("hostDetails.proxy.direct")}
|
||||
</Badge>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{t("hostDetails.proxy.directHint")}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -453,7 +453,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
@@ -465,26 +465,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return rest as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
if (!profileId) return rest as Host;
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectDirectProxy = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return { ...rest, directProxy: true } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
@@ -893,10 +886,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
directProxy={form.directProxy}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onSelectDirectProxy={selectDirectProxy}
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
|
||||
@@ -27,10 +27,6 @@ import {
|
||||
removeProxyProfileReferences,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
} from "../domain/globalDefaultProxy";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
@@ -395,9 +391,6 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
if (getActiveGlobalDefaultProxyProfileId() === deleteTarget.id) {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
}
|
||||
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
|
||||
hosts,
|
||||
groupConfigs,
|
||||
|
||||
@@ -141,8 +141,6 @@ type TerminalTabSettingsProps = Pick<
|
||||
| 'setLocalShellSidePanelAutoOpenTab'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
| 'globalDefaultProxyProfileId'
|
||||
| 'setGlobalDefaultProxyProfileId'
|
||||
>;
|
||||
|
||||
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
|
||||
@@ -173,8 +171,6 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
setLocalShellSidePanelAutoOpenTab,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
}) {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
@@ -208,8 +204,6 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
globalDefaultProxyProfileId={globalDefaultProxyProfileId}
|
||||
setGlobalDefaultProxyProfileId={setGlobalDefaultProxyProfileId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -605,8 +599,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setLocalShellSidePanelAutoOpenTab={settings.setLocalShellSidePanelAutoOpenTab}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
globalDefaultProxyProfileId={settings.globalDefaultProxyProfileId}
|
||||
setGlobalDefaultProxyProfileId={settings.setGlobalDefaultProxyProfileId}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,6 @@ import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { useActiveGlobalDefaultProxyProfileId } from "../application/state/useGlobalDefaultProxyProfileId";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
@@ -162,19 +161,16 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
resolveTransferSourceSessionId,
|
||||
]);
|
||||
|
||||
// Memoized on vault props, so this subscription is what refreshes the list
|
||||
// after the global default changes.
|
||||
const globalDefaultProxyProfileId = useActiveGlobalDefaultProxyProfileId();
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() => {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
const inheritanceOptions = { validProxyProfileIds, globalProxyProfileId: globalDefaultProxyProfileId };
|
||||
return hosts.map(h => {
|
||||
const withGroupDefaults = h.group
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, inheritanceOptions), inheritanceOptions)
|
||||
: applyGroupDefaults(h, {}, inheritanceOptions);
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
|
||||
: applyGroupDefaults(h, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles, globalDefaultProxyProfileId]);
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
|
||||
+7
-30
@@ -163,12 +163,8 @@ import {
|
||||
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
|
||||
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import {
|
||||
isIdleShellReadyForCommandInjection,
|
||||
isShellPromptReadyForInsertion,
|
||||
} from "../domain/terminalCommandInjectionReady";
|
||||
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
|
||||
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
|
||||
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
|
||||
import {
|
||||
applyUserCursorPreference,
|
||||
applyUserCursorWidthPreference,
|
||||
@@ -443,7 +439,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const recorder = useScriptRecorder(sessionId);
|
||||
const recorderRef = useRef(recorder);
|
||||
recorderRef.current = recorder;
|
||||
const passwordPromptActiveRef = useRef(false) as SensitiveInputActiveRef;
|
||||
const passwordPromptActiveRef = useRef(false);
|
||||
const terminalTitleRef = useRef<string | undefined>(undefined);
|
||||
const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null);
|
||||
useEffect(() => registerTerminalSensitiveInputReader(
|
||||
@@ -603,21 +599,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const onSessionExitRef = useRef(onSessionExit);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => {
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return false;
|
||||
const prompt = detectPrompt(term);
|
||||
const readiness = {
|
||||
return isIdleShellReadyForCommandInjection({
|
||||
sensitiveInputActive: passwordPromptActiveRef.current,
|
||||
hasLiveTerminal: true,
|
||||
alternateScreenActive: isTerminalAlternateScreenActive(term),
|
||||
isAtPrompt: prompt.isAtPrompt,
|
||||
userInputLength: prompt.userInput.length,
|
||||
pendingTypedInputLength: commandBufferRef.current.length,
|
||||
};
|
||||
return options?.allowDraft
|
||||
? isShellPromptReadyForInsertion(readiness)
|
||||
: isIdleShellReadyForCommandInjection(readiness);
|
||||
});
|
||||
}), [sessionId]);
|
||||
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
@@ -1702,9 +1695,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
if (typeof payload.passwordPromptActive === "boolean") {
|
||||
passwordPromptActiveRef.current = payload.passwordPromptActive;
|
||||
// The snapshot boolean has no source. A restored true latch stays
|
||||
// explicit so Email:/Username: cannot bypass a plugin-marked field.
|
||||
if (payload.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
|
||||
}
|
||||
if (payload.cwd !== undefined) {
|
||||
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
|
||||
@@ -2023,9 +2013,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
|
||||
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
|
||||
// Authoritative metadata must not be downgraded to the heuristic latch,
|
||||
// or trusted labels such as Email: would bypass snippet blocking.
|
||||
passwordPromptActiveRef.source = meta.pluginPipelineSensitiveInput ? "explicit" : "heuristic";
|
||||
if (meta.pluginPipelineSensitiveInput) {
|
||||
autocompleteCloseRef.current?.();
|
||||
} else {
|
||||
@@ -2037,14 +2024,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = false;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
}
|
||||
}, [isNetworkDevice]);
|
||||
|
||||
@@ -2356,8 +2341,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}), []);
|
||||
|
||||
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
|
||||
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
|
||||
|
||||
@@ -2447,12 +2430,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
// Hibernate wake builds its runtime from this callback. Same local
|
||||
// prompt-return refresh as useTerminalEffects (#1952).
|
||||
if (host.protocol === "local") {
|
||||
onCommandSubmitted?.("", host.id, host.label, sessionId);
|
||||
}
|
||||
}, [host.id, host.label, host.protocol, onCommandSubmitted, pluginTerminalLifecycle, sessionId]);
|
||||
}, [pluginTerminalLifecycle]);
|
||||
const pluginAwareOnTerminalCwdChange = useCallback((
|
||||
changedSessionId: string,
|
||||
cwd: string | null,
|
||||
@@ -4353,7 +4331,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
xTermRuntimeContextRef.current = {
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize: effectiveFontSize,
|
||||
@@ -4677,7 +4654,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X,
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore } from '../application/state/activeTabStore';
|
||||
import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts';
|
||||
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
||||
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
|
||||
import {
|
||||
@@ -113,7 +114,7 @@ import {
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
resolveSessionSidePanelAutoOpen,
|
||||
} from '../domain/terminalSidePanelAutoOpen';
|
||||
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import {
|
||||
resolvePreferredTerminalCwd,
|
||||
scheduleBackendCwdProbeAfterCommand,
|
||||
@@ -1347,12 +1348,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const tabId = activeTabIdRef.current;
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
// Local POSIX shells do not emit OSC 7. They share this probe so session
|
||||
// restore can `cd` back after restart (#1952). Prompt return calls this
|
||||
// again with an empty command once the shell is idle, so a slow command
|
||||
// such as `sleep 1; cd /tmp` is not recorded at the 150ms snapshot.
|
||||
// SSH keeps the extra-exec eligibility check inside canScheduleCommandCwdProbe.
|
||||
if (!session || !canScheduleCommandCwdProbe(session)) return;
|
||||
if (!session || !canReuseTerminalConnection(session)) return;
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
|
||||
? sftpHostForTabRef.current.get(tabId) ?? null
|
||||
@@ -1376,8 +1372,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
canProbe: async () => {
|
||||
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
|
||||
// Local cwd is the PTY process directory, not an extra SSH channel.
|
||||
if (session.protocol === "local") return true;
|
||||
const host = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!host) return false;
|
||||
const hostDeviceClass = classifyDistroId(host.distro);
|
||||
|
||||
@@ -51,17 +51,3 @@ test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
});
|
||||
|
||||
test("new host menu keeps the local shell icon at the same size as its siblings", () => {
|
||||
const menuStart = vaultViewLayoutSource.indexOf('<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>');
|
||||
assert.ok(menuStart >= 0);
|
||||
const menuEnd = vaultViewLayoutSource.indexOf("</DropdownContent>", menuStart);
|
||||
const menu = vaultViewLayoutSource.slice(menuStart, menuEnd);
|
||||
assert.match(menu, /className="flex w-max min-w-44 flex-col"/);
|
||||
assert.doesNotMatch(menu, /className="w-44"/);
|
||||
assert.match(menu, /<TerminalSquare size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<FolderTree size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Upload size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Download size=\{14\} className="shrink-0" \/>/);
|
||||
assert.doesNotMatch(menu, /className="w-full justify-start gap-2"/);
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* even when the Catty AI side panel has never been opened.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -20,13 +20,6 @@ import {
|
||||
resolveCapabilityId,
|
||||
} from '../../infrastructure/ai/harness/permissionGrants';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { AI_STATE_CHANGED_EVENT } from '../../application/state/aiStateEvents';
|
||||
import {
|
||||
externalMcpApprovalToolLabel,
|
||||
notifyExternalMcpApproval,
|
||||
publishExternalMcpApprovalAlertMode,
|
||||
} from '../../application/state/externalMcpApprovalAlert';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
|
||||
|
||||
@@ -38,37 +31,19 @@ function isExternalMcpApproval(request: ApprovalRequest): boolean {
|
||||
export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
|
||||
const alertedIdsRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
const pushMode = () => {
|
||||
publishExternalMcpApprovalAlertMode();
|
||||
};
|
||||
pushMode();
|
||||
const onChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT) pushMode();
|
||||
};
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onChange);
|
||||
return () => window.removeEventListener(AI_STATE_CHANGED_EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (request: ApprovalRequest) => {
|
||||
if (!isExternalMcpApproval(request)) return;
|
||||
setPendingApprovals((prev) => new Map(prev).set(request.toolCallId, request));
|
||||
if (alertedIdsRef.current.has(request.toolCallId)) return;
|
||||
alertedIdsRef.current.add(request.toolCallId);
|
||||
notifyExternalMcpApproval(request, t);
|
||||
};
|
||||
const unsub = onApprovalRequest(handler);
|
||||
replayPendingApprovals(handler);
|
||||
return unsub;
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return onApprovalCleared((clearedIds) => {
|
||||
for (const id of clearedIds) alertedIdsRef.current.delete(id);
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const id of clearedIds) next.delete(id);
|
||||
@@ -154,7 +129,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
{entries.map(([id, req]) => (
|
||||
<div key={id} className="space-y-1.5">
|
||||
<ToolCall
|
||||
name={externalMcpApprovalToolLabel(req.toolName)}
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
approvalTarget={req.target}
|
||||
isLoading={false}
|
||||
|
||||
@@ -27,10 +27,8 @@ export interface ProxyPanelProps {
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
selectedProxyProfileId?: string;
|
||||
directProxy?: boolean;
|
||||
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
|
||||
onSelectProxyProfile?: (profileId: string | undefined) => void;
|
||||
onSelectDirectProxy?: () => void;
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -44,10 +42,8 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
proxyProfiles = [],
|
||||
identities = [],
|
||||
selectedProxyProfileId,
|
||||
directProxy = false,
|
||||
onUpdateProxy,
|
||||
onSelectProxyProfile,
|
||||
onSelectDirectProxy,
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
@@ -58,18 +54,13 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const customValue = '__custom__';
|
||||
const directValue = '__direct__';
|
||||
const selectedProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
|
||||
[proxyProfiles, selectedProxyProfileId],
|
||||
);
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile && !directProxy);
|
||||
const selectedValue = directProxy
|
||||
? directValue
|
||||
: selectedProfile
|
||||
? selectedProfile.id
|
||||
: customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile) && !directProxy;
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
|
||||
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile);
|
||||
const isCommandProxy = isProxyCommandConfig(proxyConfig);
|
||||
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
|
||||
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
|
||||
@@ -80,8 +71,8 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
|
||||
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
|
||||
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
|
||||
const canSave = directProxy || (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && !directProxy && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const manualCredentialsValue = '__manual_credentials__';
|
||||
const missingIdentityValue = '__missing_identity__';
|
||||
const selectedIdentity = useMemo(
|
||||
@@ -112,7 +103,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile || onSelectDirectProxy) && onSelectProxyProfile && (
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
@@ -120,13 +111,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</div>
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === directValue) {
|
||||
onSelectDirectProxy?.();
|
||||
return;
|
||||
}
|
||||
onSelectProxyProfile(value === customValue ? undefined : value);
|
||||
}}
|
||||
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('hostDetails.proxyPanel.savedProxy')}
|
||||
@@ -135,9 +120,6 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{onSelectDirectProxy && (
|
||||
<SelectItem value={directValue}>{t('hostDetails.proxy.direct')}</SelectItem>
|
||||
)}
|
||||
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
|
||||
{proxyProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
@@ -181,7 +163,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isUsingProfile && !directProxy && (
|
||||
{!isUsingProfile && (
|
||||
<>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="space-y-2">
|
||||
@@ -346,7 +328,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
|
||||
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
|
||||
</Button>
|
||||
|
||||
@@ -5,13 +5,10 @@ import type {
|
||||
CursorShape,
|
||||
HostInfoBarTitleMode,
|
||||
PasswordPromptAssistMode,
|
||||
ProxyProfile,
|
||||
TerminalEmulationType,
|
||||
TerminalSettings,
|
||||
} from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_PROXY_PROFILES } from "../../../infrastructure/config/storageKeys";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, resolveTerminalFontFamilyId, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
|
||||
@@ -46,34 +43,6 @@ import {
|
||||
TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MIN,
|
||||
} from "../../../domain/terminalInlineImages";
|
||||
|
||||
const GLOBAL_DEFAULT_PROXY_NONE = "__none__";
|
||||
|
||||
const readSavedProxyProfiles = (): ProxyProfile[] => {
|
||||
const stored = localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
};
|
||||
|
||||
const useSavedProxyProfiles = (): ProxyProfile[] => {
|
||||
const [profiles, setProfiles] = useState<ProxyProfile[]>(readSavedProxyProfiles);
|
||||
useEffect(() => {
|
||||
const refresh = (key?: string | null) => {
|
||||
if (key && key !== STORAGE_KEY_PROXY_PROFILES) return;
|
||||
setProfiles(readSavedProxyProfiles());
|
||||
};
|
||||
const onAdapterChange = (event: Event) => {
|
||||
refresh((event as CustomEvent<{ key?: string }>).detail?.key);
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => refresh(event.key);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
return profiles;
|
||||
};
|
||||
|
||||
const FONT_WEIGHT_OPTIONS = [
|
||||
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
|
||||
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
|
||||
@@ -118,8 +87,6 @@ function SettingsTerminalTab(props: {
|
||||
availableFonts: TerminalFont[];
|
||||
workspaceFocusStyle: 'dim' | 'border';
|
||||
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
|
||||
globalDefaultProxyProfileId?: string;
|
||||
setGlobalDefaultProxyProfileId: (id: string | undefined) => void;
|
||||
}) {
|
||||
const {
|
||||
terminalThemeId,
|
||||
@@ -150,14 +117,8 @@ function SettingsTerminalTab(props: {
|
||||
availableFonts,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const proxyProfiles = useSavedProxyProfiles();
|
||||
const defaultProxyValue = proxyProfiles.some((profile) => profile.id === globalDefaultProxyProfileId)
|
||||
? globalDefaultProxyProfileId ?? GLOBAL_DEFAULT_PROXY_NONE
|
||||
: GLOBAL_DEFAULT_PROXY_NONE;
|
||||
|
||||
// Local shell settings state
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
@@ -971,23 +932,6 @@ function SettingsTerminalTab(props: {
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.connection")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
anchorId="terminal-default-proxy"
|
||||
label={t("settings.terminal.connection.defaultProxy")}
|
||||
description={t("settings.terminal.connection.defaultProxy.desc")}
|
||||
>
|
||||
<Select
|
||||
value={defaultProxyValue}
|
||||
onChange={(value) => {
|
||||
setGlobalDefaultProxyProfileId(value === GLOBAL_DEFAULT_PROXY_NONE ? undefined : value);
|
||||
}}
|
||||
options={[
|
||||
{ value: GLOBAL_DEFAULT_PROXY_NONE, label: t("settings.terminal.connection.defaultProxy.none") },
|
||||
...proxyProfiles.map((profile) => ({ value: profile.id, label: profile.label })),
|
||||
]}
|
||||
className="w-56"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-verify-host-keys"
|
||||
label={t("settings.terminal.connection.verifyHostKeys")}
|
||||
@@ -998,16 +942,6 @@ function SettingsTerminalTab(props: {
|
||||
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-legacy-algorithms"
|
||||
label={t("settings.terminal.connection.temporaryLegacyAlgorithms")}
|
||||
description={t("settings.terminal.connection.temporaryLegacyAlgorithms.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsAllowLegacyAlgorithms}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-ssh-auto-reconnect"
|
||||
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
) : null}
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canCopy}
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(copyKey, value)}
|
||||
aria-label={isCopied ? copiedLabel : copyLabel}
|
||||
title={isCopied ? copiedLabel : copyLabel}
|
||||
>
|
||||
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +1,17 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpIdleTimeoutMinutes,
|
||||
readExternalMcpMode,
|
||||
readExternalMcpSilentSessions,
|
||||
readSessionIdleTimeoutMinutes,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpIdleTimeoutMinutes,
|
||||
writeExternalMcpMode,
|
||||
writeExternalMcpSilentSessions,
|
||||
writeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
readExternalMcpHttpClientHost,
|
||||
readExternalMcpHttpEnabled,
|
||||
readExternalMcpHttpListenAddress,
|
||||
readExternalMcpHttpPort,
|
||||
writeExternalMcpHttpAllowCustomBind,
|
||||
writeExternalMcpHttpClientHost,
|
||||
writeExternalMcpHttpEnabled,
|
||||
writeExternalMcpHttpListenAddress,
|
||||
writeExternalMcpHttpPort,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpHttpSettings,
|
||||
type ExternalMcpMode,
|
||||
useExternalMcpToggleState,
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
@@ -33,14 +19,70 @@ import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
|
||||
import { getBridge } from "./types";
|
||||
|
||||
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor" | "other";
|
||||
|
||||
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor", "other"];
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
) : null}
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canCopy}
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(copyKey, value)}
|
||||
aria-label={isCopied ? copiedLabel : copyLabel}
|
||||
title={isCopied ? copiedLabel : copyLabel}
|
||||
>
|
||||
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExternalMcpStatus = {
|
||||
ok: boolean;
|
||||
enabled?: boolean;
|
||||
@@ -55,7 +97,6 @@ type ExternalMcpStatus = {
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
permissionMode?: string;
|
||||
error?: string | null;
|
||||
http?: ExternalMcpHttpViewStatus | null;
|
||||
};
|
||||
|
||||
type ClientSetupStatus = {
|
||||
@@ -256,19 +297,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
const [approvalAlert, setApprovalAlertRaw] = useState<ExternalMcpApprovalAlertMode>(
|
||||
() => readExternalMcpApprovalAlertMode(),
|
||||
);
|
||||
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
|
||||
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
|
||||
const [httpSettings, setHttpSettings] = useState<ExternalMcpHttpSettings>(() => ({
|
||||
enabled: readExternalMcpHttpEnabled(),
|
||||
port: readExternalMcpHttpPort(),
|
||||
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
}));
|
||||
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
|
||||
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
|
||||
@@ -314,51 +344,11 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
|
||||
}, []);
|
||||
|
||||
const setApprovalAlert = useCallback((next: ExternalMcpApprovalAlertMode) => {
|
||||
setApprovalAlertRaw(writeExternalMcpApprovalAlertMode(next));
|
||||
}, []);
|
||||
|
||||
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
|
||||
setSilentSessionsRaw(nextSilentSessions);
|
||||
writeExternalMcpSilentSessions(nextSilentSessions);
|
||||
}, []);
|
||||
|
||||
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
|
||||
const normalized: ExternalMcpHttpSettings = {
|
||||
enabled: writeExternalMcpHttpEnabled(next.enabled),
|
||||
port: writeExternalMcpHttpPort(next.port),
|
||||
allowCustomBind: writeExternalMcpHttpAllowCustomBind(next.allowCustomBind),
|
||||
listenAddress: writeExternalMcpHttpListenAddress(next.listenAddress),
|
||||
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
|
||||
};
|
||||
setHttpSettings(normalized);
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
|
||||
httpEnabled: normalized.enabled,
|
||||
httpPort: normalized.port,
|
||||
httpAllowCustomBind: normalized.allowCustomBind,
|
||||
httpListenAddress: normalized.listenAddress,
|
||||
httpClientHost: normalized.clientHost,
|
||||
});
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rotateHttpToken = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.externalMcpSetConfig) return;
|
||||
setIsRotatingHttpToken(true);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
}
|
||||
} finally {
|
||||
setIsRotatingHttpToken(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
const includeClients = options?.clients !== false;
|
||||
@@ -708,24 +698,6 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-approval-alert"
|
||||
label={t("ai.externalMcp.approvalAlert")}
|
||||
description={t("ai.externalMcp.approvalAlert.description")}
|
||||
>
|
||||
<Select
|
||||
value={approvalAlert}
|
||||
options={[
|
||||
{ value: "always", label: t("ai.externalMcp.approvalAlert.always") },
|
||||
{ value: "unfocused", label: t("ai.externalMcp.approvalAlert.unfocused") },
|
||||
{ value: "off", label: t("ai.externalMcp.approvalAlert.off") },
|
||||
]}
|
||||
onChange={(value) => setApprovalAlert(
|
||||
value === "always" || value === "off" ? value : "unfocused",
|
||||
)}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.mode")}
|
||||
description={t("ai.externalMcp.mode.description")}
|
||||
@@ -806,17 +778,6 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<ExternalMcpHttpSection
|
||||
externalMcpEnabled={enabled}
|
||||
settings={httpSettings}
|
||||
status={status?.http || null}
|
||||
copied={copied}
|
||||
rotating={isRotatingHttpToken}
|
||||
onCopy={copyText}
|
||||
onChange={(next) => { void applyHttpSettings(next); }}
|
||||
onRotateToken={() => { void rotateHttpToken(); }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
buildClaudeHttpAddCommand,
|
||||
buildCodexHttpEnvLine,
|
||||
buildCodexHttpToml,
|
||||
buildExternalMcpHttpUrl,
|
||||
buildGenericHttpMcpJson,
|
||||
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
} from "../../../../domain/externalMcpHttp";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
|
||||
export type ExternalMcpHttpViewStatus = {
|
||||
enabled?: boolean;
|
||||
listening?: boolean;
|
||||
port?: number;
|
||||
listenAddress?: string;
|
||||
allowCustomBind?: boolean;
|
||||
clientHost?: string;
|
||||
url?: string | null;
|
||||
bearerToken?: string | null;
|
||||
state?: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
};
|
||||
|
||||
export type ExternalMcpHttpViewSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
allowCustomBind: boolean;
|
||||
listenAddress: string;
|
||||
clientHost: string;
|
||||
};
|
||||
|
||||
type ExternalMcpHttpSectionProps = {
|
||||
externalMcpEnabled: boolean;
|
||||
settings: ExternalMcpHttpViewSettings;
|
||||
status: ExternalMcpHttpViewStatus | null;
|
||||
copied: string | null;
|
||||
rotating: boolean;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
onChange: (next: ExternalMcpHttpViewSettings) => void;
|
||||
onRotateToken: () => void;
|
||||
};
|
||||
|
||||
function isWildcardAddress(value: string): boolean {
|
||||
const address = value.trim();
|
||||
return address === "0.0.0.0" || address === "::";
|
||||
}
|
||||
|
||||
function isIpAddress(value: string): boolean {
|
||||
const address = value.trim();
|
||||
const ipv4 = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4) {
|
||||
return ipv4.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part);
|
||||
}
|
||||
return address.includes(":") && /^[0-9a-fA-F:]+$/.test(address);
|
||||
}
|
||||
|
||||
function parsePort(value: string): number | null {
|
||||
if (!/^\d+$/.test(value.trim())) return null;
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null;
|
||||
return port;
|
||||
}
|
||||
|
||||
export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
externalMcpEnabled,
|
||||
settings,
|
||||
status,
|
||||
copied,
|
||||
rotating,
|
||||
onCopy,
|
||||
onChange,
|
||||
onRotateToken,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
|
||||
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
|
||||
const [portError, setPortError] = useState(false);
|
||||
const [addressError, setAddressError] = useState(false);
|
||||
const [clientHostError, setClientHostError] = useState(false);
|
||||
|
||||
const listenAddress = settings.allowCustomBind
|
||||
? (status?.listenAddress || settings.listenAddress)
|
||||
: "127.0.0.1";
|
||||
const advertisedHost = status?.clientHost ?? settings.clientHost;
|
||||
const url = status
|
||||
? (status.url || "")
|
||||
: buildExternalMcpHttpUrl(listenAddress, settings.port, advertisedHost);
|
||||
const showClientHost = settings.allowCustomBind && (
|
||||
isWildcardAddress(settings.listenAddress) || isWildcardAddress(addressDraft)
|
||||
);
|
||||
const token = status?.bearerToken || "";
|
||||
const listening = Boolean(status?.listening);
|
||||
const statusLabel = listening
|
||||
? t("ai.externalMcp.http.listening")
|
||||
: t("ai.externalMcp.http.notListening");
|
||||
const statusClass = status?.errorCode
|
||||
? "text-destructive"
|
||||
: listening
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
let errorText = "";
|
||||
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (addressError) errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (clientHostError) errorText = t("ai.externalMcp.http.invalidClientHost");
|
||||
else if (status?.errorCode === "client_host_required") errorText = t("ai.externalMcp.http.clientHostRequired");
|
||||
else if (status?.errorCode === "port_in_use") {
|
||||
errorText = t("ai.externalMcp.http.portInUse", { port: String(status.port ?? settings.port) });
|
||||
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
|
||||
|
||||
const commitPort = () => {
|
||||
const port = parsePort(portDraft);
|
||||
if (port == null) {
|
||||
setPortError(true);
|
||||
return;
|
||||
}
|
||||
setPortError(false);
|
||||
setPortDraft(String(port));
|
||||
if (port !== settings.port) onChange({ ...settings, port });
|
||||
};
|
||||
|
||||
const commitClientHost = () => {
|
||||
const clientHost = clientHostDraft.trim();
|
||||
if (clientHost && (!isIpAddress(clientHost) || isWildcardAddress(clientHost))) {
|
||||
setClientHostError(true);
|
||||
return;
|
||||
}
|
||||
setClientHostError(false);
|
||||
setClientHostDraft(clientHost);
|
||||
if (clientHost !== settings.clientHost) onChange({ ...settings, clientHost });
|
||||
};
|
||||
|
||||
const commitAddress = () => {
|
||||
const address = addressDraft.trim();
|
||||
if (!isIpAddress(address)) {
|
||||
setAddressError(true);
|
||||
return;
|
||||
}
|
||||
setAddressError(false);
|
||||
setAddressDraft(address);
|
||||
if (address !== settings.listenAddress) onChange({ ...settings, listenAddress: address });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
|
||||
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-http"
|
||||
label={t("ai.externalMcp.http.enable")}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.enabled}
|
||||
onChange={(enabled) => onChange({ ...settings, enabled })}
|
||||
ariaLabel={t("ai.externalMcp.http.enable")}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.port")}
|
||||
description={t("ai.externalMcp.http.portHint")}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t("ai.externalMcp.http.port")}
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={portDraft}
|
||||
onChange={(event) => {
|
||||
setPortDraft(event.currentTarget.value);
|
||||
setPortError(false);
|
||||
}}
|
||||
onBlur={commitPort}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="w-24 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.allowCustomBind")}
|
||||
description={t("ai.externalMcp.http.allowCustomBind.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.allowCustomBind}
|
||||
onChange={(allowCustomBind) => onChange({ ...settings, allowCustomBind })}
|
||||
ariaLabel={t("ai.externalMcp.http.allowCustomBind")}
|
||||
/>
|
||||
</SettingRow>
|
||||
{settings.allowCustomBind ? (
|
||||
<SettingRow label={t("ai.externalMcp.http.listenAddress")}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.listenAddress")}
|
||||
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
|
||||
value={addressDraft}
|
||||
onChange={(event) => {
|
||||
setAddressDraft(event.currentTarget.value);
|
||||
setAddressError(false);
|
||||
}}
|
||||
onBlur={commitAddress}
|
||||
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
{showClientHost ? (
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.clientHost")}
|
||||
description={t("ai.externalMcp.http.clientHost.description")}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.clientHost")}
|
||||
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
|
||||
value={clientHostDraft}
|
||||
onChange={(event) => {
|
||||
setClientHostDraft(event.currentTarget.value);
|
||||
setClientHostError(false);
|
||||
}}
|
||||
onBlur={commitClientHost}
|
||||
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
</SettingCard>
|
||||
{settings.allowCustomBind ? (
|
||||
<p className="text-xs text-amber-500 leading-5">{t("ai.externalMcp.http.warning")}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.wslHint")}</p>
|
||||
{settings.enabled && !externalMcpEnabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
|
||||
) : null}
|
||||
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.url")}
|
||||
value={url}
|
||||
copyKey="http-url"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.token")}
|
||||
value={token}
|
||||
copyKey="http-token"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.regenerateHint")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating}
|
||||
onClick={onRotateToken}
|
||||
>
|
||||
{t("ai.externalMcp.http.regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.codexSnippet")}
|
||||
value={buildCodexHttpToml(url)}
|
||||
copyKey="http-codex"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.envLine")}
|
||||
value={token ? buildCodexHttpEnvLine(token) : ""}
|
||||
copyKey="http-env"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.claudeCommand")}
|
||||
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
|
||||
copyKey="http-claude"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.genericSnippet")}
|
||||
value={token ? buildGenericHttpMcpJson(url, token) : ""}
|
||||
copyKey="http-json"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -177,12 +177,6 @@ export interface NetcattyAiBridge {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
httpListenAddress?: string;
|
||||
httpClientHost?: string;
|
||||
rotateHttpToken?: boolean;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
externalMcpGetUniversalSetupPrompt?: () => Promise<{
|
||||
ok: boolean;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { detectPrompt, getAlignedPrompt, isProgramInputPromptText } from "./autocomplete/promptDetector.ts";
|
||||
import { detectPrompt, getAlignedPrompt } from "./autocomplete/promptDetector.ts";
|
||||
import { resolveAutocompleteQueryInput } from "./autocomplete/terminalAutocompletePrompt.ts";
|
||||
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter.ts";
|
||||
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
|
||||
@@ -817,63 +817,3 @@ test("CMD path prompts with CJK directories do not absorb padding into echoed Ch
|
||||
"zh",
|
||||
);
|
||||
});
|
||||
|
||||
test("program input prompts expose snippet text and stay out of shell history (#3622)", () => {
|
||||
const line = "Email: use";
|
||||
const term = createFakeTerm(line, line.length);
|
||||
const snippet = { id: "mail", label: "邮箱", command: "user@example.com", noAutoRun: true };
|
||||
|
||||
assert.equal(detectPrompt(term as never).isAtPrompt, false);
|
||||
|
||||
const echoed = getAlignedPrompt(term as never, "use", true);
|
||||
assert.equal(echoed.prompt.isAtPrompt, true);
|
||||
assert.equal(echoed.prompt.promptText, "Email: ");
|
||||
assert.equal(echoed.prompt.userInput, "use");
|
||||
assert.equal(echoed.alignedTyped, null);
|
||||
assert.equal(resolveAutocompleteQueryInput(echoed.prompt, "use", true), "use");
|
||||
assert.equal(
|
||||
getSnippetSuggestions(echoed.prompt.userInput, [snippet as never])[0]?.snippet?.command,
|
||||
"user@example.com",
|
||||
);
|
||||
assert.equal(getCommandToRecordOnEnter(echoed.prompt, echoed.alignedTyped, "use", true), null);
|
||||
|
||||
const pending = getAlignedPrompt(createFakeTerm("Email: ", "Email: ".length) as never, "use", true);
|
||||
assert.equal(pending.prompt.promptText, "Email: ");
|
||||
assert.equal(resolveAutocompleteQueryInput(pending.prompt, "use", true), "use");
|
||||
assert.equal(pending.alignedTyped, null);
|
||||
|
||||
const shell = "user@host:~$ git pull use";
|
||||
const shellPrompt = getAlignedPrompt(createFakeTerm(shell, shell.length) as never, "git pull use", true);
|
||||
assert.equal(shellPrompt.prompt.promptText, "user@host:~$ ");
|
||||
assert.equal(shellPrompt.prompt.userInput, "git pull use");
|
||||
assert.equal(shellPrompt.alignedTyped, "git pull use");
|
||||
});
|
||||
|
||||
test("a colon shell prompt is not a program input prompt (#3622)", () => {
|
||||
for (const prompt of ["dev: ", "development: ", "PS1: "]) {
|
||||
assert.equal(isProgramInputPromptText(prompt), false, prompt);
|
||||
const line = `${prompt}ls`;
|
||||
const term = createFakeTerm(line, line.length);
|
||||
const aligned = getAlignedPrompt(term as never, "ls", true);
|
||||
assert.equal(aligned.prompt.isAtPrompt, true, prompt);
|
||||
assert.equal(aligned.prompt.promptText, prompt, prompt);
|
||||
assert.equal(aligned.prompt.userInput, "ls", prompt);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(aligned.prompt, aligned.alignedTyped, "ls", true),
|
||||
"ls",
|
||||
prompt,
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(isProgramInputPromptText("Email: "), true);
|
||||
assert.equal(isProgramInputPromptText("Enter your username: "), true);
|
||||
assert.equal(isProgramInputPromptText("用户名: "), true);
|
||||
});
|
||||
|
||||
test("password, passphrase, PIN, and secret prompts stay uncompletable (#3622)", () => {
|
||||
for (const line of ["Password: use", "Passphrase: use", "PIN: use", "Secret: use", "Enter secret: use"]) {
|
||||
const result = getAlignedPrompt(createFakeTerm(line, line.length) as never, "use", true);
|
||||
assert.equal(result.prompt.isAtPrompt, false, line);
|
||||
assert.equal(resolveAutocompleteQueryInput(result.prompt, "use", true), null, line);
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type AutocompleteSettings,
|
||||
} from "./autocomplete";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { usePaneVisible } from "./paneVisibilityStore";
|
||||
import { getWindowPluginTerminalProviderRegistry } from "../../application/state/pluginTerminalProviderRegistry";
|
||||
import { provideTerminalCompletions } from "./autocomplete/terminalCompletionProviders";
|
||||
@@ -96,7 +95,6 @@ export function TerminalAutocomplete({
|
||||
// flow through (and re-render) the TerminalView ctx. Popup / standalone
|
||||
// Terminal mounts never publish the store — fall back to the isVisible prop
|
||||
// (same contract as hibernate).
|
||||
const { resolvedLocale } = useI18n();
|
||||
const visible = usePaneVisible(sessionId, isVisible);
|
||||
const provideCompletions = useCallback(async (
|
||||
input: string,
|
||||
@@ -133,9 +131,8 @@ export function TerminalAutocomplete({
|
||||
historyScope: options.historyScope ?? settings?.historyScope,
|
||||
signal: options.signal,
|
||||
onLatePathSuggestions: options.onLatePathSuggestions,
|
||||
locale: resolvedLocale,
|
||||
});
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, resolvedLocale, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
containerRef,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { GpuDeviceStats } from "../../application/state/useServerStats.ts";
|
||||
import { TerminalGpuCards } from "./TerminalGpuCards.tsx";
|
||||
|
||||
function renderCards(gpus: GpuDeviceStats[], gpu = 40) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TerminalGpuCards
|
||||
gpus={gpus}
|
||||
gpu={gpu}
|
||||
gpuName="NVIDIA GeForce RTX 4090"
|
||||
gpuMemUsed={2048}
|
||||
gpuMemTotal={24576}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const card = (index: number, utilization: number | null, name = `NVIDIA A${index}`): GpuDeviceStats => ({
|
||||
index,
|
||||
name,
|
||||
utilization,
|
||||
memoryUsed: 1024 * (index + 1),
|
||||
memoryTotal: 8192,
|
||||
});
|
||||
|
||||
test("one GPU uses the same per-core bar, colors, and spacing", () => {
|
||||
const markup = renderCards([card(0, 73, "NVIDIA GeForce RTX 4090")], 73);
|
||||
|
||||
assert.match(markup, /GPU Device Usage/);
|
||||
assert.match(markup, /GPU 0/);
|
||||
assert.match(markup, /NVIDIA GeForce RTX 4090/);
|
||||
assert.match(markup, /73%/);
|
||||
assert.match(markup, /1\.0\/8\.0G/);
|
||||
assert.match(markup, /grid gap-1\.5 max-h-\[260px\] overflow-y-auto/);
|
||||
assert.match(markup, /min-w-\[160px\]/);
|
||||
assert.match(markup, /line-clamp-2/);
|
||||
assert.match(markup, /title="NVIDIA GeForce RTX 4090"/);
|
||||
assert.doesNotMatch(markup, /max-w-\[88px\]/);
|
||||
assert.match(markup, /h-1\.5 bg-muted rounded-full overflow-hidden/);
|
||||
assert.match(markup, /bg-amber-500/);
|
||||
assert.match(markup, /text-amber-400/);
|
||||
assert.match(markup, /repeat\(1, 1fr\)/);
|
||||
});
|
||||
|
||||
test("eight GPUs stay in four scrolling columns", () => {
|
||||
const markup = renderCards(Array.from({ length: 8 }, (_, index) => card(index, 10 + index)));
|
||||
|
||||
assert.match(markup, /repeat\(4, 1fr\)/);
|
||||
assert.match(markup, /max-h-\[260px\] overflow-y-auto/);
|
||||
assert.match(markup, /max-w-\[26rem\]/);
|
||||
assert.match(markup, /line-clamp-2/);
|
||||
assert.match(markup, /title="NVIDIA A0"/);
|
||||
assert.doesNotMatch(markup, /max-w-\[88px\]/);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
assert.match(markup, new RegExp(`GPU ${index}`));
|
||||
}
|
||||
assert.match(markup, /text-emerald-400/);
|
||||
assert.doesNotMatch(markup, /repeat\(8,/);
|
||||
});
|
||||
|
||||
test("high utilization uses the CPU red threshold and unknown cards stay muted", () => {
|
||||
const markup = renderCards([
|
||||
card(0, 95, "NVIDIA H100"),
|
||||
{ index: 3, name: "NVIDIA A100", utilization: null, memoryUsed: null, memoryTotal: null },
|
||||
], 95);
|
||||
|
||||
assert.match(markup, /bg-red-500/);
|
||||
assert.match(markup, /text-red-400/);
|
||||
assert.match(markup, /95%/);
|
||||
assert.match(markup, /GPU 3/);
|
||||
assert.match(markup, />--</);
|
||||
assert.match(markup, /repeat\(2, 1fr\)/);
|
||||
});
|
||||
|
||||
test("a summary without per-GPU rows falls back to one aggregate bar", () => {
|
||||
const markup = renderCards([], 12);
|
||||
|
||||
assert.match(markup, /min-w-\[160px\]/);
|
||||
assert.match(markup, /h-2 bg-muted rounded-full/);
|
||||
assert.match(markup, /12%/);
|
||||
assert.match(markup, /2\.0\/24\.0G/);
|
||||
assert.match(markup, /text-emerald-400/);
|
||||
assert.doesNotMatch(markup, /grid gap-1\.5/);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import type { GpuDeviceStats } from "../../application/state/useServerStats";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function formatGpuMemoryGib(usedMb: number | null, totalMb: number | null): string | null {
|
||||
if (usedMb === null || totalMb === null || !(totalMb > 0)) return null;
|
||||
return `${(usedMb / 1024).toFixed(1)}/${(totalMb / 1024).toFixed(1)}G`;
|
||||
}
|
||||
|
||||
function usageBarClass(usage: number): string {
|
||||
return usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500";
|
||||
}
|
||||
|
||||
function usageTextClass(usage: number): string {
|
||||
return usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400";
|
||||
}
|
||||
|
||||
interface TerminalGpuCardsProps {
|
||||
gpus: GpuDeviceStats[];
|
||||
gpu: number;
|
||||
gpuName: string | null;
|
||||
gpuMemUsed: number | null;
|
||||
gpuMemTotal: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-GPU details for the terminal stats hover card.
|
||||
* Mirrors the CPU per-core grid: four columns, the same bar heights,
|
||||
* threshold colors, and a scroll cap so many cards stay inside the popover.
|
||||
*/
|
||||
export const TerminalGpuCards: React.FC<TerminalGpuCardsProps> = ({
|
||||
gpus,
|
||||
gpu,
|
||||
gpuName,
|
||||
gpuMemUsed,
|
||||
gpuMemTotal,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const aggregateMemory = formatGpuMemoryGib(gpuMemUsed, gpuMemTotal);
|
||||
|
||||
const columns = Math.min(4, gpus.length);
|
||||
|
||||
return (
|
||||
<div className={cn("text-xs space-y-2 w-max max-w-[26rem]", gpus.length === 1 && "min-w-[160px]")}>
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.gpuDevices")}</div>
|
||||
{gpus.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-1.5 max-h-[260px] overflow-y-auto pr-1 overscroll-contain"
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
|
||||
>
|
||||
{gpus.map((device) => {
|
||||
const usage = device.utilization;
|
||||
const memory = formatGpuMemoryGib(device.memoryUsed, device.memoryTotal);
|
||||
return (
|
||||
<div key={device.index} className="flex w-full min-w-0 flex-col items-center gap-1">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t("terminal.serverStats.gpuIndex", { index: device.index })}
|
||||
</div>
|
||||
{device.name ? (
|
||||
<div
|
||||
className="line-clamp-2 w-full break-words text-center text-[10px] leading-tight text-muted-foreground"
|
||||
title={device.name}
|
||||
>
|
||||
{device.name}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
usage === null ? "bg-muted" : usageBarClass(usage),
|
||||
)}
|
||||
style={{ width: `${usage ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-[11px] font-medium",
|
||||
usage === null ? "text-muted-foreground" : usageTextClass(usage),
|
||||
)}>
|
||||
{usage === null ? "--" : `${usage}%`}
|
||||
</div>
|
||||
{memory ? (
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">{memory}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 min-w-[160px]">
|
||||
{gpuName ? (
|
||||
<div className="text-[10px] text-muted-foreground truncate" title={gpuName}>{gpuName}</div>
|
||||
) : null}
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", usageBarClass(gpu))}
|
||||
style={{ width: `${gpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn("text-center text-[11px] font-medium", usageTextClass(gpu))}>
|
||||
{gpu}%{aggregateMemory ? ` · ${aggregateMemory}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -28,7 +28,4 @@ test("GPU chip only renders when the remote reports GPU utilization", () => {
|
||||
source,
|
||||
/terminal\.serverStats\.gpu[\s\S]{0,2000}terminal\.serverStats\.memory/,
|
||||
);
|
||||
// Same hover card as CPU: compact aggregate trigger, per-device body.
|
||||
assert.match(source, /HoverCardTrigger asChild[\s\S]*terminal\.serverStats\.gpu[\s\S]*<TerminalGpuCards/);
|
||||
assert.match(source, /className="w-auto p-3"[\s\S]*side="bottom"[\s\S]*align="start"[\s\S]*sideOffset=\{8\}[\s\S]*<TerminalGpuCards/);
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { formatNetSpeed } from './terminalHelpers';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
import { formatGpuMemoryGib, TerminalGpuCards } from './TerminalGpuCards';
|
||||
|
||||
interface TerminalServerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -49,7 +48,6 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
const hasNetworkDetails = serverStats.netInterfaces.length > 0;
|
||||
const hasLatency = serverStats.latencyMs !== null;
|
||||
const diskSummary = resolveTerminalDiskSummary(serverStats);
|
||||
const gpuMemory = formatGpuMemoryGib(serverStats.gpuMemUsed, serverStats.gpuMemTotal);
|
||||
|
||||
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
|
||||
|
||||
@@ -124,37 +122,28 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* GPU — only rendered when the remote reports NVIDIA GPU data (Linux nvidia-smi).
|
||||
Compact chip stays aggregate, matching CPU; per-card detail is the hover card. */}
|
||||
{/* GPU — only rendered when the remote reports NVIDIA GPU data (Linux nvidia-smi) */}
|
||||
{serverStats.gpu !== null && (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.gpu")}
|
||||
>
|
||||
<Gpu size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{serverStats.gpu}%
|
||||
{gpuMemory ? ` (${gpuMemory})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<TerminalGpuCards
|
||||
gpus={serverStats.gpus}
|
||||
gpu={serverStats.gpu}
|
||||
gpuName={serverStats.gpuName}
|
||||
gpuMemUsed={serverStats.gpuMemUsed}
|
||||
gpuMemTotal={serverStats.gpuMemTotal}
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<div
|
||||
className="flex items-center gap-0.5 min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.gpu")}
|
||||
title={
|
||||
[
|
||||
serverStats.gpuName,
|
||||
serverStats.gpuMemUsed !== null && serverStats.gpuMemTotal !== null && serverStats.gpuMemTotal > 0
|
||||
? `VRAM ${(serverStats.gpuMemUsed / 1024).toFixed(1)}/${(serverStats.gpuMemTotal / 1024).toFixed(1)}G`
|
||||
: null,
|
||||
].filter(Boolean).join(' · ') || undefined
|
||||
}
|
||||
>
|
||||
<Gpu size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{serverStats.gpu}%
|
||||
{serverStats.gpuMemUsed !== null && serverStats.gpuMemTotal !== null && serverStats.gpuMemTotal > 0
|
||||
? ` (${(serverStats.gpuMemUsed / 1024).toFixed(1)}/${(serverStats.gpuMemTotal / 1024).toFixed(1)}G)`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Memory with HoverCard for htop-style bar and top processes */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* zh-CN / zh-TW overlays for bundled Fig autocomplete descriptions.
|
||||
* Each dictionary is keyed by the exact English description string.
|
||||
* Spec objects stay in English; only suggestion copies shown in the popup change.
|
||||
*
|
||||
* The two dictionaries are separate dynamic imports so the renderer fetches
|
||||
* only the chunk for the active UI locale. zh-TW is generated from zh-CN with
|
||||
* OpenCC s2twp via scripts/generate-autocomplete-descriptions-zh-TW.mjs.
|
||||
*/
|
||||
|
||||
type DescriptionDictionary = Record<string, string>;
|
||||
|
||||
let zhCnDictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
let zhTwDictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
|
||||
function dictionaryFromModule(
|
||||
mod: { default?: DescriptionDictionary } | DescriptionDictionary,
|
||||
): DescriptionDictionary {
|
||||
const loaded = mod as { default?: DescriptionDictionary };
|
||||
return loaded.default ?? (mod as DescriptionDictionary);
|
||||
}
|
||||
|
||||
function loadZhCnDescriptions(): Promise<DescriptionDictionary> {
|
||||
zhCnDictionaryPromise ??= import("./descriptions.zh-CN.json").then(dictionaryFromModule);
|
||||
return zhCnDictionaryPromise;
|
||||
}
|
||||
|
||||
function loadZhTwDescriptions(): Promise<DescriptionDictionary> {
|
||||
zhTwDictionaryPromise ??= import("./descriptions.zh-TW.json").then(dictionaryFromModule);
|
||||
return zhTwDictionaryPromise;
|
||||
}
|
||||
|
||||
function loadDescriptionsForLocale(locale: string): Promise<DescriptionDictionary> | undefined {
|
||||
if (locale === "zh-CN") return loadZhCnDescriptions();
|
||||
if (locale === "zh-TW") return loadZhTwDescriptions();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy spec-backed suggestions so a locale overlay cannot leak into the
|
||||
* cached spec or into a later query in another locale.
|
||||
*/
|
||||
export async function localizeSpecSuggestionDescriptions<T extends { description?: string }>(
|
||||
suggestions: readonly T[],
|
||||
locale: string | undefined,
|
||||
): Promise<readonly T[]> {
|
||||
if (!locale || suggestions.length === 0) return suggestions;
|
||||
const pending = loadDescriptionsForLocale(locale);
|
||||
if (!pending) return suggestions;
|
||||
|
||||
let dictionary: DescriptionDictionary;
|
||||
try {
|
||||
dictionary = await pending;
|
||||
} catch {
|
||||
return suggestions;
|
||||
}
|
||||
let changed = false;
|
||||
const localized = suggestions.map((suggestion) => {
|
||||
const description = suggestion.description;
|
||||
if (!description) return suggestion;
|
||||
const translated = dictionary[description];
|
||||
if (!translated) return suggestion;
|
||||
changed = true;
|
||||
return { ...suggestion, description: translated };
|
||||
});
|
||||
return changed ? localized : suggestions;
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
resolvePathComponents,
|
||||
} from "./remotePathCompleter";
|
||||
import { getSnippetSuggestions } from "./snippetCompleter";
|
||||
import { localizeSpecSuggestionDescriptions } from "./autocompleteDescriptionI18n";
|
||||
import type { AutocompleteHistoryScope, Snippet } from "../../../domain/models";
|
||||
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
|
||||
|
||||
@@ -273,12 +272,6 @@ export async function getCompletions(
|
||||
* the shared directory cache for a later keystroke.
|
||||
*/
|
||||
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
|
||||
/**
|
||||
* UI locale. zh-CN and zh-TW overlay bundled spec descriptions from their
|
||||
* own dictionaries; any other locale keeps the original English text.
|
||||
* Cached specs are not rewritten.
|
||||
*/
|
||||
locale?: string;
|
||||
} = {},
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const { hostId, maxResults = 15, historyScope = "host" } = options;
|
||||
@@ -375,11 +368,7 @@ export async function getCompletions(
|
||||
)
|
||||
: [];
|
||||
|
||||
const specSuggestions = await localizeSpecSuggestionDescriptions(
|
||||
specResult.suggestions,
|
||||
options.locale,
|
||||
);
|
||||
for (const suggestion of specSuggestions) {
|
||||
for (const suggestion of specResult.suggestions) {
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -31,55 +31,10 @@ export function isNonPromptLine(lineText: string): boolean {
|
||||
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
|
||||
}
|
||||
|
||||
const PROGRAM_INPUT_LABEL = /^(?=[\p{L}\p{N}])[\p{L}\p{N} ._'()-]{1,60}$/u;
|
||||
|
||||
/** Identity prompts this feature targets. A custom PS1 like `dev: ` is not one. */
|
||||
const ORDINARY_INPUT_LABEL =
|
||||
/^(?:e-?mail(?: address)?|user(?: ?name| ?id)?|login(?: name)?|(?:full )?name|account(?: name)?|\u90ae\u7bb1|\u7535\u5b50\u90ae\u7bb1|\u7535\u5b50\u90ae\u4ef6|\u7528\u6237\u540d|\u7528\u6237|\u59d3\u540d|\u8d26\u53f7|\u5e10\u6237|\u8d26\u6237|\u767b\u5f55\u540d)$/u;
|
||||
|
||||
function colonLabelText(promptText: string): string | null {
|
||||
if (!promptText || promptText.length > 80) return null;
|
||||
if (isSensitiveTerminalChallenge(promptText)) return null;
|
||||
if (/\b(?:secret|passphrase|password|pin)\b/iu.test(promptText)) return null;
|
||||
|
||||
const trimmedEnd = promptText.trimEnd();
|
||||
const marker = trimmedEnd.at(-1);
|
||||
if (marker !== ":" && marker !== "\uFF1A") return null;
|
||||
|
||||
const label = trimmedEnd.slice(0, -1).trim();
|
||||
if (!PROGRAM_INPUT_LABEL.test(label)) return null;
|
||||
if (startsWithCommonShellCommand(label)) return null;
|
||||
return label;
|
||||
}
|
||||
|
||||
/** Colon label shape, including a custom shell PS1 (`dev: `). */
|
||||
function isColonLabelPromptText(promptText: string): boolean {
|
||||
return colonLabelText(promptText) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A program waiting for ordinary input (`read -p 'Email: '`).
|
||||
* Password, passphrase, PIN, and secret prompts stay closed.
|
||||
* Only the identity-label allowlist matches, so `PS1='dev: '` stays a shell prompt.
|
||||
*/
|
||||
export function isProgramInputPromptText(promptText: string): boolean {
|
||||
const label = colonLabelText(promptText);
|
||||
if (!label) return false;
|
||||
const normalized = label
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/^(?:please\s+)?(?:enter|input|type)\s+(?:your\s+)?/u, "")
|
||||
.replace(/^your\s+/u, "")
|
||||
.replace(/^(?:\u8bf7\u8f93\u5165|\u8bf7|\u8f93\u5165)/u, "")
|
||||
.trim();
|
||||
return ORDINARY_INPUT_LABEL.test(normalized);
|
||||
}
|
||||
|
||||
function isSpecificShellPromptCandidate(
|
||||
promptText: string,
|
||||
options: { allowGreaterThanTerminator?: boolean } = {},
|
||||
): boolean {
|
||||
if (isProgramInputPromptText(promptText)) return true;
|
||||
const trimmed = promptText.trim();
|
||||
if (
|
||||
!options.allowGreaterThanTerminator &&
|
||||
@@ -203,9 +158,6 @@ function inferPromptTextBeforeTypedInput(
|
||||
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
|
||||
}
|
||||
|
||||
// `Email: ` with the reply still in the keystroke buffer (echo not painted yet).
|
||||
if (isProgramInputPromptText(cursorPrefix)) return cursorPrefix;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -436,11 +388,6 @@ function alignTypedInputFromCursorPrefix(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Program read-prompts are snippet queries only. Do not override a real shell
|
||||
// prompt, and do not authorize Enter history recording.
|
||||
const programInput = isProgramInputPromptText(promptText);
|
||||
if (programInput && raw.isAtPrompt) return null;
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
@@ -448,7 +395,7 @@ function alignTypedInputFromCursorPrefix(
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: programInput ? null : typedBuffer,
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -624,9 +571,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
? lineText
|
||||
: cursorLinePrefix;
|
||||
const promptEnd = findPromptBoundary(promptScanText);
|
||||
// Colon read-prompts (`Email: `) stay out of shell prompt detection. Autocomplete
|
||||
// opts in later via alignTypedInputFromCursorPrefix.
|
||||
if (promptEnd >= 0 && !isProgramInputPromptText(lineText.substring(0, promptEnd))) {
|
||||
if (promptEnd >= 0) {
|
||||
const promptText = lineText.substring(0, promptEnd);
|
||||
// Input is whatever sits between the prompt and the cursor on the cell-
|
||||
// accurate prefix — don't use cursorX as a character index.
|
||||
@@ -656,7 +601,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const promptLineText = promptLine.translateToString(false);
|
||||
if (isSensitiveTerminalChallenge(promptLineText) || isNonPromptLine(promptLineText)) return NO_PROMPT;
|
||||
const pEnd = findPromptBoundary(promptLineText);
|
||||
if (pEnd >= 0 && !isProgramInputPromptText(promptLineText.substring(0, pEnd))) {
|
||||
if (pEnd >= 0) {
|
||||
const promptText = promptLineText.substring(0, pEnd);
|
||||
// Concatenate all rows from promptRow to cursorY to get full input
|
||||
let fullInput = promptLineText.substring(pEnd);
|
||||
@@ -872,24 +817,7 @@ function findPromptBoundary(lineText: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
if (lastStandardBoundary >= 0) return lastStandardBoundary;
|
||||
if (lastPuaBoundary >= 0) return lastPuaBoundary;
|
||||
return findProgramInputPromptBoundary(lineText);
|
||||
}
|
||||
|
||||
function findProgramInputPromptBoundary(lineText: string): number {
|
||||
const scanLimit = Math.min(lineText.trimEnd().length, 200);
|
||||
let last = -1;
|
||||
for (let i = 0; i < scanLimit; i++) {
|
||||
const ch = lineText[i];
|
||||
if (ch !== ":" && ch !== "\uFF1A") continue;
|
||||
const next = i + 1 < lineText.length ? lineText[i + 1] : null;
|
||||
if (next !== null && next !== " ") continue;
|
||||
const boundary = next === " " ? i + 2 : i + 1;
|
||||
if (!isColonLabelPromptText(lineText.slice(0, boundary))) continue;
|
||||
last = boundary;
|
||||
}
|
||||
return last;
|
||||
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
|
||||
import {
|
||||
isNonPromptLine,
|
||||
isProgramInputPromptText,
|
||||
reconcilePromptWithExternalCommand,
|
||||
type PromptDetectionResult,
|
||||
} from "./promptDetector";
|
||||
import { parseCommandLine, type CompletionSuggestion } from "./completionEngine";
|
||||
import { computeLivePreviewWrite, isWindowsShellLineInput } from "./livePreviewSequence";
|
||||
import { computeLivePreviewWrite } from "./livePreviewSequence";
|
||||
|
||||
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
|
||||
|
||||
@@ -105,87 +103,10 @@ export function isSameAutocompleteQuery(options: {
|
||||
return options.previewActive && options.previewBaseline === options.queryInput;
|
||||
}
|
||||
|
||||
const TRUSTED_ORDINARY_INPUT_LABELS = new Set([
|
||||
"email",
|
||||
"e-mail",
|
||||
"email address",
|
||||
"e-mail address",
|
||||
"username",
|
||||
"user name",
|
||||
"user",
|
||||
"userid",
|
||||
"user id",
|
||||
"login",
|
||||
"login name",
|
||||
"name",
|
||||
"full name",
|
||||
"account",
|
||||
"account name",
|
||||
"\u90ae\u7bb1",
|
||||
"\u7535\u5b50\u90ae\u7bb1",
|
||||
"\u7535\u5b50\u90ae\u4ef6",
|
||||
"\u7528\u6237\u540d",
|
||||
"\u7528\u6237",
|
||||
"\u59d3\u540d",
|
||||
"\u8d26\u53f7",
|
||||
"\u5e10\u6237",
|
||||
"\u8d26\u6237",
|
||||
"\u767b\u5f55\u540d",
|
||||
]);
|
||||
|
||||
const UNTRUSTED_INPUT_LABEL = /(?:\b(?:secret|passphrase|password|passcode|token|key|pin|otp)\b|\u5bc6\u7801|\u53e3\u4ee4|\u4ee4\u724c|\u5bc6\u94a5|\u9a8c\u8bc1\u7801)/iu;
|
||||
|
||||
function trustedOrdinaryInputLabel(promptText: string): string | null {
|
||||
const trimmedEnd = promptText.trimEnd();
|
||||
const marker = trimmedEnd.at(-1);
|
||||
if (marker !== ":" && marker !== "\uFF1A") return null;
|
||||
return trimmedEnd
|
||||
.slice(0, -1)
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/^(?:please\s+)?(?:enter|input|type)\s+(?:your\s+)?/u, "")
|
||||
.replace(/^your\s+/u, "")
|
||||
.replace(/^(?:\u8bf7\u8f93\u5165|\u8bf7|\u8f93\u5165)/u, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Positively identified ordinary identity prompts (`Email:`, `Username:`).
|
||||
* Unknown labels stay fail-closed so they cannot bypass the sensitive latch.
|
||||
*/
|
||||
function isTrustedOrdinaryInputPromptText(promptText: string): boolean {
|
||||
if (UNTRUSTED_INPUT_LABEL.test(promptText)) return false;
|
||||
const label = trustedOrdinaryInputLabel(promptText);
|
||||
if (!label || UNTRUSTED_INPUT_LABEL.test(label)) return false;
|
||||
return TRUSTED_ORDINARY_INPUT_LABELS.has(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `sensitiveInputActive` is latched.
|
||||
*
|
||||
* `heuristic` is the untrusted-prompt detector (`Email:`, unknown `Label:`).
|
||||
* `explicit` is authoritative metadata such as `pluginPipelineSensitiveInput`.
|
||||
* Omitted means heuristic, so existing callers keep the trusted-label bypass.
|
||||
*/
|
||||
export type SensitiveAutocompleteLatchSource = "heuristic" | "explicit";
|
||||
|
||||
export type SensitiveInputActiveRef = {
|
||||
current: boolean;
|
||||
source?: SensitiveAutocompleteLatchSource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether fetchSuggestions must refuse to query/render for an already-known
|
||||
* sensitive line (host latch or auth-challenge prompt text).
|
||||
*
|
||||
* The heuristic latch is set for every untrusted input boundary, including
|
||||
* safe program prompts such as `Email:`. Only a small allowlist of plain
|
||||
* identity labels may bypass that latch so snippet candidates can render.
|
||||
* An explicit latch (plugin pipeline or other authoritative sensitive
|
||||
* metadata) stays closed for those labels too. Unknown labels, and anything
|
||||
* password/token/key/PIN/OTP-like, stay closed.
|
||||
*
|
||||
* This is *not* a substitute for the empty-echo / `allowExternalProviders:
|
||||
* false` wait in useTerminalAutocomplete: `read -s -p '$ '` still looks like
|
||||
* a normal shell PS1 until echo validates, so that path stays fail-closed
|
||||
@@ -194,16 +115,7 @@ export type SensitiveInputActiveRef = {
|
||||
export function shouldBlockAutocompleteForSensitivePrompt(options: {
|
||||
sensitiveInputActive: boolean;
|
||||
promptText: string;
|
||||
sensitiveSource?: SensitiveAutocompleteLatchSource;
|
||||
}): boolean {
|
||||
const explicitLatch = options.sensitiveInputActive && options.sensitiveSource === "explicit";
|
||||
if (
|
||||
options.sensitiveInputActive
|
||||
&& !explicitLatch
|
||||
&& isTrustedOrdinaryInputPromptText(options.promptText)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (options.sensitiveInputActive) return true;
|
||||
return isSensitiveTerminalChallenge(options.promptText);
|
||||
}
|
||||
@@ -257,7 +169,6 @@ export function getCommandToRecordOnEnter(
|
||||
typedBufferReliable: boolean,
|
||||
): string | null {
|
||||
if (!livePrompt.isAtPrompt) return null;
|
||||
if (isProgramInputPromptText(livePrompt.promptText)) return null;
|
||||
const alignedCommand = alignedTyped?.trim();
|
||||
if (alignedCommand) return alignedCommand;
|
||||
|
||||
@@ -298,46 +209,3 @@ export function getCommandToRecordOnEnter(
|
||||
return liveCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keystrokes that remove the text a snippet acceptance replaces.
|
||||
* Line-start keeps the shell kill-line (Ctrl+U, or backspaces on Windows).
|
||||
* Mid-line and program read-prompts delete only the word being typed.
|
||||
* POSIX word delete sends DEL, the tty erase character. Ctrl-H is literal
|
||||
* input to canonical `read` when erase is ^?, while readline still deletes
|
||||
* on DEL. Windows line editing keeps backspace.
|
||||
* Script snippets run automation and do not replace the line, so they keep
|
||||
* the full-line clear.
|
||||
*/
|
||||
export function computeSnippetAcceptClearSequence(options: {
|
||||
line: string | null;
|
||||
os: string;
|
||||
promptText: string;
|
||||
snippetKind?: "snippet" | "script";
|
||||
}): string {
|
||||
const line = options.line ?? "";
|
||||
if (line.length === 0) return "";
|
||||
|
||||
const { currentWord, wordIndex } = parseCommandLine(line);
|
||||
const replaceWord = options.snippetKind !== "script"
|
||||
&& currentWord.length > 0
|
||||
&& line.endsWith(currentWord)
|
||||
&& (wordIndex > 0 || isProgramInputPromptText(options.promptText));
|
||||
if (replaceWord) {
|
||||
const eraseChar = isWindowsShellLineInput(options.os, options.promptText) ? "\b" : "\x7f";
|
||||
return eraseChar.repeat(Array.from(currentWord).length);
|
||||
}
|
||||
|
||||
return isWindowsShellLineInput(options.os, options.promptText)
|
||||
? "\b".repeat(line.length)
|
||||
: "\x15";
|
||||
}
|
||||
|
||||
/** Program read-prompts offer snippets only; shell lines keep every source. */
|
||||
export function limitSuggestionsForPrompt(
|
||||
promptText: string,
|
||||
suggestions: CompletionSuggestion[],
|
||||
): CompletionSuggestion[] {
|
||||
if (!isProgramInputPromptText(promptText)) return suggestions;
|
||||
return suggestions.filter((suggestion) => suggestion.source === "snippet");
|
||||
}
|
||||
|
||||
@@ -29,8 +29,6 @@ export interface TerminalCompletionProviderRequest {
|
||||
* soft budget (cache-bypassed relative SSH cwd).
|
||||
*/
|
||||
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
|
||||
/** UI locale forwarded to built-in spec description overlay. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS = 800;
|
||||
@@ -76,7 +74,6 @@ export async function provideTerminalCompletions(
|
||||
snippets: request.snippets,
|
||||
historyScope: request.historyScope,
|
||||
onLatePathSuggestions: request.onLatePathSuggestions,
|
||||
locale: request.locale,
|
||||
});
|
||||
const pluginRequestController = new AbortController();
|
||||
const abortPluginRequest = () => pluginRequestController.abort();
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
shouldPreferRemoteShellCwd,
|
||||
} from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
import { shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
|
||||
import { isWindowsShellLineInput, shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
|
||||
import {
|
||||
areSubDirPanelsEqual,
|
||||
areSuggestionsEqual,
|
||||
@@ -41,12 +41,9 @@ import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
|
||||
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
|
||||
import {
|
||||
computeAutocompleteAcceptWrite,
|
||||
computeSnippetAcceptClearSequence,
|
||||
isSameAutocompleteQuery,
|
||||
limitSuggestionsForPrompt,
|
||||
resolveAutocompleteQueryInput,
|
||||
shouldBlockAutocompleteForSensitivePrompt,
|
||||
type SensitiveInputActiveRef,
|
||||
} from "./terminalAutocompletePrompt";
|
||||
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
|
||||
import { isTerminalAlternateScreenActive } from "../terminalHibernateRuntime";
|
||||
@@ -237,7 +234,7 @@ interface UseTerminalAutocompleteOptions {
|
||||
* Host-owned password/auth prompt latch. When true, suppress autocomplete
|
||||
* even if the PS1 still looks like a normal shell (e.g. `read -s -p '$ '`).
|
||||
*/
|
||||
sensitiveInputActiveRef?: SensitiveInputActiveRef;
|
||||
sensitiveInputActiveRef?: RefObject<boolean>;
|
||||
/** Host-owned completion Provider adapter; defaults to Netcatty's built-in Provider. */
|
||||
provideCompletions?: (
|
||||
input: string,
|
||||
@@ -879,7 +876,6 @@ export function useTerminalAutocomplete(
|
||||
if (
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: sensitiveInputActiveRef?.current === true,
|
||||
sensitiveSource: sensitiveInputActiveRef?.source,
|
||||
promptText: prompt.promptText,
|
||||
})
|
||||
) {
|
||||
@@ -1008,11 +1004,10 @@ export function useTerminalAutocomplete(
|
||||
};
|
||||
|
||||
const applyCompletions = (
|
||||
incomingCompletions: CompletionSuggestion[],
|
||||
completions: CompletionSuggestion[],
|
||||
currentPrompt: ReturnType<typeof getAlignedPrompt>["prompt"],
|
||||
options?: { preserveSelection?: boolean },
|
||||
) => {
|
||||
const completions = limitSuggestionsForPrompt(currentPrompt.promptText, incomingCompletions);
|
||||
// Echo-lag-aware logical caret: resolved query `input` can be ahead of
|
||||
// the live xterm cursor / currentPrompt.userInput. When a late path
|
||||
// refresh lands while live-preview has already rewritten the shell line,
|
||||
@@ -1345,8 +1340,8 @@ export function useTerminalAutocomplete(
|
||||
[handleCommandHistoryCleared],
|
||||
);
|
||||
|
||||
/** Accept a snippet: clear the matched word (or the whole line for a script
|
||||
* or a shell prompt), then run it via onAcceptSnippet. */
|
||||
/** Accept a snippet: clear the user's typed input, then run it via the
|
||||
* host-canonical send path (onAcceptSnippet). */
|
||||
const acceptSnippet = useCallback((snippet: Snippet): boolean => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
@@ -1358,13 +1353,10 @@ export function useTerminalAutocomplete(
|
||||
);
|
||||
if (line !== null && line.length > 0) {
|
||||
if (!settingsRef.current.allowLineReplacement) return false;
|
||||
const clearSequence = computeSnippetAcceptClearSequence({
|
||||
line,
|
||||
os: hostOsRef.current,
|
||||
promptText: prompt.promptText,
|
||||
snippetKind: snippet.kind,
|
||||
});
|
||||
if (clearSequence) writeToTerminal(clearSequence);
|
||||
const clearSequence = isWindowsShellLineInput(hostOsRef.current, prompt.promptText)
|
||||
? "\b".repeat(line.length)
|
||||
: "\x15"; // Ctrl+U (readline kill-line)
|
||||
writeToTerminal(clearSequence);
|
||||
}
|
||||
}
|
||||
typedInputBufferRef.current = "";
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
||||
|
||||
const SINCE = "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)";
|
||||
const SINCE_ZH = "从指定时间起显示日志(例如 2013-01-02T13:23:37Z),也可用相对时间(例如 42m 表示 42 分钟)";
|
||||
const SINCE_TW = "從指定時間起顯示日誌(例如 2013-01-02T13:23:37Z),也可用相對時間(例如 42m 表示 42 分鐘)";
|
||||
const FILE_REMOVED = "\"%s: File removed before we read it\"";
|
||||
const FILE_REMOVED_TW = "\"%s: 讀取前檔案已被刪除\"";
|
||||
const DEFAULT_LABEL = "(Default)";
|
||||
const NETWORK = "Add a network";
|
||||
const PROCESS_TREE = "ASCII art process tree";
|
||||
const COMPRESSION_PROGRAM = "Do not use archive suffix to determine the compression program";
|
||||
const UNTRANSLATED = "This autocomplete description is intentionally untranslated.";
|
||||
|
||||
const dockerSpec: FigSpec = {
|
||||
name: "docker",
|
||||
subcommands: [
|
||||
{
|
||||
name: "logs",
|
||||
options: [
|
||||
{ name: "--since", description: SINCE },
|
||||
{ name: "--mystery", description: UNTRANSLATED },
|
||||
{ name: "--default-sample", description: DEFAULT_LABEL },
|
||||
{ name: "--network-sample", description: NETWORK },
|
||||
{ name: "--process-sample", description: PROCESS_TREE },
|
||||
{ name: "--file-sample", description: FILE_REMOVED },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
listFigSpecs: async () => ["docker"],
|
||||
loadFigSpec: async (commandName: string) => commandName === "docker" ? dockerSpec : null,
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
|
||||
|
||||
function sinceSuggestion(suggestions: Awaited<ReturnType<typeof getCompletions>>) {
|
||||
return suggestions.find((suggestion) => suggestion.displayText === "--since");
|
||||
}
|
||||
|
||||
function suggestionNamed(
|
||||
suggestions: Awaited<ReturnType<typeof getCompletions>>,
|
||||
displayText: string,
|
||||
) {
|
||||
return suggestions.find((suggestion) => suggestion.displayText === displayText);
|
||||
}
|
||||
|
||||
test("zh-CN translates docker logs --since and falls back for unknown descriptions", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "zh-CN",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const since = sinceSuggestion(suggestions);
|
||||
const mystery = suggestions.find((suggestion) => suggestion.displayText === "--mystery");
|
||||
|
||||
assert.equal(since?.displayText, "--since");
|
||||
assert.equal(since?.description, SINCE_ZH);
|
||||
assert.equal(mystery?.description, UNTRANSLATED);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("en locale leaves autocomplete descriptions unchanged", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "en",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const since = sinceSuggestion(suggestions);
|
||||
|
||||
assert.equal(since?.displayText, "--since");
|
||||
assert.equal(since?.description, SINCE);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("zh-TW translates descriptions with Taiwan phrases and falls back when missing", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "zh-TW",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
|
||||
assert.equal(sinceSuggestion(suggestions)?.description, SINCE_TW);
|
||||
assert.equal(suggestionNamed(suggestions, "--default-sample")?.description, "(預設)");
|
||||
assert.equal(suggestionNamed(suggestions, "--network-sample")?.description, "新增網路");
|
||||
assert.equal(suggestionNamed(suggestions, "--process-sample")?.description, "ASCII 藝術行程樹");
|
||||
assert.equal(suggestionNamed(suggestions, "--file-sample")?.description, FILE_REMOVED_TW);
|
||||
assert.equal(suggestionNamed(suggestions, "--mystery")?.description, UNTRANSLATED);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("locales other than zh-CN and zh-TW keep English descriptions", async () => {
|
||||
for (const locale of ["ru", "es", "zh-Hant", undefined]) {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale,
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
assert.equal(sinceSuggestion(suggestions)?.description, SINCE);
|
||||
assert.equal(suggestionNamed(suggestions, "--process-sample")?.description, PROCESS_TREE);
|
||||
}
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("zh-CN and zh-TW overlays do not leak across later lookups", async () => {
|
||||
const traditional = await getCompletions("docker logs --", {
|
||||
locale: "zh-TW",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const simplified = await getCompletions("docker logs --", {
|
||||
locale: "zh-CN",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const english = await getCompletions("docker logs --", {
|
||||
locale: "en",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
|
||||
assert.equal(sinceSuggestion(traditional)?.description, SINCE_TW);
|
||||
assert.equal(sinceSuggestion(simplified)?.description, SINCE_ZH);
|
||||
assert.equal(sinceSuggestion(english)?.description, SINCE);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("committed zh-TW dictionary keeps zh-CN keys and Taiwan terminology", () => {
|
||||
const dictDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "autocomplete");
|
||||
const simplified = JSON.parse(fs.readFileSync(path.join(dictDir, "descriptions.zh-CN.json"), "utf8"));
|
||||
const traditional = JSON.parse(fs.readFileSync(path.join(dictDir, "descriptions.zh-TW.json"), "utf8"));
|
||||
|
||||
assert.deepEqual(Object.keys(traditional), Object.keys(simplified));
|
||||
assert.equal(simplified[DEFAULT_LABEL], "(默认)");
|
||||
assert.equal(traditional[DEFAULT_LABEL], "(預設)");
|
||||
assert.equal(traditional[NETWORK], "新增網路");
|
||||
assert.equal(traditional[PROCESS_TREE], "ASCII 藝術行程樹");
|
||||
assert.equal(traditional[FILE_REMOVED], FILE_REMOVED_TW);
|
||||
assert.equal(traditional[SINCE], SINCE_TW);
|
||||
assert.equal(traditional[COMPRESSION_PROGRAM], "不根據歸檔字尾判斷壓縮程式");
|
||||
|
||||
let processCount = 0;
|
||||
let tripCount = 0;
|
||||
for (const key of Object.keys(simplified)) {
|
||||
const source = simplified[key];
|
||||
const converted = traditional[key];
|
||||
assert.equal(typeof converted, "string");
|
||||
assert.equal(converted.includes("进程"), false);
|
||||
assert.equal(converted.includes("默认"), false);
|
||||
assert.equal(converted.includes("网络"), false);
|
||||
processCount += source.split("进程").length - 1;
|
||||
tripCount += converted.split("行程").length - 1;
|
||||
}
|
||||
assert.ok(processCount > 0);
|
||||
assert.equal(tripCount, processCount);
|
||||
});
|
||||
@@ -8,10 +8,6 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
|
||||
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
@@ -134,11 +130,7 @@ import {
|
||||
shouldFlushDeferredImeTextInputOnKeyUp,
|
||||
shouldFlushStaleDeferredImeTextInput,
|
||||
} from "./terminalImeTextInput";
|
||||
import {
|
||||
keepImeCommittedTextThroughModifierKeyDowns,
|
||||
keepLateImeTextInsert,
|
||||
markImeDeferralConsumedInput,
|
||||
} from "./imeModifierKeyDownSeenGuard";
|
||||
import { keepImeCommittedTextThroughModifierKeyDowns } from "./imeModifierKeyDownSeenGuard";
|
||||
import { keepLiveImeTranscriptionSingle } from "./imeCompositionRewrite";
|
||||
import { formatSerialLocalEcho } from "./serialLocalEcho";
|
||||
import { getLastChar, removeLastChar, isPrintableInput } from "../../../domain/serialCharMetrics";
|
||||
@@ -325,8 +317,6 @@ export const resetKittyKeyboardModeStateForSession = (
|
||||
export type CreateXTermRuntimeContext = {
|
||||
container: HTMLDivElement;
|
||||
host: Host;
|
||||
/** Host OS facts can arrive after the runtime is created. */
|
||||
getHost?: () => Host;
|
||||
fontFamilyId: string;
|
||||
resolvedFontFamily: string;
|
||||
fontSize: number;
|
||||
@@ -676,11 +666,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// Chinese/English mode; a pure modifier keydown must not arm xterm's
|
||||
// insertText dedupe guard or the committed text is dropped (#3441).
|
||||
keepImeCommittedTextThroughModifierKeyDowns(term);
|
||||
// Sogou Chinese mode also commits latin text on Enter via a late
|
||||
// insertText after keyCode 229, with no composition events. The listener
|
||||
// has to be armed before open() so it runs before xterm's input handler
|
||||
// (#3185).
|
||||
keepLateImeTextInsert(term);
|
||||
|
||||
type MaybeRenderer = {
|
||||
constructor?: { name?: string };
|
||||
@@ -980,16 +965,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
terminalLinkHandler.activate(event, uri);
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
const cwdMarkers = createTerminalCwdMarkers(term);
|
||||
const archiveLinks = registerTerminalArchiveLinks({
|
||||
term,
|
||||
cwdMarkers,
|
||||
isConnected: () => ctx.statusRef.current === "connected",
|
||||
isBroadcasting: () => ctx.isBroadcastEnabledRef.current === true,
|
||||
isHostEligible: () => supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
|
||||
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId, { allowDraft: true }),
|
||||
linkModifier: () => ctx.terminalSettingsRef.current?.linkModifier ?? "none",
|
||||
});
|
||||
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
|
||||
? registerPluginTerminalLinkProvider({
|
||||
term,
|
||||
@@ -2920,8 +2895,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const markKittyTextInput = (event: InputEvent) => {
|
||||
if (shouldCommitDeferredImeTextInput(imeTextInputDeferredKey, event)) {
|
||||
commitImeTextInput(event.data);
|
||||
// This insert was already sent. Leave xterm's dedupe flag armed.
|
||||
markImeDeferralConsumedInput(event);
|
||||
// Keep the helper textarea empty so the next composition does not treat
|
||||
// the deferred punctuation as pre-existing suffix text.
|
||||
try {
|
||||
@@ -3266,14 +3239,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (path && path.length > 0) {
|
||||
currentCwd = path;
|
||||
ctx.onCwdChange?.(path);
|
||||
cwdMarkers.note({ cwd: path, host: url.hostname });
|
||||
logger.debug('[XTerm] OSC 7 CWD update:', path);
|
||||
}
|
||||
} else if (data.startsWith('/')) {
|
||||
// Some shells send just the path without file:// prefix
|
||||
currentCwd = data;
|
||||
ctx.onCwdChange?.(data);
|
||||
cwdMarkers.note({ cwd: data, host: '' });
|
||||
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3522,8 +3493,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
keywordHighlighter.dispose();
|
||||
cursorLineHighlighter.dispose();
|
||||
pluginLinkProviderHost?.dispose();
|
||||
archiveLinks.dispose();
|
||||
cwdMarkers.dispose();
|
||||
pluginProviderHost?.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
|
||||
@@ -3,14 +3,10 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
isModifierOnlyKeyDownEvent,
|
||||
keepImeCommittedTextThroughModifierKeyDowns,
|
||||
keepLateImeTextInsert,
|
||||
markImeDeferralConsumedInput,
|
||||
} from "./imeModifierKeyDownSeenGuard";
|
||||
import { keepLiveImeTranscriptionSingle } from "./imeCompositionRewrite";
|
||||
|
||||
const runtimeSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
|
||||
@@ -100,286 +96,4 @@ test("runtime installs the guard when the terminal is created", () => {
|
||||
runtimeSource,
|
||||
/keepImeCommittedTextThroughModifierKeyDowns\(term\)/,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/commitImeTextInput\(event\.data\);\s*\/\/ This insert was already sent\. Leave xterm's dedupe flag armed\.\s*markImeDeferralConsumedInput\(event\);/,
|
||||
);
|
||||
const modifierAt = runtimeSource.indexOf("keepImeCommittedTextThroughModifierKeyDowns(term)");
|
||||
const lateAt = runtimeSource.indexOf("keepLateImeTextInsert(term)");
|
||||
const openAt = runtimeSource.indexOf("term.open(ctx.container)");
|
||||
assert.ok(lateAt > modifierAt, "late IME insert guard is installed with the modifier guard");
|
||||
assert.ok(lateAt < openAt, "late IME insert guard is installed before open() binds xterm input");
|
||||
});
|
||||
|
||||
type XTermModule = {
|
||||
Terminal: new (options?: {
|
||||
allowProposedApi?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cursorBlink?: boolean;
|
||||
}) => {
|
||||
open(parent: HTMLElement): void;
|
||||
onData(listener: (data: string) => void): { dispose(): void };
|
||||
dispose(): void;
|
||||
textarea: HTMLTextAreaElement | undefined;
|
||||
_core: { _keyDownSeen?: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
let xtermModule: XTermModule | undefined;
|
||||
let domWindow: JSDOM["window"] | undefined;
|
||||
|
||||
function installXtermDom(): JSDOM["window"] {
|
||||
if (domWindow) return domWindow;
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost/",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const win = dom.window;
|
||||
win.matchMedia = () => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent() { return false; },
|
||||
});
|
||||
const context = {
|
||||
font: "10px monospace",
|
||||
measureText: () => ({ width: 8 }),
|
||||
fillRect() {},
|
||||
clearRect() {},
|
||||
fillText() {},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
|
||||
putImageData() {},
|
||||
scale() {},
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fill() {},
|
||||
rect() {},
|
||||
clip() {},
|
||||
};
|
||||
win.HTMLCanvasElement.prototype.getContext = () => context as unknown as CanvasRenderingContext2D;
|
||||
Object.assign(globalThis, {
|
||||
window: win,
|
||||
document: win.document,
|
||||
HTMLElement: win.HTMLElement,
|
||||
HTMLTextAreaElement: win.HTMLTextAreaElement,
|
||||
KeyboardEvent: win.KeyboardEvent,
|
||||
InputEvent: win.InputEvent,
|
||||
CompositionEvent: win.CompositionEvent,
|
||||
Node: win.Node,
|
||||
Element: win.Element,
|
||||
getComputedStyle: win.getComputedStyle.bind(win),
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 16),
|
||||
});
|
||||
domWindow = win;
|
||||
return win;
|
||||
}
|
||||
|
||||
async function loadXterm(): Promise<XTermModule> {
|
||||
if (xtermModule) return xtermModule;
|
||||
installXtermDom();
|
||||
const imported = await import("@xterm/xterm");
|
||||
const terminal = (imported as { default?: XTermModule }).default?.Terminal
|
||||
?? (imported as unknown as XTermModule).Terminal;
|
||||
xtermModule = { Terminal: terminal };
|
||||
return xtermModule;
|
||||
}
|
||||
|
||||
function flushMacrotask(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function withKeyCode(event: Event, keyCode: number): Event {
|
||||
Object.defineProperty(event, "keyCode", { get: () => keyCode });
|
||||
Object.defineProperty(event, "which", { get: () => keyCode });
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production wiring: both guards before open(), transcription rewrite after.
|
||||
* xterm's input listener is registered during open(), after onWillOpen.
|
||||
*/
|
||||
async function openGuardedTerminal(): Promise<{
|
||||
term: InstanceType<XTermModule["Terminal"]>;
|
||||
textarea: HTMLTextAreaElement;
|
||||
sent: string[];
|
||||
win: JSDOM["window"];
|
||||
}> {
|
||||
const win = installXtermDom();
|
||||
const { Terminal } = await loadXterm();
|
||||
const parent = win.document.createElement("div");
|
||||
win.document.body.appendChild(parent);
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cursorBlink: false,
|
||||
});
|
||||
assert.equal(term.textarea, undefined, "textarea exists only after open()");
|
||||
keepImeCommittedTextThroughModifierKeyDowns(term as never);
|
||||
keepLateImeTextInsert(term as never);
|
||||
term.open(parent);
|
||||
keepLiveImeTranscriptionSingle(term);
|
||||
const textarea = term.textarea;
|
||||
assert.ok(textarea, "open() creates the helper textarea");
|
||||
const sent: string[] = [];
|
||||
term.onData((data) => sent.push(data));
|
||||
return { term, textarea: textarea as HTMLTextAreaElement, sent, win };
|
||||
}
|
||||
|
||||
function closeTerminal(term: { dispose(): void }): void {
|
||||
term.dispose();
|
||||
}
|
||||
|
||||
function dispatchKey(
|
||||
win: JSDOM["window"],
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: "keydown" | "keyup",
|
||||
key: string,
|
||||
keyCode: number,
|
||||
): void {
|
||||
const event = withKeyCode(
|
||||
new win.KeyboardEvent(type, { key, bubbles: true, cancelable: true }),
|
||||
keyCode,
|
||||
);
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function dispatchInsert(
|
||||
win: JSDOM["window"],
|
||||
textarea: HTMLTextAreaElement,
|
||||
data: string,
|
||||
init: { isComposing?: boolean } = {},
|
||||
): InputEvent {
|
||||
textarea.value = data;
|
||||
const event = new win.InputEvent("input", {
|
||||
data,
|
||||
inputType: "insertText",
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
});
|
||||
if (init.isComposing) {
|
||||
Object.defineProperty(event, "isComposing", { get: () => true });
|
||||
}
|
||||
assert.equal(event.composed, true, "IME insertText is a composed event, so _keyDownSeen can drop it");
|
||||
assert.equal(event.inputType, "insertText");
|
||||
assert.equal(event.data, data);
|
||||
textarea.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test("late insert guard does not undo the modifier keydown guard", () => {
|
||||
const { core, term } = createFakeCore(false);
|
||||
keepImeCommittedTextThroughModifierKeyDowns(term as never);
|
||||
keepLateImeTextInsert(term as never);
|
||||
core._keyDown({ key: "Shift", keyCode: 16 } as KeyboardEvent);
|
||||
assert.equal(core._keyDownSeen, false);
|
||||
core._keyDown({ key: "a", keyCode: 65 } as KeyboardEvent);
|
||||
assert.equal(core._keyDownSeen, true);
|
||||
});
|
||||
|
||||
test("late keyCode 229 insertText is forwarded exactly once", async () => {
|
||||
const { term, textarea, sent, win } = await openGuardedTerminal();
|
||||
try {
|
||||
for (let index = 0; index < "qzmark".length; index += 1) {
|
||||
dispatchKey(win, textarea, "keydown", "Process", 229);
|
||||
await flushMacrotask();
|
||||
}
|
||||
dispatchKey(win, textarea, "keydown", "Enter", 229);
|
||||
await flushMacrotask();
|
||||
assert.deepEqual(sent, [], "the 0ms check runs before Sogou's text and sends nothing");
|
||||
|
||||
dispatchInsert(win, textarea, "qzmark");
|
||||
assert.deepEqual(sent, ["qzmark"]);
|
||||
|
||||
dispatchKey(win, textarea, "keyup", "Enter", 229);
|
||||
dispatchKey(win, textarea, "keydown", "Enter", 13);
|
||||
assert.deepEqual(sent, ["qzmark", "\r"]);
|
||||
} finally {
|
||||
closeTerminal(term);
|
||||
}
|
||||
});
|
||||
|
||||
test("insertText that arrives before the 0ms check is not sent twice", async () => {
|
||||
const { term, textarea, sent, win } = await openGuardedTerminal();
|
||||
try {
|
||||
dispatchKey(win, textarea, "keydown", "Enter", 229);
|
||||
dispatchInsert(win, textarea, "qzmark");
|
||||
assert.deepEqual(sent, [], "xterm's handler still ignores the insert while the check is pending");
|
||||
await flushMacrotask();
|
||||
assert.deepEqual(sent, ["qzmark"]);
|
||||
} finally {
|
||||
closeTerminal(term);
|
||||
}
|
||||
});
|
||||
|
||||
test("normal composition still commits once", async () => {
|
||||
const { term, textarea, sent, win } = await openGuardedTerminal();
|
||||
try {
|
||||
dispatchKey(win, textarea, "keydown", "Process", 229);
|
||||
textarea.dispatchEvent(new win.CompositionEvent("compositionstart", { bubbles: true }));
|
||||
textarea.value = "\u4f60\u597d";
|
||||
textarea.dispatchEvent(new win.CompositionEvent("compositionupdate", {
|
||||
data: "\u4f60\u597d",
|
||||
bubbles: true,
|
||||
}));
|
||||
dispatchInsert(win, textarea, "\u4f60\u597d", { isComposing: true });
|
||||
textarea.dispatchEvent(new win.CompositionEvent("compositionend", {
|
||||
data: "\u4f60\u597d",
|
||||
bubbles: true,
|
||||
}));
|
||||
await flushMacrotask();
|
||||
await flushMacrotask();
|
||||
assert.equal(sent.join(""), "\u4f60\u597d");
|
||||
} finally {
|
||||
closeTerminal(term);
|
||||
}
|
||||
});
|
||||
|
||||
test("insertText already committed by the IME deferral is not sent again", async () => {
|
||||
// A deferred punctuation key whose keyup was swallowed stays armed across
|
||||
// later keyCode 229 keydowns. markKittyTextInput then commits the eventual
|
||||
// insertText before this guard runs; xterm must not emit that string too.
|
||||
const { term, textarea, sent, win } = await openGuardedTerminal();
|
||||
try {
|
||||
const ancestor = textarea.parentElement;
|
||||
assert.ok(ancestor);
|
||||
ancestor.addEventListener("input", (event) => {
|
||||
const input = event as InputEvent;
|
||||
if (input.inputType !== "insertText" || !input.data) return;
|
||||
markImeDeferralConsumedInput(event);
|
||||
sent.push(input.data);
|
||||
}, true);
|
||||
|
||||
dispatchKey(win, textarea, "keydown", "Enter", 229);
|
||||
await flushMacrotask();
|
||||
assert.deepEqual(sent, [], "the 0ms check still finds no text");
|
||||
|
||||
dispatchInsert(win, textarea, "qzmark");
|
||||
assert.deepEqual(sent, ["qzmark"]);
|
||||
assert.equal(term._core._keyDownSeen, true);
|
||||
} finally {
|
||||
closeTerminal(term);
|
||||
}
|
||||
});
|
||||
|
||||
test("a non-229 keydown still dedupes its insertText", async () => {
|
||||
const { term, textarea, sent, win } = await openGuardedTerminal();
|
||||
try {
|
||||
dispatchKey(win, textarea, "keydown", "a", 65);
|
||||
dispatchInsert(win, textarea, "a");
|
||||
assert.equal(sent.join(""), "a");
|
||||
assert.equal(term._core._keyDownSeen, true);
|
||||
} finally {
|
||||
closeTerminal(term);
|
||||
}
|
||||
});
|
||||
@@ -20,13 +20,6 @@ import { isModifierOnlyKey } from "./terminalImeTextInput";
|
||||
/** Classic keyCodes that stand for a modifier-only keydown. */
|
||||
const MODIFIER_KEY_CODES = new Set([16, 17, 18, 91, 92, 93, 224]);
|
||||
|
||||
/**
|
||||
* keyCode 229 is the DOM "composition" key. Windows Sogou in Chinese mode
|
||||
* uses it for latin letters and for the Enter that commits them, without
|
||||
* ever firing composition events (#3185).
|
||||
*/
|
||||
const IME_PROCESS_KEY_CODE = 229;
|
||||
|
||||
export function isModifierOnlyKeyDownEvent(event: {
|
||||
key: string;
|
||||
keyCode?: number;
|
||||
@@ -42,86 +35,6 @@ type XTermInputCore = {
|
||||
_keyDownSeen?: boolean;
|
||||
};
|
||||
|
||||
type CompositionHelperSnapshot = {
|
||||
_isComposing?: boolean;
|
||||
_isSendingComposition?: boolean;
|
||||
_textareaChangeTimer?: unknown;
|
||||
};
|
||||
|
||||
type LateImeInsertCore = XTermInputCore & {
|
||||
textarea?: HTMLTextAreaElement | null;
|
||||
onWillOpen?: (listener: () => void) => { dispose?: () => void } | void;
|
||||
_compositionHelper?: CompositionHelperSnapshot;
|
||||
__ncLateImeInsertInstalled?: boolean;
|
||||
};
|
||||
|
||||
type TextareaWithLateImeInsert = HTMLTextAreaElement & {
|
||||
__ncLateImeInsert?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* An IME commit that arrives as an `input` event. Sogou's latin commit is
|
||||
* `inputType: "insertText"` with the committed string in `data`.
|
||||
*/
|
||||
function isImeTextInsert(event: {
|
||||
inputType?: string;
|
||||
data?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
event.inputType === "insertText"
|
||||
&& typeof event.data === "string"
|
||||
&& event.data.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* insertText events the punctuation deferral already forwarded via
|
||||
* `commitImeTextInput`. That handler runs on an ancestor during capture,
|
||||
* before this guard, and the same event object reaches both.
|
||||
*/
|
||||
const imeDeferralConsumedInputs = new WeakSet<object>();
|
||||
|
||||
/** Keep xterm from sending an insertText the IME deferral already committed. */
|
||||
export function markImeDeferralConsumedInput(event: object): void {
|
||||
imeDeferralConsumedInputs.add(event);
|
||||
}
|
||||
|
||||
function isImeDeferralConsumedInput(event: object): boolean {
|
||||
return imeDeferralConsumedInputs.has(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when xterm's `_inputEvent` must be allowed to forward this insert.
|
||||
* The 0ms CompositionHelper check already ran and found nothing; the
|
||||
* committed text showed up on a later task. Clearing `_keyDownSeen` is the
|
||||
* only change: xterm's own handler performs the send.
|
||||
*
|
||||
* While that check is still scheduled, the textarea already holds the text
|
||||
* and the check will send it. Leaving the flag armed stops `_inputEvent`
|
||||
* from sending the same string again. The same holds when the punctuation
|
||||
* deferral already committed this insert: its handler sent the text, so the
|
||||
* flag must stay armed.
|
||||
*/
|
||||
function shouldReleaseKeyDownSeenForLateImeInsert(state: {
|
||||
inputType?: string;
|
||||
data?: string | null;
|
||||
inputComposing?: boolean;
|
||||
keyDownSeen: boolean;
|
||||
lastKeyCode?: number;
|
||||
compositionActive: boolean;
|
||||
compositionSendPending: boolean;
|
||||
textareaCheckPending: boolean;
|
||||
deferralConsumed: boolean;
|
||||
}): boolean {
|
||||
if (state.deferralConsumed) return false;
|
||||
if (!isImeTextInsert(state)) return false;
|
||||
if (state.inputComposing) return false;
|
||||
if (!state.keyDownSeen || state.lastKeyCode !== IME_PROCESS_KEY_CODE) return false;
|
||||
if (state.compositionActive || state.compositionSendPending) return false;
|
||||
if (state.textareaCheckPending) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap xterm's keydown entry so a pure modifier keydown cannot arm the
|
||||
* `insertText` dedupe guard. Installed per-terminal instance; the prototype
|
||||
@@ -141,65 +54,3 @@ export function keepImeCommittedTextThroughModifierKeyDowns(term: XTerm): void {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows Sogou (Chinese mode, no inline composition) commits latin text
|
||||
* with a late `insertText` after keyCode 229. CompositionHelper snapshots
|
||||
* the still-empty textarea and its 0ms check runs before that text exists,
|
||||
* so it sends nothing. xterm has already armed `_keyDownSeen`, and
|
||||
* `_inputEvent` then drops the insert. The string sits in the hidden
|
||||
* textarea until the next Enter wipes it and only `\r` reaches the PTY
|
||||
* (#3185).
|
||||
*
|
||||
* A capture-phase listener registered from `onWillOpen` runs before
|
||||
* xterm binds its own `input` handler. It clears `_keyDownSeen` only for
|
||||
* the late-insert case above, so xterm forwards the text exactly once.
|
||||
* Installed per terminal, before `open()`, next to the modifier guard.
|
||||
*/
|
||||
export function keepLateImeTextInsert(term: XTerm): void {
|
||||
const core = (term as XTerm & { _core?: LateImeInsertCore })._core;
|
||||
if (!core || typeof core._keyDown !== "function" || core.__ncLateImeInsertInstalled) return;
|
||||
core.__ncLateImeInsertInstalled = true;
|
||||
|
||||
let lastKeyCode: number | undefined;
|
||||
const originalKeyDown = core._keyDown.bind(core);
|
||||
core._keyDown = (event: KeyboardEvent) => {
|
||||
lastKeyCode = event.keyCode;
|
||||
return originalKeyDown(event);
|
||||
};
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const input = event as Partial<InputEvent>;
|
||||
const helper = core._compositionHelper;
|
||||
if (!shouldReleaseKeyDownSeenForLateImeInsert({
|
||||
inputType: input.inputType,
|
||||
data: input.data,
|
||||
inputComposing: input.isComposing === true,
|
||||
keyDownSeen: core._keyDownSeen === true,
|
||||
lastKeyCode,
|
||||
compositionActive: helper?._isComposing === true,
|
||||
compositionSendPending: helper?._isSendingComposition === true,
|
||||
// No helper means the 0ms check cannot be observed. Treat that as
|
||||
// still pending so a late insert is not forwarded twice.
|
||||
textareaCheckPending: helper == null || helper._textareaChangeTimer != null,
|
||||
deferralConsumed: isImeDeferralConsumedInput(event),
|
||||
})) return;
|
||||
core._keyDownSeen = false;
|
||||
};
|
||||
|
||||
const attach = (textarea: HTMLTextAreaElement | null | undefined) => {
|
||||
if (!textarea || (textarea as TextareaWithLateImeInsert).__ncLateImeInsert) return;
|
||||
(textarea as TextareaWithLateImeInsert).__ncLateImeInsert = true;
|
||||
textarea.addEventListener("input", onInput, true);
|
||||
};
|
||||
|
||||
// `open()` creates the textarea and fires `onWillOpen` before `_bindKeys`
|
||||
// registers xterm's capture-phase input handler. Attaching here puts our
|
||||
// listener ahead of that handler. A terminal that is already open (tests)
|
||||
// exposes the textarea directly.
|
||||
if (core.textarea) {
|
||||
attach(core.textarea);
|
||||
return;
|
||||
}
|
||||
core.onWillOpen?.(() => attach(core.textarea));
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";
|
||||
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
|
||||
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks.ts";
|
||||
import type { LinkModifier } from "../../../domain/models/terminal.ts";
|
||||
|
||||
type PrintedLine = { text: string; wrapped: boolean };
|
||||
|
||||
function click(partial: Partial<MouseEvent> = {}): MouseEvent {
|
||||
return {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
...partial,
|
||||
} as MouseEvent;
|
||||
}
|
||||
|
||||
function makeBufferLine(line: PrintedLine) {
|
||||
const chars = Array.from(line.text);
|
||||
return {
|
||||
isWrapped: line.wrapped,
|
||||
length: chars.length,
|
||||
translateToString(trim?: boolean) {
|
||||
return trim ? line.text.trimEnd() : line.text;
|
||||
},
|
||||
getCell(index: number) {
|
||||
const ch = chars[index];
|
||||
if (ch === undefined) return undefined;
|
||||
return {
|
||||
getChars: () => ch,
|
||||
getWidth: () => 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
let lines: PrintedLine[] = [{ text: "/tmp/backup.tar.gz", wrapped: false }];
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let baseY = 0;
|
||||
let cols = 80;
|
||||
let cursorLine = 0;
|
||||
let type: "normal" | "alternate" = "normal";
|
||||
let connected = true;
|
||||
let broadcast = false;
|
||||
let eligible = true;
|
||||
let ready = true;
|
||||
let modifier: LinkModifier = "none";
|
||||
const pasted: string[] = [];
|
||||
let provider!: ILinkProvider;
|
||||
|
||||
const term = {
|
||||
get cols() {
|
||||
return cols;
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get type() {
|
||||
return type;
|
||||
},
|
||||
get cursorX() {
|
||||
return cursorX;
|
||||
},
|
||||
get cursorY() {
|
||||
return cursorY;
|
||||
},
|
||||
get baseY() {
|
||||
return baseY;
|
||||
},
|
||||
getLine(index: number) {
|
||||
const line = lines[index];
|
||||
return line ? makeBufferLine(line) : undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
registerMarker(offset: number) {
|
||||
return {
|
||||
line: cursorLine + offset,
|
||||
isDisposed: false,
|
||||
dispose() {
|
||||
this.isDisposed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
paste(value: string) {
|
||||
pasted.push(value);
|
||||
},
|
||||
focus() {},
|
||||
registerLinkProvider(next: ILinkProvider) {
|
||||
provider = next;
|
||||
return { dispose() {} };
|
||||
},
|
||||
};
|
||||
|
||||
const cwdMarkers = createTerminalCwdMarkers(term as never);
|
||||
registerTerminalArchiveLinks({
|
||||
term: term as unknown as Terminal,
|
||||
cwdMarkers,
|
||||
isConnected: () => connected,
|
||||
isBroadcasting: () => broadcast,
|
||||
isHostEligible: () => eligible,
|
||||
isInputReady: () => ready,
|
||||
linkModifier: () => modifier,
|
||||
});
|
||||
|
||||
const linksAt = (y: number) => {
|
||||
let value: ILink[] | undefined;
|
||||
provider.provideLinks(y, (result) => {
|
||||
value = result;
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
pasted,
|
||||
linksAt,
|
||||
noteAt(line: number, report: { cwd: string; host: string }, x = 0) {
|
||||
cursorLine = line;
|
||||
const previousX = cursorX;
|
||||
cursorX = x;
|
||||
cwdMarkers.note(report);
|
||||
cursorX = previousX;
|
||||
},
|
||||
setLines(next: PrintedLine[]) {
|
||||
lines = next;
|
||||
},
|
||||
setLine(index: number, text: string, wrapped = false) {
|
||||
lines[index] = { text, wrapped };
|
||||
},
|
||||
setCursor(line: number, x: number) {
|
||||
cursorY = line;
|
||||
cursorX = x;
|
||||
baseY = 0;
|
||||
},
|
||||
atPrompt() {
|
||||
const prompt = lines.length;
|
||||
lines = [...lines, { text: "", wrapped: false }];
|
||||
cursorY = prompt;
|
||||
cursorX = 0;
|
||||
baseY = 0;
|
||||
},
|
||||
setBroadcast(value: boolean) {
|
||||
broadcast = value;
|
||||
},
|
||||
setReady(value: boolean) {
|
||||
ready = value;
|
||||
},
|
||||
setEligible(value: boolean) {
|
||||
eligible = value;
|
||||
},
|
||||
setConnected(value: boolean) {
|
||||
connected = value;
|
||||
},
|
||||
setModifier(value: LinkModifier) {
|
||||
modifier = value;
|
||||
},
|
||||
setAltBuffer(value: boolean) {
|
||||
type = value ? "alternate" : "normal";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ctrl = click({ ctrlKey: true });
|
||||
|
||||
test("no modifier is a no-op, Ctrl fills one command, and Cmd works on macOS", () => {
|
||||
const linux = fixture();
|
||||
const link = linux.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.deepEqual(link.range, { start: { x: 1, y: 1 }, end: { x: 18, y: 1 } });
|
||||
linux.atPrompt();
|
||||
link.activate(click(), link.text);
|
||||
assert.deepEqual(linux.pasted, []);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(linux.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
assert.equal(linux.pasted[0]?.includes("\r"), false);
|
||||
assert.equal(linux.pasted[0]?.includes("\n"), false);
|
||||
|
||||
const mac = fixture();
|
||||
const macLink = mac.linksAt(1)?.[0];
|
||||
assert.ok(macLink);
|
||||
mac.atPrompt();
|
||||
macLink.activate(click({ metaKey: true }), macLink.text);
|
||||
assert.deepEqual(mac.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
});
|
||||
|
||||
test("a configured alt modifier is required for archive links", () => {
|
||||
const f = fixture();
|
||||
f.setModifier("alt");
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
link.activate(click({ altKey: true }), link.text);
|
||||
assert.equal(f.pasted.length, 1);
|
||||
assert.equal(f.pasted[0]?.includes("\r"), false);
|
||||
});
|
||||
|
||||
test("broadcast, readiness, host eligibility, buffer, and stale text gate provide and activate", () => {
|
||||
const f = fixture();
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
|
||||
f.setBroadcast(true);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setBroadcast(false);
|
||||
|
||||
f.setReady(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setReady(true);
|
||||
|
||||
f.setEligible(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setEligible(true);
|
||||
|
||||
f.setConnected(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setConnected(true);
|
||||
|
||||
f.setAltBuffer(true);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setAltBuffer(false);
|
||||
|
||||
f.setLine(0, "changed.tar.gz");
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
});
|
||||
|
||||
test("a non-space before the cursor is kept and separated with one space", () => {
|
||||
const draft = fixture();
|
||||
draft.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
draft.setCursor(1, 4);
|
||||
const link = draft.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.equal(draft.pasted[0], " tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
|
||||
|
||||
const spaced = fixture();
|
||||
spaced.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "sudo ", wrapped: false },
|
||||
]);
|
||||
spaced.setCursor(1, 5);
|
||||
const spacedLink = spaced.linksAt(1)?.[0];
|
||||
assert.ok(spacedLink);
|
||||
spacedLink.activate(ctrl, spacedLink.text);
|
||||
assert.equal(spaced.pasted[0], "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
|
||||
});
|
||||
|
||||
test("a non-space after the cursor is separated with one space", () => {
|
||||
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
|
||||
const before = fixture();
|
||||
before.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
before.setCursor(1, 0);
|
||||
const beforeLink = before.linksAt(1)?.[0];
|
||||
assert.ok(beforeLink);
|
||||
beforeLink.activate(ctrl, beforeLink.text);
|
||||
assert.equal(before.pasted[0], `${command} `);
|
||||
assert.equal(before.pasted[0]?.includes("\r"), false);
|
||||
|
||||
const middle = fixture();
|
||||
middle.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
middle.setCursor(1, 2);
|
||||
const middleLink = middle.linksAt(1)?.[0];
|
||||
assert.ok(middleLink);
|
||||
middleLink.activate(ctrl, middleLink.text);
|
||||
assert.equal(middle.pasted[0], ` ${command} `);
|
||||
|
||||
const already = fixture();
|
||||
already.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo foo", wrapped: false },
|
||||
]);
|
||||
already.setCursor(1, 4);
|
||||
const alreadyLink = already.linksAt(1)?.[0];
|
||||
assert.ok(alreadyLink);
|
||||
alreadyLink.activate(ctrl, alreadyLink.text);
|
||||
assert.equal(already.pasted[0], ` ${command}`);
|
||||
});
|
||||
|
||||
test("a wrapped draft separates the command from the previous row", () => {
|
||||
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
|
||||
const full = " ".repeat(79);
|
||||
|
||||
const glued = fixture();
|
||||
glued.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full}o`, wrapped: false },
|
||||
{ text: " ", wrapped: true },
|
||||
]);
|
||||
glued.setCursor(2, 0);
|
||||
const gluedLink = glued.linksAt(1)?.[0];
|
||||
assert.ok(gluedLink);
|
||||
gluedLink.activate(ctrl, gluedLink.text);
|
||||
assert.equal(glued.pasted[0], ` ${command}`);
|
||||
|
||||
const spaced = fixture();
|
||||
spaced.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full} `, wrapped: false },
|
||||
{ text: " ", wrapped: true },
|
||||
]);
|
||||
spaced.setCursor(2, 0);
|
||||
const spacedLink = spaced.linksAt(1)?.[0];
|
||||
assert.ok(spacedLink);
|
||||
spacedLink.activate(ctrl, spacedLink.text);
|
||||
assert.equal(spaced.pasted[0], command);
|
||||
|
||||
const separate = fixture();
|
||||
separate.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full}o`, wrapped: false },
|
||||
{ text: " ", wrapped: false },
|
||||
]);
|
||||
separate.setCursor(2, 0);
|
||||
const separateLink = separate.linksAt(1)?.[0];
|
||||
assert.ok(separateLink);
|
||||
separateLink.activate(ctrl, separateLink.text);
|
||||
assert.equal(separate.pasted[0], command);
|
||||
|
||||
const continued = fixture();
|
||||
continued.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "e".repeat(80), wrapped: false },
|
||||
{ text: "x", wrapped: true },
|
||||
]);
|
||||
continued.setCursor(1, 80);
|
||||
const continuedLink = continued.linksAt(1)?.[0];
|
||||
assert.ok(continuedLink);
|
||||
continuedLink.activate(ctrl, continuedLink.text);
|
||||
assert.equal(continued.pasted[0], ` ${command} `);
|
||||
});
|
||||
|
||||
test("a name that starts on the next wrapped row is not anchored on the previous row", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "x ", wrapped: false },
|
||||
{ text: "/tmp/a.tar", wrapped: true },
|
||||
]);
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.equal(link.text, "/tmp/a.tar");
|
||||
assert.deepEqual(link.range, {
|
||||
start: { x: 1, y: 2 },
|
||||
end: { x: 10, y: 2 },
|
||||
});
|
||||
|
||||
const ended = fixture();
|
||||
ended.setLines([
|
||||
{ text: "/a.tar", wrapped: false },
|
||||
{ text: " rest", wrapped: true },
|
||||
]);
|
||||
const endedLink = ended.linksAt(1)?.[0];
|
||||
assert.ok(endedLink);
|
||||
assert.equal(endedLink.text, "/a.tar");
|
||||
assert.deepEqual(endedLink.range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 6, y: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("a wrapped logical line is one multi-row link and fills the full path", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "/tmp/back", wrapped: false },
|
||||
{ text: "up.tar.gz", wrapped: true },
|
||||
]);
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.deepEqual(link.range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 9, y: 2 },
|
||||
});
|
||||
f.atPrompt();
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
});
|
||||
|
||||
test("absolute names link without markers and relative names do not", () => {
|
||||
const relative = fixture();
|
||||
relative.setLine(0, "bundle.tar.gz");
|
||||
assert.equal(relative.linksAt(1), undefined);
|
||||
|
||||
const absolute = fixture();
|
||||
absolute.setLine(0, "/var/pack.zip");
|
||||
const link = absolute.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
absolute.atPrompt();
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(absolute.pasted, ["unzip '/var/pack.zip' -d '/var'"]);
|
||||
|
||||
const open = fixture();
|
||||
open.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/var/pack.zip", wrapped: false },
|
||||
]);
|
||||
open.noteAt(0, { cwd: "/var", host: "box" });
|
||||
const openLink = open.linksAt(2)?.[0];
|
||||
assert.ok(openLink);
|
||||
open.atPrompt();
|
||||
openLink.activate(ctrl, openLink.text);
|
||||
assert.equal(open.pasted[0], "unzip '/var/pack.zip' -d '/var'");
|
||||
open.setLine(1, "pack.zip");
|
||||
assert.equal(open.linksAt(2), undefined);
|
||||
|
||||
const mismatched = fixture();
|
||||
mismatched.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/tmp/a.tar.gz", wrapped: false },
|
||||
{ text: "prompt", wrapped: false },
|
||||
]);
|
||||
mismatched.noteAt(0, { cwd: "/tmp", host: "box" });
|
||||
mismatched.noteAt(2, { cwd: "/other", host: "box" });
|
||||
const mismatchLink = mismatched.linksAt(2)?.[0];
|
||||
assert.ok(mismatchLink);
|
||||
mismatched.atPrompt();
|
||||
mismatchLink.activate(ctrl, mismatchLink.text);
|
||||
assert.equal(mismatched.pasted[0], "tar -xvzf '/tmp/a.tar.gz' -C '/tmp'");
|
||||
mismatched.setLine(1, "a.tar.gz");
|
||||
assert.equal(mismatched.linksAt(2), undefined);
|
||||
});
|
||||
|
||||
test("an absolute path from another host is not a link", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/tmp/a.tar.gz", wrapped: false },
|
||||
{ text: "prompt", wrapped: false },
|
||||
]);
|
||||
f.noteAt(0, { cwd: "/tmp", host: "outer" });
|
||||
f.noteAt(2, { cwd: "/tmp", host: "outer" });
|
||||
f.noteAt(4, { cwd: "/home", host: "inner" });
|
||||
assert.equal(f.linksAt(2), undefined);
|
||||
});
|
||||
|
||||
test("ls -l links only the archive name and a symlink line links both names", () => {
|
||||
const listing = fixture();
|
||||
listing.setLines([
|
||||
{ text: "$ ls -l", wrapped: false },
|
||||
{ text: "-rw-r--r-- 1 me me 10 Jan 1 00:00 bundle.tar.gz", wrapped: false },
|
||||
{ text: "$", wrapped: false },
|
||||
]);
|
||||
listing.noteAt(0, { cwd: "/data", host: "box" });
|
||||
listing.noteAt(2, { cwd: "/data", host: "box" });
|
||||
const links = listing.linksAt(2);
|
||||
assert.deepEqual(links?.map((link) => link.text), ["bundle.tar.gz"]);
|
||||
listing.atPrompt();
|
||||
links?.[0]?.activate(ctrl, links[0].text);
|
||||
assert.deepEqual(listing.pasted, ["tar -xvzf '/data/bundle.tar.gz' -C '/data'"]);
|
||||
|
||||
const symlink = fixture();
|
||||
symlink.setLines([
|
||||
{ text: "$ ls", wrapped: false },
|
||||
{ text: "a.tar.gz -> b.tar.gz", wrapped: false },
|
||||
{ text: "$", wrapped: false },
|
||||
]);
|
||||
symlink.noteAt(0, { cwd: "/data", host: "box" });
|
||||
symlink.noteAt(2, { cwd: "/data", host: "box" });
|
||||
assert.deepEqual(symlink.linksAt(2)?.map((link) => link.text), ["a.tar.gz", "b.tar.gz"]);
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
import type { IDisposable, ILink, ILinkProvider, Terminal } from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
buildArchiveExtractCommand,
|
||||
findTerminalArchiveTokens,
|
||||
resolveArchivePath,
|
||||
} from "../../../domain/terminalArchives";
|
||||
import type { LinkModifier } from "../../../domain/models/terminal";
|
||||
import {
|
||||
readPluginTerminalBufferText,
|
||||
type PluginTerminalBufferText,
|
||||
} from "../pluginTerminalBufferText";
|
||||
import type { TerminalCwdInspection, TerminalCwdMarkers } from "./terminalCwdMarkers";
|
||||
|
||||
const MAX_LOGICAL_LINE_CHARS = 8192;
|
||||
|
||||
export interface TerminalArchiveLinkHost {
|
||||
term: Terminal;
|
||||
cwdMarkers: TerminalCwdMarkers;
|
||||
isConnected(): boolean;
|
||||
isBroadcasting(): boolean;
|
||||
isHostEligible(): boolean;
|
||||
isInputReady(): boolean;
|
||||
linkModifier(): LinkModifier;
|
||||
}
|
||||
|
||||
interface LogicalSegment {
|
||||
lineNo: number;
|
||||
text: PluginTerminalBufferText;
|
||||
stringStart: number;
|
||||
}
|
||||
|
||||
interface LogicalLine {
|
||||
text: string;
|
||||
segments: LogicalSegment[];
|
||||
}
|
||||
|
||||
interface BufferCell {
|
||||
line: number;
|
||||
cell: number;
|
||||
}
|
||||
|
||||
function readLogicalLine(term: Terminal, oneBasedY: number): LogicalLine | null {
|
||||
const buffer = term.buffer.active;
|
||||
const index = oneBasedY - 1;
|
||||
if (!buffer.getLine(index)) return null;
|
||||
let start = index;
|
||||
while (start > 0 && buffer.getLine(start)?.isWrapped) start -= 1;
|
||||
|
||||
const segments: LogicalSegment[] = [];
|
||||
let total = 0;
|
||||
for (let lineNo = start; ; lineNo += 1) {
|
||||
const line = buffer.getLine(lineNo);
|
||||
if (!line) break;
|
||||
if (lineNo > start && !line.isWrapped) break;
|
||||
const next = buffer.getLine(lineNo + 1);
|
||||
const isLast = !next?.isWrapped;
|
||||
const text = readPluginTerminalBufferText(line, isLast);
|
||||
if (total + text.text.length > MAX_LOGICAL_LINE_CHARS) return null;
|
||||
segments.push({ lineNo, text, stringStart: total });
|
||||
total += text.text.length;
|
||||
if (isLast) break;
|
||||
}
|
||||
if (segments.length === 0) return null;
|
||||
return {
|
||||
text: segments.map((segment) => segment.text.text).join(""),
|
||||
segments,
|
||||
};
|
||||
}
|
||||
|
||||
function cellAt(
|
||||
segments: readonly LogicalSegment[],
|
||||
offset: number,
|
||||
bound: "inclusive" | "exclusive",
|
||||
): BufferCell | null {
|
||||
for (const segment of segments) {
|
||||
const local = offset - segment.stringStart;
|
||||
if (local < 0) return null;
|
||||
const length = segment.text.text.length;
|
||||
// A boundary offset is the next row's first cell for a token start, and the
|
||||
// previous row's exclusive end for a match that stops on that row.
|
||||
if (bound === "inclusive" ? local >= length : local > length) continue;
|
||||
const cell = segment.text.cellAtStringOffset[local];
|
||||
if (!Number.isInteger(cell)) return null;
|
||||
return { line: segment.lineNo, cell };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameSpan(left: TerminalCwdInspection, right: TerminalCwdInspection): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === "ambiguous") return false;
|
||||
if (left.kind === "outside") return true;
|
||||
if (left.kind === "open") return right.kind === "open" && left.host === right.host;
|
||||
return right.kind === "closed" && left.key === right.key;
|
||||
}
|
||||
|
||||
function modifierMatches(event: MouseEvent, modifier: LinkModifier): boolean {
|
||||
if (event.button !== 0) return false;
|
||||
if (modifier === "ctrl") return event.ctrlKey;
|
||||
if (modifier === "alt") return event.altKey;
|
||||
if (modifier === "meta") return event.metaKey;
|
||||
// "none" still requires Ctrl or Cmd. A configured modifier replaces that default.
|
||||
return event.ctrlKey || event.metaKey;
|
||||
}
|
||||
|
||||
function neighborNeedsSpace(term: Terminal, direction: -1 | 1): boolean {
|
||||
const buffer = term.buffer.active;
|
||||
let lineIndex = buffer.baseY + buffer.cursorY;
|
||||
let line = buffer.getLine(lineIndex);
|
||||
if (!line || typeof line.getCell !== "function") return false;
|
||||
let index = buffer.cursorX + (direction < 0 ? -1 : 0);
|
||||
for (let steps = 0; steps < term.cols + 1; steps += 1) {
|
||||
if (index < 0 || index >= term.cols) {
|
||||
if (direction < 0) {
|
||||
if (!line.isWrapped) return false;
|
||||
lineIndex -= 1;
|
||||
} else {
|
||||
lineIndex += 1;
|
||||
const next = buffer.getLine(lineIndex);
|
||||
if (!next?.isWrapped) return false;
|
||||
}
|
||||
line = buffer.getLine(lineIndex);
|
||||
if (!line || typeof line.getCell !== "function") return false;
|
||||
index = direction < 0 ? term.cols - 1 : 0;
|
||||
continue;
|
||||
}
|
||||
const cell = line.getCell(index);
|
||||
if (!cell) return false;
|
||||
if (cell.getWidth() === 0) {
|
||||
index += direction;
|
||||
continue;
|
||||
}
|
||||
return (cell.getChars() || " ") !== " ";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd-click archive names to paste an absolute extract command.
|
||||
* Directory labels come only from OSC 7 markers; input is never observed.
|
||||
*/
|
||||
export function registerTerminalArchiveLinks(options: TerminalArchiveLinkHost): IDisposable {
|
||||
let disposed = false;
|
||||
const { term, cwdMarkers } = options;
|
||||
|
||||
const sessionAllowsLinks = (): boolean => options.isConnected()
|
||||
&& !options.isBroadcasting()
|
||||
&& options.isHostEligible()
|
||||
&& options.isInputReady()
|
||||
&& term.buffer.active.type === "normal";
|
||||
|
||||
const cwdFor = (path: string, start: BufferCell, end: BufferCell): string | null | undefined => {
|
||||
const left = cwdMarkers.inspectCell(start.line, start.cell);
|
||||
const right = cwdMarkers.inspectCell(end.line, end.cell);
|
||||
if (!sameSpan(left, right) || left.kind === "ambiguous") return undefined;
|
||||
const absolute = path.startsWith("/");
|
||||
if (absolute) {
|
||||
if (left.kind === "outside") return null;
|
||||
const latest = cwdMarkers.latestHost();
|
||||
if (latest != null && left.host !== latest) return undefined;
|
||||
return null;
|
||||
}
|
||||
if (left.kind !== "closed" || !left.trusted) return undefined;
|
||||
const latest = cwdMarkers.latestHost();
|
||||
if (latest != null && left.host !== latest) return undefined;
|
||||
return left.cwd;
|
||||
};
|
||||
|
||||
const provider: ILinkProvider = {
|
||||
provideLinks(bufferLineNumber, callback) {
|
||||
if (disposed || !sessionAllowsLinks()) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const logical = readLogicalLine(term, bufferLineNumber);
|
||||
if (!logical || logical.text.length === 0) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const links: ILink[] = [];
|
||||
for (const token of findTerminalArchiveTokens(logical.text)) {
|
||||
const start = cellAt(logical.segments, token.start, "inclusive");
|
||||
const endExclusive = cellAt(logical.segments, token.start + token.length, "exclusive");
|
||||
const last = cellAt(logical.segments, token.start + token.length - 1, "inclusive");
|
||||
if (!start || !endExclusive || !last) continue;
|
||||
const cwd = cwdFor(token.path, start, last);
|
||||
if (cwd === undefined) continue;
|
||||
const abs = resolveArchivePath(token.path, cwd);
|
||||
const command = abs ? buildArchiveExtractCommand(abs) : null;
|
||||
if (!command) continue;
|
||||
const capturedText = logical.text;
|
||||
links.push({
|
||||
range: {
|
||||
start: { x: start.cell + 1, y: start.line + 1 },
|
||||
end: { x: endExclusive.cell, y: endExclusive.line + 1 },
|
||||
},
|
||||
text: token.text,
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(event) {
|
||||
if (disposed || !modifierMatches(event, options.linkModifier())) return;
|
||||
if (!sessionAllowsLinks()) return;
|
||||
const fresh = readLogicalLine(term, start.line + 1);
|
||||
if (!fresh || fresh.text !== capturedText) return;
|
||||
const freshStart = cellAt(fresh.segments, token.start, "inclusive");
|
||||
const freshLast = cellAt(fresh.segments, token.start + token.length - 1, "inclusive");
|
||||
if (!freshStart || !freshLast) return;
|
||||
const freshCwd = cwdFor(token.path, freshStart, freshLast);
|
||||
if (freshCwd === undefined) return;
|
||||
const freshAbs = resolveArchivePath(token.path, freshCwd);
|
||||
const freshCommand = freshAbs ? buildArchiveExtractCommand(freshAbs) : null;
|
||||
if (!freshCommand || /[\r\n]/u.test(freshCommand)) return;
|
||||
const lead = neighborNeedsSpace(term, -1) ? " " : "";
|
||||
const trail = neighborNeedsSpace(term, 1) ? " " : "";
|
||||
term.paste(`${lead}${freshCommand}${trail}`);
|
||||
term.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
callback(links.length > 0 ? links : undefined);
|
||||
},
|
||||
};
|
||||
|
||||
const registration = term.registerLinkProvider(provider);
|
||||
return {
|
||||
dispose() {
|
||||
disposed = true;
|
||||
registration.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -113,32 +113,6 @@ test('acknowledged pasted commands enter history over output without becoming tr
|
||||
}
|
||||
});
|
||||
|
||||
test('colon shell prompts still record the submitted command (#3622)', () => {
|
||||
const executed: string[] = [];
|
||||
const result = recordTerminalCommandExecution('ls', {
|
||||
host: { id: 'host-1', label: 'Host' },
|
||||
sessionId: 'session-1',
|
||||
onCommandExecuted: (command) => executed.push(command),
|
||||
commandBufferRef: { current: 'ls' },
|
||||
}, createFakeTerm('dev: ls') as never);
|
||||
assert.equal(result, 'ls');
|
||||
assert.deepEqual(executed, ['ls']);
|
||||
});
|
||||
|
||||
test('program input replies are not stored as shell commands (#3622)', () => {
|
||||
for (const line of ['Email: use', 'Password: use', 'Secret: use']) {
|
||||
const executed: string[] = [];
|
||||
const result = recordTerminalCommandExecution('use', {
|
||||
host: { id: 'host-1', label: 'Host' },
|
||||
sessionId: 'session-1',
|
||||
onCommandExecuted: (command) => executed.push(command),
|
||||
commandBufferRef: { current: 'use' },
|
||||
}, createFakeTerm(line) as never);
|
||||
assert.equal(result, null, line);
|
||||
assert.deepEqual(executed, [], line);
|
||||
}
|
||||
});
|
||||
|
||||
test('acknowledged writes retain both live challenge and explicit sensitive checks', () => {
|
||||
for (const [line, sensitive] of [['Password: ', false], ['OTP> ', false], ['output', true]] as const) {
|
||||
const history: string[] = [];
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
isProgramInputPromptText,
|
||||
reconcilePromptWithExternalCommand,
|
||||
reconcilePromptWithTypedInput,
|
||||
type PromptDetectionResult,
|
||||
@@ -385,7 +384,6 @@ export const shouldRecordShellHistory = (
|
||||
|
||||
const trimmed = command.trim();
|
||||
const alignedResult = getAlignedPrompt(term, command, true);
|
||||
if (isProgramInputPromptText(alignedResult.prompt.promptText)) return false;
|
||||
const prompt = expandPromptUserInputToFullLine(term, alignedResult.prompt, command);
|
||||
if (!prompt.isAtPrompt) return false;
|
||||
if (alignedResult.alignedTyped?.trim() === trimmed) return true;
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
export interface TerminalCommandInjectionReadyOptions {
|
||||
/** Keep a typed draft. Sensitive prompts, alt-screen, and non-prompts still block. */
|
||||
allowDraft?: boolean;
|
||||
}
|
||||
|
||||
const commandInjectionReadyReaders = new Map<
|
||||
string,
|
||||
(options?: TerminalCommandInjectionReadyOptions) => boolean
|
||||
>();
|
||||
const commandInjectionReadyReaders = new Map<string, () => boolean>();
|
||||
|
||||
export function registerTerminalCommandInjectionReadyReader(
|
||||
sessionId: string,
|
||||
reader: (options?: TerminalCommandInjectionReadyOptions) => boolean,
|
||||
reader: () => boolean,
|
||||
): () => void {
|
||||
commandInjectionReadyReaders.set(sessionId, reader);
|
||||
return () => {
|
||||
@@ -20,10 +12,7 @@ export function registerTerminalCommandInjectionReadyReader(
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the live terminal reports a shell prompt ready for injection. */
|
||||
export function isTerminalReadyForCommandInjection(
|
||||
sessionId: string,
|
||||
options?: TerminalCommandInjectionReadyOptions,
|
||||
): boolean {
|
||||
return commandInjectionReadyReaders.get(sessionId)?.(options) === true;
|
||||
/** True when the live terminal reports an idle shell prompt ready for injection. */
|
||||
export function isTerminalReadyForCommandInjection(sessionId: string): boolean {
|
||||
return commandInjectionReadyReaders.get(sessionId)?.() === true;
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
|
||||
|
||||
type FakeMarker = {
|
||||
line: number;
|
||||
isDisposed: boolean;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
function harness() {
|
||||
let cursorLine = 0;
|
||||
let cursorX = 0;
|
||||
let cols = 80;
|
||||
let type: "normal" | "alternate" = "normal";
|
||||
const created: FakeMarker[] = [];
|
||||
const term = {
|
||||
get cols() {
|
||||
return cols;
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get type() {
|
||||
return type;
|
||||
},
|
||||
get cursorX() {
|
||||
return cursorX;
|
||||
},
|
||||
},
|
||||
},
|
||||
registerMarker(offset: number) {
|
||||
const marker: FakeMarker = {
|
||||
line: cursorLine + offset,
|
||||
isDisposed: false,
|
||||
dispose() {
|
||||
this.isDisposed = true;
|
||||
},
|
||||
};
|
||||
created.push(marker);
|
||||
return marker;
|
||||
},
|
||||
};
|
||||
const markers = createTerminalCwdMarkers(term as never);
|
||||
return {
|
||||
markers,
|
||||
created,
|
||||
setCursor(line: number, x = 0) {
|
||||
cursorLine = line;
|
||||
cursorX = x;
|
||||
},
|
||||
setCols(next: number) {
|
||||
cols = next;
|
||||
},
|
||||
setType(next: "normal" | "alternate") {
|
||||
type = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("prompt-only intervals are trusted only when both ends report the same cwd", () => {
|
||||
const same = harness();
|
||||
same.setCursor(0);
|
||||
same.markers.note({ cwd: "/a", host: "box" });
|
||||
same.setCursor(3);
|
||||
same.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.deepEqual(same.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
|
||||
const changed = harness();
|
||||
changed.setCursor(0);
|
||||
changed.markers.note({ cwd: "/a", host: "box" });
|
||||
changed.setCursor(3);
|
||||
changed.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(changed.markers.resolveCell(1, 0), null);
|
||||
|
||||
const thenCd = harness();
|
||||
thenCd.setCursor(0);
|
||||
thenCd.markers.note({ cwd: "/a", host: "box" });
|
||||
thenCd.setCursor(4);
|
||||
thenCd.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(thenCd.markers.resolveCell(2, 0), null);
|
||||
});
|
||||
|
||||
test("a chpwd emitter trusts the interval after the directory change", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a/x", host: "box" });
|
||||
h.setCursor(5);
|
||||
h.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(1, 0), null);
|
||||
assert.deepEqual(h.markers.resolveCell(3, 0), { cwd: "/a/x", host: "box" });
|
||||
});
|
||||
|
||||
test("the open interval after the latest marker is not trusted", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(4, 0), null);
|
||||
});
|
||||
|
||||
test("a host mismatch between interval ends is not trusted and latestHost follows the last marker", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "outer" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a", host: "inner" });
|
||||
assert.equal(h.markers.resolveCell(1, 0), null);
|
||||
assert.equal(h.markers.latestHost(), "inner");
|
||||
});
|
||||
|
||||
test("a disposed start or end marker drops the interval", () => {
|
||||
const start = harness();
|
||||
start.setCursor(0);
|
||||
start.markers.note({ cwd: "/a", host: "box" });
|
||||
start.setCursor(2);
|
||||
start.markers.note({ cwd: "/a", host: "box" });
|
||||
start.created[0]?.dispose();
|
||||
assert.equal(start.markers.resolveCell(1, 0), null);
|
||||
|
||||
const end = harness();
|
||||
end.setCursor(0);
|
||||
end.markers.note({ cwd: "/a", host: "box" });
|
||||
end.setCursor(2);
|
||||
end.markers.note({ cwd: "/a", host: "box" });
|
||||
end.created[1]?.dispose();
|
||||
assert.equal(end.markers.resolveCell(1, 0), null);
|
||||
});
|
||||
|
||||
test("a new marker above existing ones drops the later markers", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(5);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(10);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(3);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.equal(h.created[1]?.isDisposed, true);
|
||||
assert.equal(h.created[2]?.isDisposed, true);
|
||||
assert.equal(h.created[0]?.isDisposed, false);
|
||||
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(6, 0), null);
|
||||
});
|
||||
|
||||
test("cells left of a marker belong to the previous interval and a cols change makes that row ambiguous", () => {
|
||||
const split = harness();
|
||||
split.setCursor(0, 0);
|
||||
split.markers.note({ cwd: "/old", host: "box" });
|
||||
split.setCursor(5, 10);
|
||||
split.markers.note({ cwd: "/new", host: "box" });
|
||||
split.setCursor(9, 0);
|
||||
split.markers.note({ cwd: "/new", host: "box" });
|
||||
assert.equal(split.markers.resolveCell(5, 9), null);
|
||||
assert.deepEqual(split.markers.resolveCell(5, 10), { cwd: "/new", host: "box" });
|
||||
assert.deepEqual(split.markers.resolveCell(7, 0), { cwd: "/new", host: "box" });
|
||||
|
||||
const resized = harness();
|
||||
resized.setCursor(0, 0);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCursor(5, 4);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCursor(9, 0);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCols(100);
|
||||
assert.equal(resized.markers.resolveCell(5, 0), null);
|
||||
assert.equal(resized.markers.resolveCell(5, 4), null);
|
||||
assert.equal(resized.markers.resolveCell(0, 1), null);
|
||||
assert.deepEqual(resized.markers.resolveCell(3, 0), { cwd: "/a", host: "box" });
|
||||
});
|
||||
|
||||
test("the alternate buffer does not record cwd markers", () => {
|
||||
const h = harness();
|
||||
h.setType("alternate");
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.equal(h.created.length, 0);
|
||||
assert.equal(h.markers.resolveCell(0, 0), null);
|
||||
assert.equal(h.markers.latestHost(), null);
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
const MAX_CWD_MARKERS = 5000;
|
||||
|
||||
interface CwdMarker {
|
||||
readonly line: number;
|
||||
readonly isDisposed: boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface TerminalCwdMarkerTerminal {
|
||||
readonly cols: number;
|
||||
readonly buffer: {
|
||||
readonly active: {
|
||||
readonly type: "normal" | "alternate";
|
||||
readonly cursorX: number;
|
||||
};
|
||||
};
|
||||
registerMarker(cursorYOffset: number): CwdMarker | undefined;
|
||||
}
|
||||
|
||||
interface CwdMarkerEntry {
|
||||
id: number;
|
||||
marker: CwdMarker;
|
||||
x: number;
|
||||
cols: number;
|
||||
cwd: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface TerminalCwdCell {
|
||||
cwd: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export type TerminalCwdInspection =
|
||||
| { kind: "outside" }
|
||||
| { kind: "ambiguous" }
|
||||
| { kind: "open"; host: string; cwd: string }
|
||||
| { kind: "closed"; host: string; cwd: string; trusted: boolean; key: string };
|
||||
|
||||
export interface TerminalCwdMarkers {
|
||||
note(report: { cwd: string; host: string }): void;
|
||||
resolveCell(row: number, col: number): TerminalCwdCell | null;
|
||||
inspectCell(row: number, col: number): TerminalCwdInspection;
|
||||
latestHost(): string | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* OSC 7 cwd labels. Output is trusted only between two markers that agree.
|
||||
* Nothing here watches keyboard or paste input.
|
||||
*/
|
||||
export function createTerminalCwdMarkers(term: TerminalCwdMarkerTerminal): TerminalCwdMarkers {
|
||||
let entries: CwdMarkerEntry[] = [];
|
||||
let nextId = 1;
|
||||
let disposed = false;
|
||||
|
||||
const liveEntries = (): CwdMarkerEntry[] => {
|
||||
if (entries.some((entry) => entry.marker.isDisposed)) {
|
||||
entries = entries.filter((entry) => !entry.marker.isDisposed);
|
||||
}
|
||||
for (let index = 1; index < entries.length; index += 1) {
|
||||
if (entries[index - 1].marker.line > entries[index].marker.line) {
|
||||
entries.sort((left, right) => left.marker.line - right.marker.line);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const relation = (
|
||||
row: number,
|
||||
col: number,
|
||||
entry: CwdMarkerEntry,
|
||||
): "before" | "atOrAfter" | "ambiguous" => {
|
||||
const line = entry.marker.line;
|
||||
if (row < line) return "before";
|
||||
if (row > line) return "atOrAfter";
|
||||
if (term.cols !== entry.cols) return "ambiguous";
|
||||
return col < entry.x ? "before" : "atOrAfter";
|
||||
};
|
||||
|
||||
const inspectCell = (row: number, col: number): TerminalCwdInspection => {
|
||||
const list = liveEntries();
|
||||
if (list.length === 0) return { kind: "outside" };
|
||||
let start: CwdMarkerEntry | null = null;
|
||||
for (const entry of list) {
|
||||
const where = relation(row, col, entry);
|
||||
if (where === "ambiguous") return { kind: "ambiguous" };
|
||||
if (where === "before") {
|
||||
if (!start) return { kind: "outside" };
|
||||
if (start.host !== entry.host) return { kind: "ambiguous" };
|
||||
return {
|
||||
kind: "closed",
|
||||
host: start.host,
|
||||
cwd: start.cwd,
|
||||
trusted: start.cwd === entry.cwd,
|
||||
key: `${start.id}:${entry.id}`,
|
||||
};
|
||||
}
|
||||
start = entry;
|
||||
}
|
||||
if (!start) return { kind: "outside" };
|
||||
return { kind: "open", host: start.host, cwd: start.cwd };
|
||||
};
|
||||
|
||||
return {
|
||||
note(report) {
|
||||
if (disposed || term.buffer.active.type !== "normal") return;
|
||||
const marker = term.registerMarker(0);
|
||||
if (!marker) return;
|
||||
const fresh: CwdMarkerEntry = {
|
||||
id: nextId,
|
||||
marker,
|
||||
x: term.buffer.active.cursorX,
|
||||
cols: term.cols,
|
||||
cwd: report.cwd,
|
||||
host: report.host,
|
||||
};
|
||||
nextId += 1;
|
||||
const kept: CwdMarkerEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.marker.isDisposed || entry.marker.line >= marker.line) {
|
||||
if (!entry.marker.isDisposed) entry.marker.dispose();
|
||||
continue;
|
||||
}
|
||||
kept.push(entry);
|
||||
}
|
||||
kept.push(fresh);
|
||||
while (kept.length > MAX_CWD_MARKERS) {
|
||||
const removed = kept.shift();
|
||||
if (removed && !removed.marker.isDisposed) removed.marker.dispose();
|
||||
}
|
||||
entries = kept;
|
||||
},
|
||||
inspectCell,
|
||||
resolveCell(row, col) {
|
||||
const inspection = inspectCell(row, col);
|
||||
if (inspection.kind !== "closed" || !inspection.trusted) return null;
|
||||
return { cwd: inspection.cwd, host: inspection.host };
|
||||
},
|
||||
latestHost() {
|
||||
const list = liveEntries();
|
||||
return list.length > 0 ? list[list.length - 1].host : null;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
for (const entry of entries) {
|
||||
if (!entry.marker.isDisposed) entry.marker.dispose();
|
||||
}
|
||||
entries = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,10 +3,6 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const runtimeSource = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const autocompleteHookSource = readFileSync(
|
||||
new URL("../autocomplete/useTerminalAutocomplete.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const attachmentSource = readFileSync(new URL("./terminalSessionAttachment.ts", import.meta.url), "utf8");
|
||||
const terminalSource = readFileSync(new URL("../../Terminal.tsx", import.meta.url), "utf8");
|
||||
const terminalLayerSource = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
@@ -114,30 +110,6 @@ test("renderer flow control acknowledges host ingress rather than transformed di
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit plugin sensitivity is not treated as the heuristic prompt latch", () => {
|
||||
const effectsSource = readFileSync(new URL("../useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput;[\s\S]*?passwordPromptActiveRef\.source = meta\.pluginPipelineSensitiveInput \? "explicit" : "heuristic";/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/passwordPromptActiveRef\.current = payload\.passwordPromptActive;[\s\S]*?if \(payload\.passwordPromptActive\) passwordPromptActiveRef\.source = "explicit";/,
|
||||
);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/passwordPromptActiveRef\.current = snap\.passwordPromptActive;[\s\S]*?if \(snap\.passwordPromptActive\) passwordPromptActiveRef\.source = "explicit";/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/passwordPromptActiveRef\.current = true;[\s\S]*?passwordPromptActiveRef\.source = "heuristic";/,
|
||||
);
|
||||
assert.match(
|
||||
autocompleteHookSource,
|
||||
/sensitiveSource: sensitiveInputActiveRef\?\.source,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("active and hibernated output share host-owned sensitive prompt classification", () => {
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
@@ -448,68 +448,6 @@ test("long multi-line paste clears cursor-right residue after terminal echo", ()
|
||||
assert.deepEqual(writes, ["\x1b[K"]);
|
||||
});
|
||||
|
||||
test("long multi-line paste does not erase to end of line on the alternate screen", () => {
|
||||
const writes: string[] = [];
|
||||
let bufferType: "alternate" | "normal" = "alternate";
|
||||
const term = {
|
||||
cols: 20,
|
||||
rows: 4,
|
||||
buffer: {
|
||||
active: {
|
||||
get type() {
|
||||
return bufferType;
|
||||
},
|
||||
},
|
||||
},
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
write: (data: string) => writes.push(data),
|
||||
};
|
||||
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
||||
pasteTextIntoTerminal(term, longPaste, {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough content\x1b[27m");
|
||||
|
||||
assert.equal(clearPasteResidualAfterTerminalWrite(term), null);
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
bufferType = "normal";
|
||||
assert.equal(clearPasteResidualAfterTerminalWrite(term), null);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("long multi-line paste does not erase the restored normal buffer when the same batch leaves the alternate screen", () => {
|
||||
const writes: string[] = [];
|
||||
let bufferType: "alternate" | "normal" = "alternate";
|
||||
const term = {
|
||||
cols: 20,
|
||||
rows: 4,
|
||||
buffer: {
|
||||
active: {
|
||||
get type() {
|
||||
return bufferType;
|
||||
},
|
||||
},
|
||||
},
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
write: (data: string) => writes.push(data),
|
||||
};
|
||||
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
||||
pasteTextIntoTerminal(term, longPaste, {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough content\x1b[27m\x1b[?1049l");
|
||||
// xterm applies ESC[?1049l before the write callback observes buffer.active.
|
||||
bufferType = "normal";
|
||||
|
||||
assert.equal(clearPasteResidualAfterTerminalWrite(term), null);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sensitive paste override matches sanitized data containing zero-width characters", () => {
|
||||
const term = {
|
||||
paste: () => {},
|
||||
|
||||
@@ -32,8 +32,6 @@ type PasteDisplayState = {
|
||||
clearPending: number;
|
||||
pasteEchoFragments: string[];
|
||||
inPasteEchoActiveRegion: boolean;
|
||||
// Buffer type observed while preparing this chunk, before xterm applies it.
|
||||
preparedOnAlternateScreen: boolean;
|
||||
};
|
||||
|
||||
type PasteInputScrollState = {
|
||||
@@ -330,7 +328,6 @@ export function pasteTextIntoTerminal(
|
||||
clearPending: 0,
|
||||
pasteEchoFragments: getPasteEchoFragments(text),
|
||||
inPasteEchoActiveRegion: false,
|
||||
preparedOnAlternateScreen: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -524,17 +521,10 @@ function consumePasteInputState(
|
||||
return true;
|
||||
}
|
||||
|
||||
const isAlternateScreenBuffer = (term: object): boolean =>
|
||||
(term as { buffer?: { active?: { type?: string } } }).buffer?.active?.type === "alternate";
|
||||
|
||||
export function prepareTerminalDataForUserPasteDisplay(term: object, data: string): string {
|
||||
const state = pasteDisplayStates.get(term);
|
||||
if (!isStateActive(state)) return data;
|
||||
|
||||
// Captured before term.write. The residual check runs in the write callback,
|
||||
// after a same-batch ESC[?1049l has already restored the normal buffer.
|
||||
state.preparedOnAlternateScreen = isAlternateScreenBuffer(term);
|
||||
|
||||
const strippedActiveRegion = stripMatchedReadlineActiveRegion(data, state);
|
||||
if (strippedActiveRegion.matched) {
|
||||
state.clearPending = Math.max(state.clearPending, 3);
|
||||
@@ -551,16 +541,6 @@ export function prepareTerminalDataForUserPasteDisplay(term: object, data: strin
|
||||
export function clearPasteResidualAfterTerminalWrite(term: object): string | null {
|
||||
const state = pasteDisplayStates.get(term);
|
||||
if (!isStateActive(state)) return null;
|
||||
// Vim and other alternate-screen apps often end a paste chunk with the cursor
|
||||
// mid-line. EL would erase the rest of that row, and the app may not redraw it (#3166).
|
||||
// Drop the pending clear so it cannot fire after returning to the normal screen.
|
||||
// preparedOnAlternateScreen covers a same-batch exit: buffer.active is already
|
||||
// normal when this write callback runs.
|
||||
if (state.preparedOnAlternateScreen || isAlternateScreenBuffer(term)) {
|
||||
state.preparedOnAlternateScreen = false;
|
||||
state.clearPending = 0;
|
||||
return null;
|
||||
}
|
||||
if (state.clearPending <= 0) return null;
|
||||
if (typeof (term as Partial<Pick<XTerm, "write">>).write !== "function") return null;
|
||||
|
||||
|
||||
@@ -3,13 +3,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
computeAutocompleteAcceptWrite,
|
||||
computeSnippetAcceptClearSequence,
|
||||
isSameAutocompleteQuery,
|
||||
limitSuggestionsForPrompt,
|
||||
resolveAutocompleteQueryInput,
|
||||
shouldBlockAutocompleteForSensitivePrompt,
|
||||
} from "./autocomplete/terminalAutocompletePrompt.ts";
|
||||
import type { CompletionSuggestion } from "./autocomplete/completionEngine.ts";
|
||||
import type { PromptDetectionResult } from "./autocomplete/promptDetector.ts";
|
||||
|
||||
function atPrompt(userInput: string, promptText = "$ "): PromptDetectionResult {
|
||||
@@ -60,97 +57,6 @@ test("shouldBlockAutocompleteForSensitivePrompt blocks auth-challenge prompts",
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldBlockAutocompleteForSensitivePrompt lets safe program prompts past the untrusted latch", () => {
|
||||
// observeTerminalInputPrompt latches every untrusted colon prompt, including
|
||||
// `Email:`, before limitSuggestionsForPrompt can offer snippets.
|
||||
for (const promptText of [
|
||||
"Email: ",
|
||||
"email:",
|
||||
"E-mail: ",
|
||||
"EMAIL ADDRESS: ",
|
||||
"Enter your username: ",
|
||||
"user: ",
|
||||
"User Name: ",
|
||||
"Login: ",
|
||||
"Name: ",
|
||||
"\u90ae\u7bb1: ",
|
||||
"\u90ae\u7bb1\uFF1A",
|
||||
"\u7528\u6237\u540d: ",
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
promptText,
|
||||
}),
|
||||
false,
|
||||
promptText,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
promptText: "Password: ",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldBlockAutocompleteForSensitivePrompt keeps explicit plugin sensitivity closed for trusted labels", () => {
|
||||
// pluginPipelineSensitiveInput (and any other authoritative latch) must not
|
||||
// use the Email:/Username: bypass. A heuristic-only latch still may.
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
sensitiveSource: "explicit",
|
||||
promptText: "Email:",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
sensitiveSource: "explicit",
|
||||
promptText: "Username:",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
sensitiveSource: "heuristic",
|
||||
promptText: "Email:",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldBlockAutocompleteForSensitivePrompt keeps the latch for unknown and secret labels", () => {
|
||||
for (const promptText of [
|
||||
"API key:",
|
||||
"API key: ",
|
||||
"Recovery key: ",
|
||||
"Token: ",
|
||||
"TOKEN:",
|
||||
"Password: ",
|
||||
"Passphrase: ",
|
||||
"Secret: ",
|
||||
"PIN: ",
|
||||
"OTP: ",
|
||||
"One-time password: ",
|
||||
"Favorite color: ",
|
||||
"SSH key: ",
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldBlockAutocompleteForSensitivePrompt({
|
||||
sensitiveInputActive: true,
|
||||
promptText,
|
||||
}),
|
||||
true,
|
||||
promptText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("isSameAutocompleteQuery keeps late paths alive during live preview", () => {
|
||||
// Highlighting a candidate rewrites typedInputBuffer to the preview text.
|
||||
// Late path listings for the original query must still apply.
|
||||
@@ -320,126 +226,6 @@ test("computeAutocompleteAcceptWrite refuses line replacement when disabled", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("snippet accept deletes only the mid-line word (#3622)", () => {
|
||||
// POSIX erase is DEL (0x7f). Ctrl-H is literal under canonical `read`.
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "git pull use",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
}),
|
||||
"\x7f\x7f\x7f",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "git pull use",
|
||||
os: "windows",
|
||||
promptText: "C:\\Users\\me>",
|
||||
}),
|
||||
"\b\b\b",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "use",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
}),
|
||||
"\x15",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "deploy",
|
||||
os: "windows",
|
||||
promptText: "C:\\Users\\me>",
|
||||
}),
|
||||
"\b".repeat("deploy".length),
|
||||
);
|
||||
const emailClear = computeSnippetAcceptClearSequence({
|
||||
line: "use",
|
||||
os: "linux",
|
||||
promptText: "Email: ",
|
||||
});
|
||||
assert.equal(emailClear, "\x7f".repeat(3));
|
||||
assert.deepEqual([...emailClear].map((ch) => ch.charCodeAt(0)), [0x7f, 0x7f, 0x7f]);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "foo use",
|
||||
os: "linux",
|
||||
promptText: "Email: ",
|
||||
}),
|
||||
"\x7f\x7f\x7f",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "use",
|
||||
os: "windows",
|
||||
promptText: "Email: ",
|
||||
}),
|
||||
"\b\b\b",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "git pull \u{1F680}",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
}),
|
||||
"\x7f",
|
||||
);
|
||||
});
|
||||
|
||||
test("script snippet accept still clears the whole line (#3622)", () => {
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "echo sm",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
snippetKind: "script",
|
||||
}),
|
||||
"\x15",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "echo sm",
|
||||
os: "windows",
|
||||
promptText: "C:\\Users\\me>",
|
||||
snippetKind: "script",
|
||||
}),
|
||||
"\b".repeat("echo sm".length),
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "hello use",
|
||||
os: "linux",
|
||||
promptText: "Email: ",
|
||||
snippetKind: "script",
|
||||
}),
|
||||
"\x15",
|
||||
);
|
||||
assert.equal(
|
||||
computeSnippetAcceptClearSequence({
|
||||
line: "echo sm",
|
||||
os: "linux",
|
||||
promptText: "user@host:~$ ",
|
||||
snippetKind: "snippet",
|
||||
}),
|
||||
"\x7f\x7f",
|
||||
);
|
||||
});
|
||||
|
||||
test("program input prompts keep snippet suggestions only (#3622)", () => {
|
||||
const suggestions = [
|
||||
{ text: "useradd", displayText: "useradd", source: "history", score: 10 },
|
||||
{ text: "邮箱", displayText: "邮箱", source: "snippet", score: 20 },
|
||||
] as CompletionSuggestion[];
|
||||
|
||||
assert.deepEqual(
|
||||
limitSuggestionsForPrompt("Email: ", suggestions).map((suggestion) => suggestion.source),
|
||||
["snippet"],
|
||||
);
|
||||
assert.equal(limitSuggestionsForPrompt("user@host:~$ ", suggestions).length, 2);
|
||||
assert.equal(limitSuggestionsForPrompt("Password: ", suggestions).length, 2);
|
||||
});
|
||||
|
||||
test("computeAutocompleteAcceptWrite clears with backspaces on a mislabeled Windows shell (#3184)", () => {
|
||||
// Windows PowerShell host saved with the os:"linux" default: the detected
|
||||
// drive-letter prompt must route the clear to backspaces, not Ctrl-U.
|
||||
|
||||
@@ -214,8 +214,6 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
||||
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
|
||||
terminalSettings,
|
||||
@@ -231,21 +229,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandSubmitted');
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
|
||||
};
|
||||
// The xterm runtime captures this callback at boot. Read the latest layer
|
||||
// handler through a ref so a prompt-return refresh sees the current
|
||||
// restore-cwd setting (#1952).
|
||||
const onCommandSettledRef = useRef(ctx.onCommandSettled);
|
||||
onCommandSettledRef.current = ctx.onCommandSettled;
|
||||
const pluginAwareOnCommandCompleted = () => {
|
||||
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
// Stock local shells update cwd when the command finishes, which can be
|
||||
// long after the 150ms post-submit probe. Prompt return (and OSC 133)
|
||||
// already call this hook; re-run the probe then. Empty command text keeps
|
||||
// coding-CLI detection from treating the refresh as a new launch.
|
||||
if (isLocalConnection) {
|
||||
onCommandSettledRef.current?.("", host.id, host.label, sessionId);
|
||||
}
|
||||
};
|
||||
const isRendererActive = isVisible || !hibernateHiddenTabs;
|
||||
const isRendererActiveRef = useRef(isRendererActive);
|
||||
@@ -531,7 +517,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const runtime = createXTermRuntime({
|
||||
container: containerRef.current,
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize,
|
||||
@@ -720,8 +705,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}
|
||||
if (typeof snap.passwordPromptActive === "boolean") {
|
||||
passwordPromptActiveRef.current = snap.passwordPromptActive;
|
||||
// Same conservative restore as the home apply-snapshot path.
|
||||
if (snap.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
|
||||
}
|
||||
if (snap.cwd !== undefined) {
|
||||
const cwd = terminalCwdTracker.setRendererCwd(snap.cwd, "snapshot");
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from "./commandCwdProbe";
|
||||
import { shouldProbeCommandCwd } from "./commandCwdProbe";
|
||||
|
||||
test("probes command cwd for session restore even when the SFTP panel is not visible", () => {
|
||||
assert.equal(
|
||||
@@ -52,73 +51,6 @@ test("visible SFTP host override can disable command cwd probing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("schedules the post-command cwd probe for a connected local POSIX shell", () => {
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connected", shellType: "posix" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connected" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not schedule a local cwd probe for Windows shells or a disconnected shell", () => {
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connected", shellType: "powershell" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connected", shellType: "cmd" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connecting", shellType: "posix" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the SSH post-command probe on reusable connections only", () => {
|
||||
assert.equal(canScheduleCommandCwdProbe({ protocol: "ssh", status: "connected" }), true);
|
||||
assert.equal(canScheduleCommandCwdProbe({ protocol: "ssh", status: "disconnected" }), false);
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "ssh", status: "connected", moshEnabled: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(canScheduleCommandCwdProbe({ protocol: "telnet", status: "connected" }), false);
|
||||
});
|
||||
|
||||
test("local command submission uses the shared cwd probe gate", () => {
|
||||
const source = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("const handleCommandSubmitted = useCallback");
|
||||
const end = source.indexOf("const handleCommandExecuted = useCallback", start);
|
||||
const body = source.slice(start, end);
|
||||
assert.match(body, /canScheduleCommandCwdProbe\(session\)/);
|
||||
assert.match(body, /session\.protocol === "local"\) return true/);
|
||||
});
|
||||
|
||||
test("local prompt return re-runs the cwd probe after the command finishes", () => {
|
||||
const effects = readFileSync(new URL("../terminal/useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
const completed = effects.slice(
|
||||
effects.indexOf("const pluginAwareOnCommandCompleted"),
|
||||
effects.indexOf("const isRendererActive ="),
|
||||
);
|
||||
assert.match(completed, /if \(isLocalConnection\)/);
|
||||
assert.match(completed, /onCommandSettledRef\.current\?\.\("", host\.id, host\.label, sessionId\)/);
|
||||
|
||||
const terminal = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const wakeCompleted = terminal.slice(
|
||||
terminal.indexOf("const pluginAwareOnCommandCompleted = useCallback"),
|
||||
terminal.indexOf("const pluginAwareOnTerminalCwdChange"),
|
||||
);
|
||||
assert.match(wakeCompleted, /host\.protocol === "local"/);
|
||||
assert.match(wakeCompleted, /onCommandSubmitted\?\.\("", host\.id, host\.label, sessionId\)/);
|
||||
assert.match(
|
||||
terminal,
|
||||
/onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not probe command cwd on single-channel SSH hosts", () => {
|
||||
assert.equal(
|
||||
shouldProbeCommandCwd({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isTerminalSessionEligibleForSftpReuse } from "../../application/state/terminalConnectionReuse";
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { resolveHostFollowTerminalCwd, resolveSftpFollowTerminalCwdTargetHost } from "../../domain/sftpFollowTerminalCwd";
|
||||
|
||||
type FollowTerminalCwdHost = {
|
||||
@@ -14,26 +12,6 @@ type ShouldProbeCommandCwdOptions = {
|
||||
restrictExtraSshChannels?: boolean;
|
||||
};
|
||||
|
||||
type CommandCwdProbeSession = Pick<
|
||||
TerminalSession,
|
||||
"protocol" | "status" | "moshEnabled" | "etEnabled" | "shellType"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Sessions whose post-command cwd probe can run.
|
||||
*
|
||||
* SSH reuses the extra exec channel (`getSessionPwd`). Local POSIX shells have
|
||||
* no OSC 7 by default and no SSH channel, but the same probe path can read the
|
||||
* PTY process cwd. CMD/PowerShell cannot accept the POSIX restore `cd`.
|
||||
*/
|
||||
export const canScheduleCommandCwdProbe = (session: CommandCwdProbeSession): boolean => {
|
||||
if (session.status !== "connected") return false;
|
||||
if (session.protocol === "local") {
|
||||
return session.shellType !== "powershell" && session.shellType !== "cmd";
|
||||
}
|
||||
return isTerminalSessionEligibleForSftpReuse(session);
|
||||
};
|
||||
|
||||
export const shouldProbeCommandCwd = ({
|
||||
restoreTerminalCwd,
|
||||
visibleSftpHost,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const toastSource = readFileSync(new URL("./toast.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("toast surfaces follow app theme tokens instead of OS palette fills", () => {
|
||||
const styles = toastSource.slice(
|
||||
toastSource.indexOf("const TOAST_STYLES"),
|
||||
toastSource.indexOf("export const ToastProvider"),
|
||||
);
|
||||
|
||||
assert.match(styles, /success: 'border-emerald-600'/);
|
||||
assert.match(styles, /error: 'border-red-600'/);
|
||||
assert.match(styles, /warning: 'border-yellow-600'/);
|
||||
assert.match(styles, /info: 'border-blue-600'/);
|
||||
// `dark:` is prefers-color-scheme, so these fills stay pale while
|
||||
// text-foreground / text-muted-foreground follow the `.dark` class.
|
||||
assert.doesNotMatch(styles, /bg-(?:emerald|red|yellow|blue)-50/);
|
||||
assert.doesNotMatch(styles, /dark:bg-(?:emerald|red|yellow|blue)-950/);
|
||||
assert.match(toastSource, /"bg-card animate-in/);
|
||||
assert.match(toastSource, /text-sm font-medium text-foreground/);
|
||||
assert.match(toastSource, /text-sm text-muted-foreground break-words/);
|
||||
});
|
||||
@@ -82,15 +82,11 @@ const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
|
||||
info: <Info className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
// Colored `bg-*-50` / `dark:bg-*-950` fills follow the OS color scheme.
|
||||
// Title and body use `.dark` tokens (`text-foreground`, `text-muted-foreground`),
|
||||
// so an app-dark / OS-light session paints light text on a pale fill.
|
||||
// The container's `bg-card` tracks the app theme; the border and icon mark the variant.
|
||||
const TOAST_STYLES: Record<ToastType, string> = {
|
||||
success: 'border-emerald-600',
|
||||
error: 'border-red-600',
|
||||
warning: 'border-yellow-600',
|
||||
info: 'border-blue-600',
|
||||
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
|
||||
error: 'border-red-600 bg-red-50 dark:bg-red-950',
|
||||
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
|
||||
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
|
||||
@@ -1007,43 +1007,42 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
{/* Fixed w-44 flex-shrinks the shell icon when the label is longer than the siblings. */}
|
||||
<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleNewLocalShellHost}
|
||||
>
|
||||
<TerminalSquare size={14} className="shrink-0" /> {t("vault.hosts.newLocalShell")}
|
||||
<TerminalSquare size={14} /> {t("vault.hosts.newLocalShell")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setTargetParentPath(selectedGroupPath);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={14} className="shrink-0" /> {t("vault.hosts.newGroup")}
|
||||
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2"
|
||||
className="w-full justify-start gap-2"
|
||||
disabled={importProgress?.status === "running"}
|
||||
onClick={() => {
|
||||
if (importProgress?.status === "running") return;
|
||||
setIsImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} className="shrink-0" /> {t("vault.hosts.import")}
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} className="shrink-0" /> {t("vault.hosts.export")}
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
buildClaudeHttpAddCommand,
|
||||
buildCodexHttpEnvLine,
|
||||
buildCodexHttpToml,
|
||||
buildExternalMcpHttpUrl,
|
||||
buildGenericHttpMcpJson,
|
||||
} from "./externalMcpHttp.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const security = require("../electron/bridges/externalMcp/httpSecurity.cjs");
|
||||
|
||||
test("HTTP client URL stays on loopback unless a specific listen address is set", () => {
|
||||
assert.equal(DEFAULT_EXTERNAL_MCP_HTTP_PORT, security.DEFAULT_EXTERNAL_MCP_HTTP_PORT);
|
||||
assert.equal(
|
||||
buildExternalMcpHttpUrl("127.0.0.1", 47391),
|
||||
"http://127.0.0.1:47391/mcp",
|
||||
);
|
||||
assert.equal(buildExternalMcpHttpUrl("0.0.0.0", 47391), "");
|
||||
assert.equal(
|
||||
buildExternalMcpHttpUrl("0.0.0.0", 47391, "172.22.0.1"),
|
||||
"http://172.22.0.1:47391/mcp",
|
||||
);
|
||||
assert.equal(
|
||||
buildExternalMcpHttpUrl("172.22.0.1", 47391),
|
||||
"http://172.22.0.1:47391/mcp",
|
||||
);
|
||||
});
|
||||
|
||||
test("HTTP snippets use Codex env bearer form, Claude http transport, and generic JSON", () => {
|
||||
const url = "http://127.0.0.1:47391/mcp";
|
||||
const token = "abc123";
|
||||
const toml = buildCodexHttpToml(url);
|
||||
assert.match(toml, /\[mcp_servers\.netcatty-http\]/);
|
||||
assert.match(toml, /url = "http:\/\/127\.0\.0\.1:47391\/mcp"/);
|
||||
assert.match(toml, /bearer_token_env_var = "NETCATTY_MCP_BEARER_TOKEN"/);
|
||||
assert.equal(buildCodexHttpEnvLine(token), "export NETCATTY_MCP_BEARER_TOKEN='abc123'");
|
||||
assert.equal(
|
||||
buildCodexHttpEnvLine("a'b"),
|
||||
"export NETCATTY_MCP_BEARER_TOKEN='a'\\''b'",
|
||||
);
|
||||
assert.equal(
|
||||
buildClaudeHttpAddCommand(url, token),
|
||||
"claude mcp add --transport http netcatty-http 'http://127.0.0.1:47391/mcp' --header 'Authorization: Bearer abc123'",
|
||||
);
|
||||
const generic = JSON.parse(buildGenericHttpMcpJson(url, token));
|
||||
assert.equal(generic.mcpServers["netcatty-http"].type, "http");
|
||||
assert.equal(generic.mcpServers["netcatty-http"].url, url);
|
||||
assert.equal(generic.mcpServers["netcatty-http"].headers.Authorization, "Bearer abc123");
|
||||
});
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user