mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 12:32:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2a1f825a5 | ||
|
|
e802c92ac6 | ||
|
|
92923f5cc6 | ||
|
|
c0d49b50fc | ||
|
|
faa52f0cf7 | ||
|
|
99dd770d45 | ||
|
|
ed699f0b7f | ||
|
|
5007b9eca2 | ||
|
|
aebe9944ed | ||
|
|
8fdca2baea | ||
|
|
39e4e08d9c | ||
|
|
3ef402cc71 | ||
|
|
ee7b8fae6a | ||
|
|
604e11d239 | ||
|
|
04d71be651 | ||
|
|
5ca36721b5 | ||
|
|
a3083a4ac2 | ||
|
|
b2503ff6ba | ||
|
|
b27f0ad7d3 | ||
|
|
0c36ef7275 | ||
|
|
110c79c8dc | ||
|
|
e8c31a7800 | ||
|
|
2527f140c0 | ||
|
|
aaee43417f | ||
|
|
a622d953ad | ||
|
|
16c9af6e32 | ||
|
|
64717fffd5 | ||
|
|
2ba045c8e7 | ||
|
|
8b0b7706a3 | ||
|
|
fa34a5602e | ||
|
|
d14591b36a | ||
|
|
2bb7baebde | ||
|
|
aa597135b1 | ||
|
|
42b8e2f347 | ||
|
|
9c312b7b98 | ||
|
|
05e4bf69f7 |
No files matched your search
@@ -0,0 +1,72 @@
|
||||
name: Forward issue events to Grok Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
forward:
|
||||
# PR comments are handled elsewhere; only forward real issues.
|
||||
if: github.event_name == 'issues' || github.event.issue.pull_request == null
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Forward minimal event to Grok Bot webhook
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.GROKBOT_WEBHOOK_URL }}
|
||||
WEBHOOK_KEY: ${{ secrets.GROKBOT_WEBHOOK_KEY }}
|
||||
run: |
|
||||
if [ -z "$WEBHOOK_URL" ] || [ -z "$WEBHOOK_KEY" ]; then
|
||||
echo "Grok Bot webhook secrets not set; skipping."
|
||||
exit 0
|
||||
fi
|
||||
jq --arg event "$GITHUB_EVENT_NAME" '
|
||||
def clip:
|
||||
if type == "string" then .[0:4000] else . end;
|
||||
{
|
||||
event: $event,
|
||||
action: .action,
|
||||
repository: .repository.full_name,
|
||||
sender: .sender.login,
|
||||
issue: (
|
||||
{
|
||||
number: .issue.number,
|
||||
title: .issue.title,
|
||||
author: .issue.user.login,
|
||||
url: .issue.html_url
|
||||
} + (
|
||||
if $event == "issues" then {
|
||||
author_association: .issue.author_association,
|
||||
body: (.issue.body | clip)
|
||||
} else {} end
|
||||
)
|
||||
),
|
||||
comment: (
|
||||
if .comment then
|
||||
{
|
||||
id: .comment.id,
|
||||
author: .comment.user.login,
|
||||
url: .comment.html_url,
|
||||
node_id: .comment.node_id,
|
||||
author_association: .comment.author_association,
|
||||
user_type: .comment.user.type,
|
||||
created_at: .comment.created_at,
|
||||
updated_at: .comment.updated_at,
|
||||
body: (.comment.body | clip)
|
||||
} + (
|
||||
if .action == "edited" then {
|
||||
previous_body: (.changes.body.from | clip)
|
||||
} else {} end
|
||||
)
|
||||
else null end
|
||||
)
|
||||
}
|
||||
' "$GITHUB_EVENT_PATH" \
|
||||
| curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: Bearer $WEBHOOK_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @- "$WEBHOOK_URL"
|
||||
@@ -2,7 +2,7 @@ name: spam-comments
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -10,13 +10,13 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
delete-spam:
|
||||
hide-spam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout filter rules
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Delete suspected malware spam
|
||||
- name: Hide suspected malware spam
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
@@ -25,26 +25,59 @@ jobs:
|
||||
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
|
||||
);
|
||||
|
||||
const comment = context.payload.comment;
|
||||
const comment = context.payload.comment || {};
|
||||
const issue = context.payload.issue || {};
|
||||
const login = (comment.user && comment.user.login) || 'unknown';
|
||||
const result = detectSpamComment({
|
||||
body: comment.body || '',
|
||||
authorAssociation: comment.author_association,
|
||||
userType: comment.user && comment.user.type,
|
||||
commentAuthor: comment.user && comment.user.login,
|
||||
issueAuthor: issue.user && issue.user.login,
|
||||
});
|
||||
|
||||
core.info(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.spam) {
|
||||
core.info('Comment did not match the zip/dangerous-file spam filter.');
|
||||
core.info('Comment did not match the risky-download spam filter.');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
const matchedUrl = result.matchedUrls.join(', ');
|
||||
if (!comment.node_id) {
|
||||
core.setFailed(
|
||||
`Comment ${comment.id} by @${login} on #${issue.number} matched ${matchedUrl} but has no node_id.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.graphql(
|
||||
`mutation($input: MinimizeCommentInput!) {
|
||||
minimizeComment(input: $input) {
|
||||
minimizedComment {
|
||||
isMinimized
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
input: {
|
||||
subjectId: comment.node_id,
|
||||
classifier: 'SPAM',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const message = String((error && error.message) || error);
|
||||
if (/already minimized/i.test(message)) {
|
||||
core.info(
|
||||
`Comment ${comment.id} by @${login} on #${issue.number} is already minimized. Matched URL: ${matchedUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
core.notice(
|
||||
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
|
||||
`Hid comment ${comment.id} by @${login} on #${issue.number} as spam. Matched URL: ${matchedUrl}`
|
||||
);
|
||||
@@ -87,6 +87,7 @@ 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)
|
||||
@@ -241,6 +242,24 @@ 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:
|
||||
|
||||
@@ -534,9 +534,10 @@ 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 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.
|
||||
// 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.
|
||||
const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId);
|
||||
|
||||
const bridge = netcattyBridge?.get?.();
|
||||
|
||||
@@ -15,4 +15,14 @@ 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\(\)/);
|
||||
});
|
||||
@@ -94,6 +94,7 @@ 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';
|
||||
|
||||
@@ -1557,7 +1558,7 @@ export function AppSideEffects() {
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string; shell?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
@@ -1721,14 +1722,44 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
// 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 }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
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,
|
||||
);
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({
|
||||
kind: 'open-terminal-path',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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']);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
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,6 +356,14 @@ 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',
|
||||
@@ -368,6 +376,38 @@ 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.',
|
||||
|
||||
@@ -413,7 +413,7 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Selected top tabs (`.top-tab[data-active="true"]`) read `--top-tab-active-bg`, `--top-tab-active-fg`, and `--top-tab-active-border`. Leave them unset to keep the current look.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help',
|
||||
'settings.appearance.customCss.help.title': 'Custom CSS guide',
|
||||
'settings.appearance.customCss.help.body': `Major UI regions expose a \`data-section\` attribute you can target. Changes apply as soon as you edit the CSS.
|
||||
@@ -440,6 +440,22 @@ Because many styles come from Tailwind utilities, you often need \`!important\`
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
Selected top tabs use the class \`top-tab\` and \`data-active="true"\`. Set these variables to recolor the selected tab. Leave them unset to keep the current look. They apply without \`!important\`.
|
||||
|
||||
- \`--top-tab-active-bg\` — selected fill (defaults to the current tab background)
|
||||
- \`--top-tab-active-fg\` — selected label color (defaults to the current tab text)
|
||||
- \`--top-tab-active-border\` — 1px inset outline (defaults to transparent, so there is no border)
|
||||
|
||||
The Vaults tab stays flat and does not use the fill.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Vault
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -533,7 +549,7 @@ Highlight the focused split pane:
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Selected top tab. Leave these unset to keep the current look. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -712,7 +728,7 @@ Highlight the focused split pane:
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash. Defaults to \\e\\r (Escape + Enter, the legacy Alt+Enter encoding), which most TUI CLIs treat as a newline.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
|
||||
|
||||
@@ -109,6 +109,8 @@ 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',
|
||||
|
||||
@@ -347,6 +347,14 @@ 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',
|
||||
@@ -359,6 +367,38 @@ 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.',
|
||||
|
||||
@@ -328,7 +328,7 @@ export const esCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Paleta oscura',
|
||||
'settings.appearance.customCss': 'CSS personalizado',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato.',
|
||||
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato. Las pestañas superiores seleccionadas (`.top-tab[data-active="true"]`) leen `--top-tab-active-bg`, `--top-tab-active-fg` y `--top-tab-active-border`. Si no las defines, se conserva el aspecto actual.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Ayuda de CSS personalizado',
|
||||
'settings.appearance.customCss.help.title': 'Guía de CSS personalizado',
|
||||
'settings.appearance.customCss.help.body': `Las principales regiones de la interfaz exponen un atributo \`data-section\` que puedes usar como destino. Los cambios se aplican en cuanto editas el CSS.
|
||||
@@ -355,6 +355,22 @@ Como muchos estilos provienen de utilidades de Tailwind, a menudo necesitas \`!i
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
Las pestañas superiores seleccionadas usan la clase \`top-tab\` y \`data-active="true"\`. Define estas variables para cambiar el color de la pestaña seleccionada. Si no las defines, se conserva el aspecto actual. Se aplican sin \`!important\`.
|
||||
|
||||
- \`--top-tab-active-bg\` — relleno seleccionado (por defecto, el fondo actual de la pestaña)
|
||||
- \`--top-tab-active-fg\` — color del texto seleccionado (por defecto, el texto actual)
|
||||
- \`--top-tab-active-border\` — contorno interior de 1px (transparente por defecto, así que no hay borde)
|
||||
|
||||
La pestaña Vaults permanece plana y no usa el relleno.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Vault
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -448,7 +464,7 @@ Resalta el panel dividido enfocado:
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
|
||||
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Pestaña superior seleccionada. Si no defines estas variables, se conserva el aspecto actual. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
|
||||
'settings.appearance.language': 'Idioma',
|
||||
'settings.appearance.language.desc': 'Elige el idioma de la interfaz',
|
||||
'settings.appearance.uiFont': 'Fuente de la interfaz',
|
||||
@@ -627,7 +643,7 @@ Resalta el panel dividido enfocado:
|
||||
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida. Por defecto \\e\\r (Escape + Enter, la codificación clásica de Alt+Enter), que la mayoría de las CLI TUI interpretan como nueva línea.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
|
||||
|
||||
@@ -109,6 +109,8 @@ 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',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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\}/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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,6 +303,14 @@ 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': 'Тайм-аут открытого сеанса',
|
||||
@@ -315,6 +323,38 @@ 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) могут показывать своё подтверждение инструментов.',
|
||||
|
||||
@@ -397,7 +397,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу. Выбранные верхние вкладки (`.top-tab[data-active="true"]`) читают `--top-tab-active-bg`, `--top-tab-active-fg` и `--top-tab-active-border`. Не задавайте их, чтобы сохранить текущий вид.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки.
|
||||
@@ -423,6 +423,22 @@ export const ruCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
У выбранных верхних вкладок есть класс \`top-tab\` и атрибут \`data-active="true"\`. Задайте эти переменные, чтобы перекрасить выбранную вкладку. Не задавайте их, чтобы сохранить текущий вид. \`!important\` не нужен.
|
||||
|
||||
- \`--top-tab-active-bg\` — заливка выбранной вкладки (по умолчанию текущий фон вкладки)
|
||||
- \`--top-tab-active-fg\` — цвет подписи (по умолчанию текущий цвет текста)
|
||||
- \`--top-tab-active-border\` — внутренняя обводка 1px (по умолчанию прозрачная, то есть без рамки)
|
||||
|
||||
Вкладка Vaults остаётся плоской и не использует заливку.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Хранилище
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -502,7 +518,7 @@ export const ruCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Выбранная верхняя вкладка. Не задавайте переменные, чтобы сохранить текущий вид. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
@@ -663,7 +679,7 @@ export const ruCoreMessages: Messages = {
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты. По умолчанию \\e\\r (Escape + Enter, классическая кодировка Alt+Enter), которую большинство TUI-CLI распознают как перевод строки.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
|
||||
|
||||
@@ -130,6 +130,8 @@ 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': 'Использовано',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -356,6 +356,14 @@ 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': '已打开会话空闲超时',
|
||||
@@ -368,6 +376,38 @@ 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)仍可能有自己的工具审批界面。',
|
||||
|
||||
@@ -417,7 +417,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。选中的顶部标签(`.top-tab[data-active="true"]`)会读取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不设置则保持当前外观。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.title': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。
|
||||
@@ -444,6 +444,22 @@ export const zhCNCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
选中的顶部标签带有 \`top-tab\` 类和 \`data-active="true"\`。设置下面的变量即可改变选中标签的颜色;不设置则保持当前外观。这些变量不需要 \`!important\`。
|
||||
|
||||
- \`--top-tab-active-bg\` — 选中标签的背景(默认是现在的标签背景)
|
||||
- \`--top-tab-active-fg\` — 选中标签的文字颜色(默认是现在的文字颜色)
|
||||
- \`--top-tab-active-border\` — 1px 内描边(默认透明,也就是没有边框)
|
||||
|
||||
Vaults 标签保持扁平,不使用这块背景。
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 保险库
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -537,7 +553,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 选中的顶部标签。不设置这些变量则保持当前外观。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
|
||||
@@ -352,7 +352,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。默认 \\e\\r(ESC+回车,传统 Alt+Enter 编码),大多数 TUI CLI 会将其识别为换行。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
|
||||
|
||||
@@ -532,6 +532,8 @@ 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,6 +346,14 @@ 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': '已開啟工作階段閒置逾時',
|
||||
@@ -358,6 +366,38 @@ 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)仍可能有自己的工具核准介面。',
|
||||
|
||||
@@ -419,7 +419,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主題',
|
||||
'settings.appearance.customCss': '自訂 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。',
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。選取的頂部分頁(`.top-tab[data-active="true"]`)會讀取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不設定則維持目前外觀。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.title': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。
|
||||
@@ -446,6 +446,22 @@ export const zhTWCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
選取的頂部分頁帶有 \`top-tab\` 類別和 \`data-active="true"\`。設定下列變數即可改變選取分頁的顏色;不設定則維持目前外觀。這些變數不需要 \`!important\`。
|
||||
|
||||
- \`--top-tab-active-bg\` — 選取分頁的背景(預設為目前的分頁背景)
|
||||
- \`--top-tab-active-fg\` — 選取分頁的文字顏色(預設為目前的文字顏色)
|
||||
- \`--top-tab-active-border\` — 1px 內描邊(預設透明,也就是沒有邊框)
|
||||
|
||||
Vaults 分頁維持扁平,不使用這塊背景。
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 保險庫
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -539,7 +555,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 選取的頂部分頁。不設定這些變數則維持目前外觀。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'settings.appearance.language': '語言',
|
||||
'settings.appearance.language.desc': '選擇介面語言',
|
||||
'settings.appearance.uiFont': '介面字型',
|
||||
|
||||
@@ -352,7 +352,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。預設 \\e\\r(ESC+Enter,傳統 Alt+Enter 編碼),大多數 TUI CLI 會將其識別為換行。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
|
||||
|
||||
@@ -532,6 +532,8 @@ 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': '已用',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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,10 +13,11 @@ type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "last
|
||||
|
||||
export interface CaptureInheritedCwdOptions {
|
||||
/**
|
||||
* 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`).
|
||||
* 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.
|
||||
*/
|
||||
liveCwd?: string;
|
||||
/**
|
||||
@@ -35,7 +36,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) -> live SSH `/proc` probe (when allowed)
|
||||
* Priority: live tracked cwd (OSC 7 or post-command probe) -> 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
|
||||
|
||||
@@ -530,8 +530,12 @@ test("only trusted live terminal cwd recovery resumes follow after initial probe
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
|
||||
test("regular follow waits for trusted live cwd instead of polling after command clear", async () => {
|
||||
// Regression for #3588: command submit clears cwd to null and schedules a
|
||||
// backend-strict probe. Auto-follow must not preferFreshBackend-poll in that
|
||||
// gap — that race aborted the navigate and left the panel stuck.
|
||||
const navigatedPaths: string[] = [];
|
||||
let probeCalls = 0;
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
@@ -550,20 +554,15 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
},
|
||||
},
|
||||
};
|
||||
let activeSessionId = "session-a";
|
||||
let activeTerminalCwd: string | null = "/home/alice";
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
let deferProbe = false;
|
||||
let activeTerminalCwdTrusted = false;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onGetTerminalCwd = () => {
|
||||
if (!deferProbe) return Promise.resolve("/root/session-a");
|
||||
return new Promise<string>((resolve) => { resolveCwd = resolve; });
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
@@ -575,7 +574,11 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd,
|
||||
onGetTerminalCwd: async () => {
|
||||
probeCalls += 1;
|
||||
// First-open may probe; keep the pane where it already is.
|
||||
return connection.currentPath;
|
||||
},
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
@@ -586,23 +589,28 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
const probesAfterOpen = probeCalls;
|
||||
assert.ok(probesAfterOpen >= 1, "first-open sync still probes");
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
deferProbe = true;
|
||||
// Command-submit style clear: no trusted cwd yet.
|
||||
activeTerminalCwd = null;
|
||||
activeTerminalCwdTrusted = false;
|
||||
await act(async () => {
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
assert.equal(probeCalls, probesAfterOpen, "regular follow must not poll while cwd is cleared");
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
activeSessionId = "session-b";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
// Post-command backend-strict (or OSC 7) publishes a trusted live cwd.
|
||||
activeTerminalCwd = "/etc";
|
||||
activeTerminalCwdTrusted = true;
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/from-session-a");
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
assert.deepEqual(navigatedPaths, ["/etc"]);
|
||||
assert.equal(probeCalls, probesAfterOpen, "trusted live cwd must navigate without another poll");
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
@@ -302,6 +302,12 @@ export function useSftpFollowTerminalCwd({
|
||||
&& previousOriginId !== expectedSessionId,
|
||||
);
|
||||
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
|
||||
// Auto-follow must wait for a trusted live cwd (OSC 7, post-command
|
||||
// backend-strict probe, or inferred). Polling preferFreshBackend here while
|
||||
// cwd is null/untrusted races that probe: navigate starts, the probe then
|
||||
// invalidates the sync generation, abort restores the previous path, and
|
||||
// without a connectionPath effect dependency the panel stays put (#3588).
|
||||
// Manual "Go to terminal directory" and first-open sync still probe.
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd && originChanged) {
|
||||
@@ -311,12 +317,6 @@ export function useSftpFollowTerminalCwd({
|
||||
homeDir: sftpRef.current.leftPane.connection?.homeDir,
|
||||
currentPath: sftpRef.current.leftPane.connection?.currentPath,
|
||||
});
|
||||
} else if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
@@ -430,6 +430,7 @@ export function useSftpFollowTerminalCwd({
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionPath,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
|
||||
@@ -56,6 +56,28 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('enter reconnect stays off until the session allows it and does not queue', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const requests: string[] = [];
|
||||
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
registry.register('session-1', () => requests.push('session-1'));
|
||||
assert.deepEqual(requests, []);
|
||||
|
||||
registry.setEnterReconnectEnabled('session-1', true);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), true);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
|
||||
registry.setActive('session-1', true);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
|
||||
registry.setActive('session-1', false);
|
||||
registry.setEnterReconnectEnabled('session-1', false);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
});
|
||||
|
||||
test('unregistering the current handler clears active reconnect state', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const unregister = registry.register('session-1', () => {});
|
||||
|
||||
@@ -4,6 +4,7 @@ export const createTerminalReconnectRegistry = () => {
|
||||
const handlers = new Map<string, TerminalReconnectHandler>();
|
||||
const pendingRequests = new Set<string>();
|
||||
const activeSessionIds = new Set<string>();
|
||||
const enterReconnectEnabledIds = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => {
|
||||
@@ -51,7 +52,27 @@ export const createTerminalReconnectRegistry = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
return { register, request, setActive, isActive, subscribe };
|
||||
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use
|
||||
// request(); this path stays off during auth, host-key, and blocking overlays.
|
||||
const setEnterReconnectEnabled = (sessionId: string, enabled: boolean): void => {
|
||||
if (enabled) enterReconnectEnabledIds.add(sessionId);
|
||||
else enterReconnectEnabledIds.delete(sessionId);
|
||||
};
|
||||
|
||||
const requestEnterReconnect = (sessionId: string): boolean => {
|
||||
if (!enterReconnectEnabledIds.has(sessionId)) return false;
|
||||
return request(sessionId);
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
request,
|
||||
requestEnterReconnect,
|
||||
setEnterReconnectEnabled,
|
||||
setActive,
|
||||
isActive,
|
||||
subscribe,
|
||||
};
|
||||
};
|
||||
|
||||
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
|
||||
@@ -3,6 +3,17 @@ 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,
|
||||
@@ -11,9 +22,11 @@ import {
|
||||
getExternalMcpStartupReadyWaiterCountForTests,
|
||||
isExternalMcpStartupReady,
|
||||
markExternalMcpStartupReady,
|
||||
normalizeExternalMcpApprovalAlertMode,
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpSilentSessions,
|
||||
readExternalMcpStoredEnabled,
|
||||
@@ -25,6 +38,7 @@ import {
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpSilentSessions,
|
||||
} from './useExternalMcpToggleState.ts';
|
||||
@@ -69,6 +83,22 @@ 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]) {
|
||||
@@ -395,3 +425,30 @@ 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,7 +1,13 @@
|
||||
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,
|
||||
@@ -9,11 +15,15 @@ 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;
|
||||
@@ -100,10 +110,23 @@ 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 = {
|
||||
@@ -183,6 +206,26 @@ 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;
|
||||
@@ -217,6 +260,109 @@ 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,
|
||||
@@ -239,11 +385,19 @@ 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;
|
||||
@@ -252,6 +406,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
storedEnabled,
|
||||
@@ -265,14 +420,17 @@ 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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ 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
|
||||
@@ -37,6 +45,7 @@ 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
|
||||
@@ -116,6 +125,7 @@ function createEmptyServerStats(): ServerStats {
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
gpus: [],
|
||||
memTotal: null,
|
||||
memUsed: null,
|
||||
memFree: null,
|
||||
@@ -218,6 +228,7 @@ 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,
|
||||
|
||||
@@ -15,6 +15,11 @@ 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.authMethod !== undefined || c.identityId !== undefined ||
|
||||
c.agentForwarding !== undefined || c.x11Forwarding !== 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,6 +262,7 @@ 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;
|
||||
@@ -499,6 +500,10 @@ 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";
|
||||
@@ -568,6 +573,7 @@ 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 }),
|
||||
@@ -778,6 +784,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
setNewKeyFilePath={setNewKeyFilePath}
|
||||
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
|
||||
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
|
||||
inheritedX11Forwarding={inheritedX11Forwarding}
|
||||
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
||||
|
||||
@@ -47,6 +47,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
setNewKeyFilePath,
|
||||
inheritedLegacyAlgorithms,
|
||||
inheritedSkipEcdsaHostKey,
|
||||
inheritedX11Forwarding,
|
||||
inheritedStartupCommandRunMode,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
@@ -395,6 +396,16 @@ 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,6 +30,17 @@ 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,4 +1,5 @@
|
||||
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";
|
||||
@@ -63,6 +64,15 @@ 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 (
|
||||
<>
|
||||
@@ -337,7 +347,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
{showX11Forwarding && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
@@ -345,8 +355,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
enabled={effectiveX11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !effectiveX11Forwarding)}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import type { Host, PortForwardingRule, PortForwardingType } from "../domain/models";
|
||||
import { EditPanel } from "./port-forwarding/EditPanel";
|
||||
import { NewFormPanel } from "./port-forwarding/NewFormPanel";
|
||||
import { WizardContent } from "./port-forwarding/WizardContent";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
||||
const baseHost: Omit<Host, "os"> = {
|
||||
id: "host-1",
|
||||
label: "Forwarding host",
|
||||
hostname: "example.com",
|
||||
username: "user",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const baseRule: PortForwardingRule = {
|
||||
id: "rule-1",
|
||||
label: "Test forwarding rule",
|
||||
type: "local",
|
||||
hostId: baseHost.id,
|
||||
localPort: 8080,
|
||||
bindAddress: "127.0.0.1",
|
||||
remoteHost: "localhost",
|
||||
remotePort: 80,
|
||||
status: "inactive",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const noop = () => undefined;
|
||||
const panelRenderers = {
|
||||
edit: (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<EditPanel
|
||||
rule={draft}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onDraftChange={noop}
|
||||
onSave={noop}
|
||||
onClose={noop}
|
||||
onDuplicate={noop}
|
||||
onDelete={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
new: (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<NewFormPanel
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onDraftChange={noop}
|
||||
onSave={noop}
|
||||
onClose={noop}
|
||||
onOpenHostSelector={noop}
|
||||
onOpenWizard={noop}
|
||||
isValid={false}
|
||||
/>
|
||||
),
|
||||
"remote-host-selection": (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<WizardContent
|
||||
step="remote-host-selection"
|
||||
type={draft.type}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onTypeChange={noop}
|
||||
onDraftChange={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
"host-selection": (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<WizardContent
|
||||
step="host-selection"
|
||||
type={draft.type}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onTypeChange={noop}
|
||||
onDraftChange={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const hostCases: { name: string; fields: { os?: Host["os"] | "" | null } }[] = [
|
||||
{ name: "missing OS", fields: {} },
|
||||
{ name: "undefined OS", fields: { os: undefined } },
|
||||
{ name: "null OS", fields: { os: null } },
|
||||
{ name: "empty OS", fields: { os: "" } },
|
||||
{ name: "Linux", fields: { os: "linux" } },
|
||||
{ name: "Windows", fields: { os: "windows" } },
|
||||
{ name: "macOS", fields: { os: "macos" } },
|
||||
];
|
||||
|
||||
function render(panel: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>{panel}</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [panelName, renderPanel] of Object.entries(panelRenderers)) {
|
||||
for (const type of ["local", "remote", "dynamic"] as PortForwardingType[]) {
|
||||
for (const { name, fields } of hostCases) {
|
||||
test(`${panelName} renders ${type} forwarding with ${name}`, () => {
|
||||
// Persisted legacy records can violate the required Host.os type at runtime.
|
||||
const host = { ...baseHost, ...fields } as unknown as Host;
|
||||
const markup = render(renderPanel([host], { ...baseRule, type }));
|
||||
|
||||
assert.match(markup, /Forwarding host/);
|
||||
assert.match(markup, /lucide-server/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test(`${panelName} preserves the selected host's distro icon`, () => {
|
||||
const host: Host = { ...baseHost, os: "linux", distro: "ubuntu" };
|
||||
const markup = render(renderPanel([host], baseRule));
|
||||
|
||||
assert.match(markup, /Forwarding host/);
|
||||
assert.match(markup, /src="\/distro\/ubuntu\.svg"/);
|
||||
});
|
||||
|
||||
test(`${panelName} renders the host picker when no host is selected`, () => {
|
||||
const markup = render(renderPanel([], { ...baseRule, hostId: undefined }));
|
||||
|
||||
assert.match(markup, /Select a host/);
|
||||
assert.doesNotMatch(markup, /Forwarding host/);
|
||||
});
|
||||
|
||||
test(`${panelName} renders the host picker when the selected host is unavailable`, () => {
|
||||
const markup = render(renderPanel([], baseRule));
|
||||
|
||||
assert.match(markup, /Select a host/);
|
||||
assert.doesNotMatch(markup, /Forwarding host/);
|
||||
});
|
||||
}
|
||||
@@ -76,8 +76,8 @@ import {
|
||||
resolveLocateSftpPathInTerminalAction,
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { sftpRequiresDedicatedLogin } from "../domain/sftpDedicatedLogin";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
@@ -577,7 +577,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
|
||||
sessionId: string;
|
||||
singleChannel: boolean;
|
||||
remoteSshVersion?: string;
|
||||
} | null>(null);
|
||||
const activeSessionStatusForBanner = activeSessionId
|
||||
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
|
||||
@@ -590,7 +590,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}
|
||||
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
|
||||
if (!readRemoteInfo) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -598,11 +598,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (cancelled) return;
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -733,8 +733,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
|
||||
});
|
||||
// The peer banner is known only after the terminal handshake. Wait for it
|
||||
// before choosing a shared terminal channel, so a one-channel bastion
|
||||
// dials its file login directly.
|
||||
// before choosing a shared terminal channel, so a one-channel bastion or
|
||||
// network-vendor switch dials its file login directly.
|
||||
if (
|
||||
activeSessionId
|
||||
&& activeSessionStatus === "connected"
|
||||
@@ -935,8 +935,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel;
|
||||
// Independent login: network-device mode, a network-vendor banner, or a
|
||||
// one-channel bastion. Do not open SFTP on the terminal connection.
|
||||
const dedicatedSftpLogin = sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
});
|
||||
const reuseTerminalTransport = !dedicatedSftpLogin && (
|
||||
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
|
||||
);
|
||||
@@ -1382,10 +1388,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={activeHost}
|
||||
activeSessionId={activeSessionId}
|
||||
bannerRequiresSingleChannel={
|
||||
sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel
|
||||
}
|
||||
bannerRequiresSingleChannel={sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
})}
|
||||
focusedSessionId={focusedSessionId}
|
||||
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
||||
renderOverlays={renderOverlays}
|
||||
|
||||
+19
-4
@@ -1162,7 +1162,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | undefined> => {
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// The backend pwd probe opens an exec channel on the terminal connection;
|
||||
// network devices (mode, detected distro, or vendor banner such as Huawei
|
||||
// `-`) can drop the whole session for it (#1043, #3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionRef.current) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionRef.current).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
|
||||
@@ -1175,7 +1185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
}, [host, terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
@@ -2420,7 +2430,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
}, [pluginTerminalLifecycle]);
|
||||
// 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]);
|
||||
const pluginAwareOnTerminalCwdChange = useCallback((
|
||||
changedSessionId: string,
|
||||
cwd: string | null,
|
||||
@@ -4644,7 +4659,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, 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, 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 });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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 {
|
||||
@@ -114,7 +113,7 @@ import {
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
resolveSessionSidePanelAutoOpen,
|
||||
} from '../domain/terminalSidePanelAutoOpen';
|
||||
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import {
|
||||
resolvePreferredTerminalCwd,
|
||||
scheduleBackendCwdProbeAfterCommand,
|
||||
@@ -1348,7 +1347,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const tabId = activeTabIdRef.current;
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !canReuseTerminalConnection(session)) return;
|
||||
// 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;
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
|
||||
? sftpHostForTabRef.current.get(tabId) ?? null
|
||||
@@ -1372,6 +1376,8 @@ 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);
|
||||
@@ -1594,7 +1600,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// Same rule as the terminal's own SFTP path lookup (#3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionId && host) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
rendererCwdSource: sessionId
|
||||
|
||||
@@ -294,6 +294,26 @@ test("session close button double click stays on the close button", () => {
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
|
||||
});
|
||||
|
||||
test("main window controls keep mouse presses from taking focus", () => {
|
||||
const windowControlsSource = topTabItemsSource.slice(
|
||||
topTabItemsSource.indexOf("export const WindowControls"),
|
||||
topTabItemsSource.indexOf("WindowControls.displayName"),
|
||||
);
|
||||
|
||||
assert.match(
|
||||
windowControlsSource,
|
||||
/const preventWindowControlFocus: React\.MouseEventHandler<HTMLButtonElement> = \(event\) => \{\s*event\.preventDefault\(\);\s*\};/,
|
||||
);
|
||||
assert.equal(windowControlsSource.match(/onMouseDown=\{preventWindowControlFocus\}/g)?.length, 2);
|
||||
const closeButton = windowControlsSource.match(
|
||||
/<button type="button" className=\{closeControlClassName\}[^>]*>/,
|
||||
)?.[0] ?? "";
|
||||
assert.equal(closeButton.includes("onMouseDown"), false);
|
||||
assert.match(windowControlsSource, /onClick=\{handleMinimize\}/);
|
||||
assert.match(windowControlsSource, /onClick=\{handleMaximize\}/);
|
||||
assert.match(windowControlsSource, /onClick=\{handleClose\}/);
|
||||
});
|
||||
|
||||
test("session top tab label only shows the host label for ssh sessions", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* even when the Catty AI side panel has never been opened.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -20,6 +20,13 @@ 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__';
|
||||
|
||||
@@ -31,19 +38,37 @@ 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);
|
||||
@@ -129,7 +154,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
{entries.map(([id, req]) => (
|
||||
<div key={id} className="space-y-1.5">
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
name={externalMcpApprovalToolLabel(req.toolName)}
|
||||
args={req.args}
|
||||
approvalTarget={req.target}
|
||||
isLoading={false}
|
||||
|
||||
@@ -134,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
|
||||
@@ -134,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
|
||||
@@ -123,7 +123,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
@@ -228,7 +232,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
@@ -224,9 +224,9 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
|
||||
>
|
||||
<Input
|
||||
value={terminalSettings.shiftEnterNewlineText ?? "\\n"}
|
||||
value={terminalSettings.shiftEnterNewlineText ?? "\\e\\r"}
|
||||
onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)}
|
||||
placeholder="\\n"
|
||||
placeholder="\\e\\r"
|
||||
className="w-56 font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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,17 +1,31 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { 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";
|
||||
@@ -19,70 +33,14 @@ 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;
|
||||
@@ -97,6 +55,7 @@ type ExternalMcpStatus = {
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
permissionMode?: string;
|
||||
error?: string | null;
|
||||
http?: ExternalMcpHttpViewStatus | null;
|
||||
};
|
||||
|
||||
type ClientSetupStatus = {
|
||||
@@ -297,8 +256,19 @@ 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);
|
||||
@@ -344,11 +314,51 @@ 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;
|
||||
@@ -698,6 +708,24 @@ 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")}
|
||||
@@ -778,6 +806,17 @@ 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>
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
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,6 +177,12 @@ 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,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "../../domain/sftpDedicatedLogin";
|
||||
|
||||
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
|
||||
const slotSource = readFileSync(
|
||||
@@ -49,12 +51,99 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
|
||||
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\);/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
|
||||
assert.doesNotMatch(sidePanelSource, /sftpBannerDecision\.singleChannel/);
|
||||
assert.match(sidePanelSource, /reuseTransport: false/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/sourceSessionId: reuseTerminalTransport/,
|
||||
);
|
||||
});
|
||||
|
||||
test("network-device mode and network-vendor banners force dedicated SFTP", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "-" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0--" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0-RGOS_SSH" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("vendor banners block follow-terminal-cwd the same way bastions do", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/bannerRequiresSingleChannel=\{sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\)\}/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /remoteSoftwareRequiresSingleChannel/);
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "BHostSSH_7.0"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opening SFTP skips the terminal pwd exec for network-vendor banners", () => {
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
|
||||
const assertBannerPwdGuard = (source: string, fnName: string) => {
|
||||
const start = source.indexOf(`const ${fnName} = useCallback`);
|
||||
assert.ok(start >= 0, fnName);
|
||||
const body = source.slice(start, start + 1600);
|
||||
assert.match(body, /let skipBackendPwd = hostRestrictsExtraSshChannels\(host\);/);
|
||||
assert.match(body, /getSessionRemoteInfo\?\./);
|
||||
assert.match(body, /skipBackendPwd = !shouldProbeSessionCwd\(\{/);
|
||||
assert.match(body, /classifyDistroId\(host\.distro\) === 'network-device'/);
|
||||
assert.match(body, /remoteSshVersion: info\?\.remoteSshVersion/);
|
||||
assert.match(body, /getSessionPwd: skipBackendPwd/);
|
||||
};
|
||||
|
||||
assertBannerPwdGuard(terminalSource, "resolveSftpInitialPath");
|
||||
assertBannerPwdGuard(layerSource, "getTerminalCwd");
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from "\.\.\/domain\/host";/,
|
||||
);
|
||||
assert.match(
|
||||
layerSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from '\.\.\/domain\/host';/,
|
||||
);
|
||||
});
|
||||
@@ -17,8 +17,13 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
|
||||
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
|
||||
);
|
||||
// Reuse the terminal transport only when SSH is connected, or when a strict
|
||||
// pending source id is already pinned. A dedicated single-channel login never
|
||||
// reuses that transport. A linked id may arrive while reconnecting.
|
||||
// pending source id is already pinned. A dedicated login (bastion banner,
|
||||
// network-vendor banner, or network-device mode) never reuses that
|
||||
// transport. A linked id may arrive while reconnecting.
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin && \(\s*Boolean\(pendingStrictSourceSessionId\) \|\| activeSessionStatus === "connected"\s*\);/,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -95,6 +96,7 @@ 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,
|
||||
@@ -131,8 +133,9 @@ export function TerminalAutocomplete({
|
||||
historyScope: options.historyScope ?? settings?.historyScope,
|
||||
signal: options.signal,
|
||||
onLatePathSuggestions: options.onLatePathSuggestions,
|
||||
locale: resolvedLocale,
|
||||
});
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, resolvedLocale, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
containerRef,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
|
||||
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
@@ -102,3 +103,95 @@ test('compose bar preserves a draft on pane focus and records only successful as
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('empty compose draft Enter reconnects; a typed draft still sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
const sessionId = 'compose-enter-reconnect';
|
||||
const reconnects: string[] = [];
|
||||
const unregister = terminalReconnectRegistry.register(sessionId, () => {
|
||||
reconnects.push(sessionId);
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const createTextarea = () => ({
|
||||
value: '', selectionStart: 0, selectionEnd: 0,
|
||||
focus() {},
|
||||
setSelectionRange() {},
|
||||
});
|
||||
let textarea = createTextarea();
|
||||
const sent: string[] = [];
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<TooltipProvider>
|
||||
<TerminalComposeBar sessionId={sessionId} onSend={(text) => { sent.push(text); return true; }} onClose={() => {}} />
|
||||
</TooltipProvider>,
|
||||
{ createNodeMock: (el) => {
|
||||
if (el.type !== 'textarea') return null;
|
||||
textarea = createTextarea();
|
||||
return textarea;
|
||||
} },
|
||||
);
|
||||
});
|
||||
const key = (name: string, extra: Record<string, unknown> = {}) => {
|
||||
let prevented = false;
|
||||
root.root.findByType('textarea').props.onKeyDown({
|
||||
key: name,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
nativeEvent: {},
|
||||
currentTarget: textarea,
|
||||
preventDefault() { prevented = true; },
|
||||
...extra,
|
||||
});
|
||||
return prevented;
|
||||
};
|
||||
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, true);
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(textarea.value, '');
|
||||
|
||||
textarea.value = 'echo hi';
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.equal(textarea.value, '');
|
||||
|
||||
textarea.value = 'keep me';
|
||||
assert.equal(key('Enter', { shiftKey: true }), false);
|
||||
assert.equal(textarea.value, 'keep me');
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
textarea.value = '';
|
||||
assert.equal(key('Enter', { ctrlKey: true }), true);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
assert.equal(key('Enter', { nativeEvent: { isComposing: true, keyCode: 229 } }), false);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
|
||||
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
|
||||
@@ -496,12 +497,22 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
// Plain Enter on an empty draft reconnects a disconnected session.
|
||||
// Modified Enter and a non-empty draft keep the existing send path (#3767).
|
||||
const plainEnter = !e.altKey && !e.ctrlKey && !e.metaKey;
|
||||
if (
|
||||
plainEnter
|
||||
&& !e.currentTarget.value
|
||||
&& terminalReconnectRegistry.requestEnterReconnect(sessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, navigate, onClose]);
|
||||
}, [handleSend, navigate, onClose, sessionId]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
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,4 +28,7 @@ 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,6 +8,7 @@ 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;
|
||||
@@ -48,6 +49,7 @@ 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;
|
||||
|
||||
@@ -122,28 +124,37 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* GPU — only rendered when the remote reports NVIDIA GPU data (Linux nvidia-smi) */}
|
||||
{/* 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. */}
|
||||
{serverStats.gpu !== null && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{/* Memory with HoverCard for htop-style bar and top processes */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
|
||||
@@ -220,7 +220,6 @@ test("disconnected terminal reconnects on plain Enter when input is not claimed
|
||||
key: "Enter",
|
||||
status: "disconnected",
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
@@ -234,7 +233,6 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
|
||||
key: "Enter",
|
||||
status: "disconnected" as const,
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
@@ -243,14 +241,16 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
|
||||
// Open search must not globally suppress Enter reconnect / the hint (#2546).
|
||||
// Open search and an open compose bar must not globally suppress Enter
|
||||
// reconnect / the hint (#2546 / #3767).
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, shiftKey: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposing: true }), false);
|
||||
});
|
||||
|
||||
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuCon
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { classifyDistroId } from '../../domain/host';
|
||||
import type { HostInfoBarTitleMode } from '../../domain/models';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { OSC7_SETUP_TARGETS } from './osc7Setup';
|
||||
@@ -184,7 +185,6 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
key,
|
||||
status,
|
||||
hasRetryHandler,
|
||||
isComposeBarOpen,
|
||||
needsAuth,
|
||||
needsHostKeyVerification,
|
||||
hasBlockingOverlay,
|
||||
@@ -198,7 +198,6 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
key: string;
|
||||
status?: string;
|
||||
hasRetryHandler: boolean;
|
||||
isComposeBarOpen: boolean;
|
||||
needsAuth: boolean;
|
||||
needsHostKeyVerification: boolean;
|
||||
hasBlockingOverlay: boolean;
|
||||
@@ -209,15 +208,15 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
shiftKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
}): boolean {
|
||||
// Search-bar open state is intentionally not a global gate. While disconnected,
|
||||
// find-next is less useful than reconnect; the capture handler still refuses
|
||||
// Enter only when a real interactive control outside xterm owns the event
|
||||
// (compose/auth/buttons). The terminal search input is allow-listed so an open
|
||||
// search bar cannot hide the hint or swallow reconnect (#2544 / #2546).
|
||||
// Search and compose bars are not global gates. While disconnected, plain
|
||||
// Enter still reconnects from the terminal and the disconnected dialog.
|
||||
// Capture still skips real controls outside xterm (compose textarea, auth,
|
||||
// buttons). Search input is allow-listed so an open search bar cannot hide
|
||||
// the hint or swallow reconnect (#2544 / #2546). An empty compose draft
|
||||
// reconnects from TerminalComposeBar; a non-empty draft keeps send (#3767).
|
||||
return key === "Enter"
|
||||
&& status === "disconnected"
|
||||
&& hasRetryHandler
|
||||
&& !isComposeBarOpen
|
||||
&& !needsAuth
|
||||
&& !needsHostKeyVerification
|
||||
&& !hasBlockingOverlay
|
||||
@@ -529,18 +528,24 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
key: "Enter",
|
||||
status,
|
||||
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
|
||||
isComposeBarOpen,
|
||||
needsAuth: Boolean(auth.needsAuth),
|
||||
needsHostKeyVerification: Boolean(needsHostKeyVerification),
|
||||
hasBlockingOverlay: hasBlockingReconnectOverlay,
|
||||
isReconnectActive,
|
||||
});
|
||||
// Publish Enter-reconnect so an empty compose draft can reconnect this
|
||||
// session, including the workspace compose bar (#3767).
|
||||
useEffect(() => {
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, showEnterReconnectHint);
|
||||
return () => {
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
};
|
||||
}, [sessionId, showEnterReconnectHint]);
|
||||
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!shouldReconnectTerminalOnEnterKey({
|
||||
key: event.key,
|
||||
status,
|
||||
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
|
||||
isComposeBarOpen,
|
||||
needsAuth: Boolean(auth.needsAuth),
|
||||
needsHostKeyVerification: Boolean(needsHostKeyVerification),
|
||||
hasBlockingOverlay: hasBlockingReconnectOverlay,
|
||||
@@ -563,7 +568,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
auth.needsAuth,
|
||||
handleRetry,
|
||||
hasBlockingReconnectOverlay,
|
||||
isComposeBarOpen,
|
||||
isReconnectActive,
|
||||
needsHostKeyVerification,
|
||||
status,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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,6 +31,7 @@ import {
|
||||
resolvePathComponents,
|
||||
} from "./remotePathCompleter";
|
||||
import { getSnippetSuggestions } from "./snippetCompleter";
|
||||
import { localizeSpecSuggestionDescriptions } from "./autocompleteDescriptionI18n";
|
||||
import type { AutocompleteHistoryScope, Snippet } from "../../../domain/models";
|
||||
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
|
||||
|
||||
@@ -272,6 +273,12 @@ 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;
|
||||
@@ -368,7 +375,11 @@ export async function getCompletions(
|
||||
)
|
||||
: [];
|
||||
|
||||
for (const suggestion of specResult.suggestions) {
|
||||
const specSuggestions = await localizeSpecSuggestionDescriptions(
|
||||
specResult.suggestions,
|
||||
options.locale,
|
||||
);
|
||||
for (const suggestion of specSuggestions) {
|
||||
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
@@ -29,6 +29,8 @@ 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;
|
||||
@@ -74,6 +76,7 @@ export async function provideTerminalCompletions(
|
||||
snippets: request.snippets,
|
||||
historyScope: request.historyScope,
|
||||
onLatePathSuggestions: request.onLatePathSuggestions,
|
||||
locale: request.locale,
|
||||
});
|
||||
const pluginRequestController = new AbortController();
|
||||
const abortPluginRequest = () => pluginRequestController.abort();
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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);
|
||||
});
|
||||
@@ -389,6 +389,23 @@ test("disconnected connection dialog keeps an Enter-reconnect focus sink", () =>
|
||||
assert.match(source, /restoreTerminalFocusFromDisconnectedDialog\(\{[\s\S]*isFocusedPane/);
|
||||
});
|
||||
|
||||
test("open compose bar does not globally gate enter reconnect", () => {
|
||||
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
|
||||
const bodyEnd = viewSource.indexOf("export function shouldBlockTerminalReconnectForTarget", gateIndex);
|
||||
const gateBody = viewSource.slice(gateIndex, bodyEnd);
|
||||
assert.equal(gateBody.includes("!isComposeBarOpen"), false);
|
||||
assert.match(viewSource, /setEnterReconnectEnabled\(sessionId, showEnterReconnectHint\)/);
|
||||
assert.match(
|
||||
readFileSync(new URL("./TerminalComposeBar.tsx", import.meta.url), "utf8"),
|
||||
/requestEnterReconnect\(sessionId\)/,
|
||||
);
|
||||
assert.match(
|
||||
readFileSync(new URL("./terminalHelpers.ts", import.meta.url), "utf8"),
|
||||
/data-section='terminal-compose-bar'/,
|
||||
);
|
||||
});
|
||||
|
||||
test("open terminal search does not globally gate enter reconnect", () => {
|
||||
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
|
||||
@@ -402,7 +419,7 @@ test("open terminal search does not globally gate enter reconnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal view receives the effective compose bar state for enter reconnect gating", () => {
|
||||
test("terminal view receives the effective compose bar state", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const effectiveDefinitionIndex = source.indexOf("const effectiveComposeBarOpen =");
|
||||
const viewIndex = source.indexOf("<TerminalView ctx={{");
|
||||
@@ -413,7 +430,7 @@ test("terminal view receives the effective compose bar state for enter reconnect
|
||||
assert.notEqual(passEffectiveIndex, -1);
|
||||
assert.ok(
|
||||
effectiveDefinitionIndex < passEffectiveIndex,
|
||||
"TerminalView must use the visible workspace compose state before deciding whether Enter can reconnect",
|
||||
"TerminalView must receive the visible workspace compose state",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -541,7 +541,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
}), {
|
||||
data: "\n",
|
||||
data: "\u001b\r",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
@@ -552,7 +552,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
}), {
|
||||
data: "\n",
|
||||
data: "\u001b\r",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
|
||||
@@ -26,8 +26,8 @@ const keyEvent = (overrides: Partial<KeyboardEvent> = {}) => ({
|
||||
...overrides,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
test("shift enter text defaults to newline", () => {
|
||||
assert.equal(resolveShiftEnterText(), "\n");
|
||||
test("shift enter text defaults to the legacy Alt+Enter encoding (ESC + CR)", () => {
|
||||
assert.equal(resolveShiftEnterText(), "\u001b\r");
|
||||
});
|
||||
|
||||
test("shift enter text decodes newline, tab, carriage return, and backslash escapes", () => {
|
||||
@@ -71,6 +71,20 @@ test("shift enter submitted input detects single command text with a line ending
|
||||
assert.equal(getShiftEnterSubmittedInput("foo\nbar\n"), null);
|
||||
});
|
||||
|
||||
test("Alt+Enter ESC+CR is a non-submitting logical line break", () => {
|
||||
assert.equal(getShiftEnterSubmittedInput("\u001b\r"), null);
|
||||
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r"), null);
|
||||
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r\n"), null);
|
||||
assert.deepEqual(getShiftEnterSubmittedInput("echo hi\r"), {
|
||||
text: "echo hi",
|
||||
lineEnding: "\r",
|
||||
});
|
||||
assert.deepEqual(getShiftEnterSubmittedInput("\u001b\n"), {
|
||||
text: "\u001b",
|
||||
lineEnding: "\n",
|
||||
});
|
||||
});
|
||||
|
||||
test("shift enter handler only matches plain Shift+Enter keydown", () => {
|
||||
assert.equal(shouldSendShiftEnterText(keyEvent()), true);
|
||||
assert.equal(shouldSendShiftEnterText(keyEvent({ type: "keyup" })), false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TerminalSettings } from "../../../domain/models";
|
||||
|
||||
export const DEFAULT_SHIFT_ENTER_TEXT = "\\n";
|
||||
export const DEFAULT_SHIFT_ENTER_TEXT = "\\e\\r";
|
||||
|
||||
/** Kitty CSI-u encoding for Shift+Enter (keycode 13, modifier shift → 2). */
|
||||
export const SHIFT_ENTER_CSI_U_SEQUENCE = "\u001b[13;2u";
|
||||
@@ -109,10 +109,15 @@ export type ShiftEnterSubmittedInput = {
|
||||
lineEnding: "\r\n" | "\r" | "\n";
|
||||
};
|
||||
|
||||
/** ESC immediately before a terminating CR is the legacy Alt+Enter line break. */
|
||||
function isAltEnterLineBreak(text: string): boolean {
|
||||
return text.endsWith("\u001b\r") || text.endsWith("\u001b\r\n");
|
||||
}
|
||||
|
||||
export function getShiftEnterSubmittedInput(
|
||||
text: string,
|
||||
): ShiftEnterSubmittedInput | null {
|
||||
if (isShiftEnterLineContinuationText(text)) return null;
|
||||
if (isShiftEnterLineContinuationText(text) || isAltEnterLineBreak(text)) return null;
|
||||
const match = text.match(/^([^\r\n]*)(\r\n|\r|\n)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
|
||||
@@ -448,6 +448,68 @@ 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,6 +32,8 @@ type PasteDisplayState = {
|
||||
clearPending: number;
|
||||
pasteEchoFragments: string[];
|
||||
inPasteEchoActiveRegion: boolean;
|
||||
// Buffer type observed while preparing this chunk, before xterm applies it.
|
||||
preparedOnAlternateScreen: boolean;
|
||||
};
|
||||
|
||||
type PasteInputScrollState = {
|
||||
@@ -328,6 +330,7 @@ export function pasteTextIntoTerminal(
|
||||
clearPending: 0,
|
||||
pasteEchoFragments: getPasteEchoFragments(text),
|
||||
inPasteEchoActiveRegion: false,
|
||||
preparedOnAlternateScreen: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -521,10 +524,17 @@ 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);
|
||||
@@ -541,6 +551,16 @@ 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;
|
||||
|
||||
|
||||
@@ -448,6 +448,11 @@ export function shouldClaimDisconnectedDialogFocus({
|
||||
// Sink already focused.
|
||||
return active !== dialogNode;
|
||||
}
|
||||
// An open compose draft owns Enter: empty reconnects from the bar, and a
|
||||
// non-empty draft must keep send behavior (#3767). Do not park focus here.
|
||||
if (active.closest("[data-section='terminal-compose-bar']")) {
|
||||
return false;
|
||||
}
|
||||
const terminalRoot = resolveDisconnectedDialogTerminalRoot(dialogNode, sessionRoot);
|
||||
if (terminalRoot?.contains(active)) {
|
||||
return true;
|
||||
|
||||
@@ -229,9 +229,21 @@ 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);
|
||||
|
||||
@@ -98,6 +98,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
id: string;
|
||||
parent?: FakeNode | null;
|
||||
interactive?: boolean;
|
||||
composeBar?: boolean;
|
||||
children?: FakeNode[];
|
||||
},
|
||||
) {
|
||||
@@ -118,6 +119,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
return false;
|
||||
}
|
||||
closest(selector: string) {
|
||||
if (selector.includes("terminal-compose-bar") && this.opts.composeBar) return this;
|
||||
if (selector.includes("button") && this.opts.interactive) return this;
|
||||
return null;
|
||||
}
|
||||
@@ -227,6 +229,23 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Compose-bar focus stays put so an empty draft can reconnect and a
|
||||
// non-empty draft is not stolen by the disconnected dialog (#3767).
|
||||
const composeInput = new FakeNode({ id: "compose", composeBar: true });
|
||||
const composeDialog = new FakeNode({ id: "compose-dialog" });
|
||||
const composeSession = new FakeNode({
|
||||
id: "compose-session",
|
||||
children: [composeInput, composeDialog],
|
||||
});
|
||||
assert.equal(
|
||||
shouldClaimDisconnectedDialogFocus({
|
||||
activeElement: composeInput as unknown as Element,
|
||||
dialogNode: composeDialog as unknown as HTMLElement,
|
||||
sessionRoot: composeSession as unknown as Element,
|
||||
documentBody: body as unknown as Element,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
restoreTerminalFocusFromDisconnectedDialog({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { shouldProbeCommandCwd } from "./commandCwdProbe";
|
||||
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from "./commandCwdProbe";
|
||||
|
||||
test("probes command cwd for session restore even when the SFTP panel is not visible", () => {
|
||||
assert.equal(
|
||||
@@ -51,6 +52,73 @@ 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,3 +1,5 @@
|
||||
import { isTerminalSessionEligibleForSftpReuse } from "../../application/state/terminalConnectionReuse";
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { resolveHostFollowTerminalCwd, resolveSftpFollowTerminalCwdTargetHost } from "../../domain/sftpFollowTerminalCwd";
|
||||
|
||||
type FollowTerminalCwdHost = {
|
||||
@@ -12,6 +14,26 @@ 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,
|
||||
|
||||
@@ -30,6 +30,13 @@ import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/t
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
||||
import {
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
clearInactiveTopTabHover,
|
||||
paintInactiveTopTabHover,
|
||||
resolveTopTabChromeStyle,
|
||||
topTabActiveValue,
|
||||
} from './topTabActiveStyle';
|
||||
import { renderHostIconGlyph } from '../hostIconRenderer';
|
||||
import type { PluginViewTab } from '../../application/state/pluginViewTabStore';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
@@ -310,13 +317,19 @@ export const WindowControls: React.FC = memo(() => {
|
||||
|
||||
const controlClassName = 'window-control-btn app-no-drag';
|
||||
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
|
||||
// Mouse press would move DOM focus onto minimize/maximize. After restore
|
||||
// that leaves the terminal textarea unfocused, so keystrokes do nothing.
|
||||
// Close skips this so the active field still blurs (inline rename commits on blur).
|
||||
const preventWindowControlFocus: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-2 flex h-7 items-center overflow-visible app-no-drag">
|
||||
<button type="button" className={controlClassName} onClick={handleMinimize}>
|
||||
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMinimize}>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button type="button" className={controlClassName} onClick={handleMaximize}>
|
||||
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMaximize}>
|
||||
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
|
||||
</button>
|
||||
<button type="button" className={closeControlClassName} onClick={handleClose}>
|
||||
@@ -440,31 +453,20 @@ export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon,
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="root"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 overflow-hidden text-xs font-semibold cursor-pointer flex items-center app-no-drag transition-[padding,gap] duration-300 ease-out",
|
||||
compact ? "px-2 gap-0" : "px-3 gap-2",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive && !suppressActiveBg
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
style={resolveTopTabChromeStyle(isActive, { suppressActiveBg })}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{shortcutNumber != null ? <TabShortcutNumberBadge number={shortcutNumber} /> : icon}
|
||||
@@ -523,6 +525,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="plugin-view"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={() => activeTabStore.setActiveTabId(tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
|
||||
@@ -533,6 +536,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(event) => onTabDrop(event, tab.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
'netcatty-tab relative h-7 min-w-[140px] max-w-[240px] flex-shrink-0 cursor-pointer items-center justify-between gap-2 overflow-hidden rounded-t-md pl-3 pr-2 text-xs font-semibold app-no-drag',
|
||||
'flex transition-transform duration-150',
|
||||
isBeingDragged && isDraggingForReorder && 'scale-95 opacity-40',
|
||||
@@ -540,8 +544,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent',
|
||||
color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && <div className="absolute -left-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
|
||||
@@ -643,6 +646,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
@@ -653,6 +657,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, tabId)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -660,24 +665,13 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -827,6 +821,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-tab-id={session.id}
|
||||
data-tab-type="session"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
@@ -838,6 +833,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -845,24 +841,13 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -1039,6 +1024,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-id={workspace.id}
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -1051,6 +1037,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -1059,24 +1046,13 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -1197,6 +1173,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-tab-id={logView.id}
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
@@ -1207,6 +1184,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, logView.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -1214,24 +1192,13 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Stable hooks for the selected top tab.
|
||||
*
|
||||
* Defaults fall through to the existing chrome colors, so leaving the custom
|
||||
* properties unset keeps today's look. Custom CSS can set them on
|
||||
* `.top-tab[data-active="true"]` (or any ancestor) without !important.
|
||||
* `--top-tab-active-border` is read by index.css as a 1px inset outline and
|
||||
* defaults to transparent.
|
||||
*/
|
||||
|
||||
export const TOP_TAB_ITEM_CLASS = 'top-tab';
|
||||
|
||||
/** Previous selected fill, kept as the fallback when the hook is unset. */
|
||||
export const TOP_TAB_LEGACY_ACTIVE_BG = 'var(--top-tabs-active-bg, hsl(var(--background)))';
|
||||
|
||||
/** Previous selected label color, kept as the fallback when the hook is unset. */
|
||||
export const TOP_TAB_LEGACY_ACTIVE_FG = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
|
||||
export const TOP_TAB_ACTIVE_BG = `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_FG = `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_BORDER = 'var(--top-tab-active-border, transparent)';
|
||||
|
||||
export const TOP_TAB_INACTIVE_FG = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
|
||||
/** Same 40% mix as the previous hover fill. The inner color is the active-tab hook. */
|
||||
export const TOP_TAB_HOVER_BG = `color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_OUTLINE = `1px solid ${TOP_TAB_ACTIVE_BORDER}`;
|
||||
|
||||
export function topTabActiveValue(isActive: boolean): 'true' | 'false' {
|
||||
return isActive ? 'true' : 'false';
|
||||
}
|
||||
|
||||
export interface TopTabChromeStyle {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function resolveTopTabChromeStyle(
|
||||
isActive: boolean,
|
||||
options?: { suppressActiveBg?: boolean },
|
||||
): TopTabChromeStyle {
|
||||
if (!isActive) {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: TOP_TAB_INACTIVE_FG,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: options?.suppressActiveBg ? 'transparent' : TOP_TAB_ACTIVE_BG,
|
||||
color: TOP_TAB_ACTIVE_FG,
|
||||
};
|
||||
}
|
||||
|
||||
type TopTabStyleTarget = {
|
||||
style: {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function paintInactiveTopTabHover(element: TopTabStyleTarget): void {
|
||||
element.style.backgroundColor = TOP_TAB_HOVER_BG;
|
||||
element.style.color = TOP_TAB_ACTIVE_FG;
|
||||
}
|
||||
|
||||
export function clearInactiveTopTabHover(element: TopTabStyleTarget): void {
|
||||
element.style.backgroundColor = 'transparent';
|
||||
element.style.color = TOP_TAB_INACTIVE_FG;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import en from '../application/i18n/locales/en';
|
||||
import es from '../application/i18n/locales/es';
|
||||
import ru from '../application/i18n/locales/ru';
|
||||
import zhCN from '../application/i18n/locales/zh-CN';
|
||||
import zhTW from '../application/i18n/locales/zh-TW';
|
||||
import {
|
||||
TOP_TAB_ACTIVE_BG,
|
||||
TOP_TAB_ACTIVE_BORDER,
|
||||
TOP_TAB_ACTIVE_FG,
|
||||
TOP_TAB_ACTIVE_OUTLINE,
|
||||
TOP_TAB_HOVER_BG,
|
||||
TOP_TAB_INACTIVE_FG,
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
TOP_TAB_LEGACY_ACTIVE_BG,
|
||||
TOP_TAB_LEGACY_ACTIVE_FG,
|
||||
clearInactiveTopTabHover,
|
||||
paintInactiveTopTabHover,
|
||||
resolveTopTabChromeStyle,
|
||||
topTabActiveValue,
|
||||
} from './top-tabs/topTabActiveStyle';
|
||||
|
||||
const topTabItemsSource = readFileSync(
|
||||
fileURLToPath(new URL('./top-tabs/TopTabItems.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const indexCss = readFileSync(fileURLToPath(new URL('../index.css', import.meta.url)), 'utf8');
|
||||
|
||||
const TAB_TYPES = ['root', 'plugin-view', 'editor', 'session', 'workspace', 'logView'] as const;
|
||||
const LOCALES = [
|
||||
{ name: 'en', messages: en },
|
||||
{ name: 'zh-CN', messages: zhCN },
|
||||
{ name: 'zh-TW', messages: zhTW },
|
||||
{ name: 'es', messages: es },
|
||||
{ name: 'ru', messages: ru },
|
||||
];
|
||||
const HOOKS = ['--top-tab-active-bg', '--top-tab-active-fg', '--top-tab-active-border'] as const;
|
||||
|
||||
test('active top tab colors fall back to the current chrome colors', () => {
|
||||
assert.equal(TOP_TAB_ACTIVE_BG, `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`);
|
||||
assert.equal(TOP_TAB_ACTIVE_FG, `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`);
|
||||
assert.equal(TOP_TAB_ACTIVE_BORDER, 'var(--top-tab-active-border, transparent)');
|
||||
assert.equal(TOP_TAB_LEGACY_ACTIVE_BG, 'var(--top-tabs-active-bg, hsl(var(--background)))');
|
||||
assert.equal(TOP_TAB_LEGACY_ACTIVE_FG, 'var(--top-tabs-fg, hsl(var(--foreground)))');
|
||||
assert.equal(
|
||||
TOP_TAB_HOVER_BG,
|
||||
`color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`,
|
||||
);
|
||||
|
||||
const active = resolveTopTabChromeStyle(true);
|
||||
assert.deepEqual(active, {
|
||||
backgroundColor: TOP_TAB_ACTIVE_BG,
|
||||
color: TOP_TAB_ACTIVE_FG,
|
||||
});
|
||||
|
||||
const inactive = resolveTopTabChromeStyle(false);
|
||||
assert.deepEqual(inactive, {
|
||||
backgroundColor: 'transparent',
|
||||
color: TOP_TAB_INACTIVE_FG,
|
||||
});
|
||||
assert.equal(TOP_TAB_INACTIVE_FG, 'var(--top-tabs-muted, hsl(var(--muted-foreground)))');
|
||||
|
||||
const vault = resolveTopTabChromeStyle(true, { suppressActiveBg: true });
|
||||
assert.equal(vault.backgroundColor, 'transparent');
|
||||
assert.equal(vault.color, TOP_TAB_ACTIVE_FG);
|
||||
});
|
||||
|
||||
test('top tab active attribute is a stable true/false hook', () => {
|
||||
assert.equal(topTabActiveValue(true), 'true');
|
||||
assert.equal(topTabActiveValue(false), 'false');
|
||||
assert.equal(TOP_TAB_ITEM_CLASS, 'top-tab');
|
||||
});
|
||||
|
||||
test('inactive hover uses the active-tab hook and clears back to the flat tab', () => {
|
||||
const element = { style: { backgroundColor: '', color: '' } };
|
||||
paintInactiveTopTabHover(element);
|
||||
assert.equal(element.style.backgroundColor, TOP_TAB_HOVER_BG);
|
||||
assert.equal(element.style.color, TOP_TAB_ACTIVE_FG);
|
||||
clearInactiveTopTabHover(element);
|
||||
assert.equal(element.style.backgroundColor, 'transparent');
|
||||
assert.equal(element.style.color, TOP_TAB_INACTIVE_FG);
|
||||
});
|
||||
|
||||
test('every top tab kind exposes the active style hook', () => {
|
||||
for (const type of TAB_TYPES) {
|
||||
const marker = `data-tab-type="${type}"`;
|
||||
const start = topTabItemsSource.indexOf(marker);
|
||||
assert.notEqual(start, -1, marker);
|
||||
const next = topTabItemsSource.indexOf('data-tab-type=', start + marker.length);
|
||||
const slice = topTabItemsSource.slice(start, next === -1 ? undefined : next);
|
||||
assert.match(slice, /data-active=\{topTabActiveValue\(isActive\)\}/);
|
||||
assert.match(slice, /TOP_TAB_ITEM_CLASS/);
|
||||
assert.match(slice, /resolveTopTabChromeStyle\(isActive/);
|
||||
}
|
||||
|
||||
assert.equal(topTabItemsSource.split('data-active={topTabActiveValue(isActive)}').length - 1, TAB_TYPES.length);
|
||||
assert.doesNotMatch(topTabItemsSource, /backgroundColor: isActive/);
|
||||
});
|
||||
|
||||
test('selected top tab border hook defaults to a transparent inset outline', () => {
|
||||
const start = indexCss.indexOf('[data-section="top-tabs"] .top-tab[data-active="true"]');
|
||||
assert.notEqual(start, -1);
|
||||
const rule = indexCss.slice(start, indexCss.indexOf('}', start));
|
||||
assert.match(rule, /outline:\s*1px solid var\(--top-tab-active-border, transparent\)/);
|
||||
assert.match(rule, /outline-offset:\s*-1px/);
|
||||
assert.equal(TOP_TAB_ACTIVE_OUTLINE, '1px solid var(--top-tab-active-border, transparent)');
|
||||
});
|
||||
|
||||
test('custom CSS sets the top tab hooks on the selected tab', () => {
|
||||
const dom = new JSDOM(`<!doctype html><html><head></head><body>
|
||||
<div data-section="top-tabs">
|
||||
<div id="active" class="top-tab" data-active="true"></div>
|
||||
<div id="idle" class="top-tab" data-active="false"></div>
|
||||
</div>
|
||||
</body></html>`);
|
||||
const { document, getComputedStyle } = dom.window;
|
||||
const active = document.getElementById('active')!;
|
||||
const idle = document.getElementById('idle')!;
|
||||
const activeStyle = resolveTopTabChromeStyle(true);
|
||||
active.style.backgroundColor = activeStyle.backgroundColor;
|
||||
active.style.color = activeStyle.color;
|
||||
Object.assign(idle.style, resolveTopTabChromeStyle(false));
|
||||
|
||||
assert.equal(active.getAttribute('data-active'), 'true');
|
||||
assert.equal(active.style.backgroundColor, TOP_TAB_ACTIVE_BG);
|
||||
assert.equal(active.style.color, TOP_TAB_ACTIVE_FG);
|
||||
assert.equal(idle.style.backgroundColor, 'transparent');
|
||||
assert.equal(getComputedStyle(active).getPropertyValue('--top-tab-active-bg').trim(), '');
|
||||
|
||||
const custom = document.createElement('style');
|
||||
custom.id = 'netcatty-custom-css';
|
||||
custom.textContent = `
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: rgb(37, 99, 235);
|
||||
--top-tab-active-fg: rgb(255, 255, 255);
|
||||
--top-tab-active-border: rgb(29, 78, 216);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(custom);
|
||||
|
||||
const styled = getComputedStyle(active);
|
||||
const normalized = (value: string) => value.replace(/\s+/g, '');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-bg')), 'rgb(37,99,235)');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-fg')), 'rgb(255,255,255)');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-border')), 'rgb(29,78,216)');
|
||||
assert.equal(getComputedStyle(idle).getPropertyValue('--top-tab-active-bg').trim(), '');
|
||||
assert.match(active.style.backgroundColor, /var\(--top-tab-active-bg,/);
|
||||
});
|
||||
|
||||
test('custom CSS help mentions the top tab hooks in every locale', () => {
|
||||
for (const locale of LOCALES) {
|
||||
const desc = locale.messages['settings.appearance.customCss.desc'];
|
||||
const help = locale.messages['settings.appearance.customCss.help.body'];
|
||||
const placeholder = locale.messages['settings.appearance.customCss.placeholder'];
|
||||
assert.equal(typeof desc, 'string', locale.name);
|
||||
assert.equal(typeof help, 'string', locale.name);
|
||||
assert.equal(typeof placeholder, 'string', locale.name);
|
||||
for (const hook of HOOKS) {
|
||||
assert.ok(desc.includes(hook), `${locale.name} description missing ${hook}`);
|
||||
assert.ok(help.includes(hook), `${locale.name} help missing ${hook}`);
|
||||
assert.ok(placeholder.includes(hook), `${locale.name} placeholder missing ${hook}`);
|
||||
}
|
||||
assert.match(help, /\.top-tab\[data-active="true"\]/);
|
||||
assert.match(placeholder, /\.top-tab\[data-active="true"\]/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
# Agent Note: Keep port forwarding usable when host OS metadata is missing
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
[Issue #3748](https://github.com/binaricat/Netcatty/issues/3748) reports that the
|
||||
port-forwarding page displays `Port forwarding could not load.` when rendering
|
||||
a selected host whose legacy `os` value is absent, null, or an empty string.
|
||||
|
||||
`EditPanel`, `NewFormPanel`, and both host-selection steps in `WizardContent`
|
||||
evaluate `selectedHost.os[0].toUpperCase()` while constructing `DistroAvatar`
|
||||
props. Missing or null values fail on indexing; an empty string fails on
|
||||
`toUpperCase`. This happens before the avatar can render its generic icon.
|
||||
Although `Host.os` is required by TypeScript, persisted runtime records can lack
|
||||
that metadata. The original source of the affected records is not established.
|
||||
|
||||
## Decision
|
||||
|
||||
The four call sites use `(selectedHost.os || 'unknown').charAt(0).toUpperCase()`
|
||||
to keep the required string fallback safe without changing the shared avatar
|
||||
API. Valid OS values retain their existing initials. Missing and empty values
|
||||
use the explicit unknown fallback, and the avatar keeps its existing generic
|
||||
icon behavior.
|
||||
|
||||
The fix stays in the affected presentation code. It does not change host data,
|
||||
normalization, persistence, or tunnel lifecycle behavior. A shared helper for a
|
||||
single short expression would add indirection without a separate responsibility,
|
||||
so the four call sites use the same expression directly.
|
||||
|
||||
## Validation
|
||||
|
||||
`components/PortForwardingPanels.test.tsx` renders the real components with
|
||||
`react-dom/server`, the existing i18n provider, and the tooltip provider. It does
|
||||
not mock the panels or avatar and does not inspect their source text.
|
||||
|
||||
The table-driven cases cover all four entry points, all three forwarding types,
|
||||
and missing, undefined, null, empty, Linux, Windows, and macOS values. Separate
|
||||
checks preserve distro icons and host-picker behavior when no host is selected
|
||||
or the selected host is no longer available. Invalid legacy fixture shapes use
|
||||
one explicit type assertion rather than weakening the production `Host` type.
|
||||
|
||||
Before the fix, 48 of the 96 new cases fail with the reported TypeError; the
|
||||
other 48 pass. After the fix, all 96 pass. Together with the existing avatar,
|
||||
port-forwarding toolbar, and utility tests, the focused run passes 109 tests:
|
||||
|
||||
```sh
|
||||
node --test --import tsx components/PortForwardingPanels.test.tsx \
|
||||
components/PortForwardingNew.test.ts components/portForwardingUtils.test.ts \
|
||||
components/DistroAvatar.test.tsx --test-concurrency=2
|
||||
```
|
||||
|
||||
These are rendering regression tests, not SSH or network end-to-end tests.
|
||||
|
||||
Additional checks against upstream commit `786a317`:
|
||||
|
||||
- `npm run build` succeeds.
|
||||
- `npm run lint` succeeds with the same eight existing warnings as upstream.
|
||||
The four changed TypeScript/TSX files also pass lint with `--max-warnings=0`.
|
||||
- `LC_ALL=C npm test -- --test-concurrency=1` runs 13,986 tests: 13,944 pass,
|
||||
four fail, and 38 are skipped. An unmodified upstream worktree runs 13,890
|
||||
tests with the same four failures and 38 skips, so this change adds 96 passing
|
||||
cases without adding a failing case. The baseline failures are the SFTP
|
||||
large-directory persistence timing test, two Bash OSC 7 setup tests, and the
|
||||
README clipboard-paste screenshot-dimension assertion. The SFTP timing test
|
||||
passes in isolated runs on both checkouts but fails in these full-suite runs.
|
||||
A non-English locale additionally causes the existing fish error-message
|
||||
assertion to fail; both comparison runs use the C locale.
|
||||
- The full-project `tsc --noEmit --pretty false` check has 759 existing errors.
|
||||
Its complete output matches the unmodified upstream output after normalizing
|
||||
checkout paths; none of the changed files has a diagnostic. This is not a
|
||||
claim that full-project type checking passes.
|
||||
|
||||
### Upstream CI follow-up
|
||||
|
||||
The original PR CI run [39215269619, attempt 2](https://github.com/binaricat/Netcatty/actions/runs/39215269619/attempts/2)
|
||||
passes all 96 new regression cases and fails only the existing README paste
|
||||
assertion. Upstream commit `b366967` fixes that assertion by removing its
|
||||
coupling to changing screenshot dimensions. The branch incorporates this fix
|
||||
through a normal merge of upstream `main` at `42b8e2f`, without rewriting the
|
||||
original fix commit or adding an independent change to the unrelated test.
|
||||
|
||||
After synchronization, the focused run including the entire clipboard-paste
|
||||
test file passes 149 tests. Lint and the production build also pass; lint keeps
|
||||
the same eight existing warnings. The earlier full-suite and type-check
|
||||
comparison above describes the original `786a317` baseline, not the updated
|
||||
base. Current remote CI results are tracked on the PR.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Remove the unused fallback prop from the four calls: the shared avatar still
|
||||
requires it in its TypeScript contract. Changing that API and cleaning up all
|
||||
callers is unnecessary for this targeted bug fix.
|
||||
- Normalize or rewrite stored host records: broadens the persistence scope,
|
||||
obscures the rendering assumption, and cannot replace guarding optional
|
||||
runtime metadata at the point of use.
|
||||
- Add a new avatar wrapper or fallback utility: unnecessary for this small
|
||||
expression and would expand the production change without improving the
|
||||
boundary being fixed.
|
||||
|
||||
## Consequences
|
||||
|
||||
Incomplete host OS metadata no longer prevents new/edit panels or wizard
|
||||
host-selection steps from rendering. Existing icons, component contracts, and
|
||||
host records remain unchanged. The guard is local to port forwarding; auditing
|
||||
unrelated callers or redesigning the unused fallback API remains out of scope.
|
||||
@@ -0,0 +1,54 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export const EXTERNAL_MCP_HTTP_BEARER_ENV = "NETCATTY_MCP_BEARER_TOKEN";
|
||||
export const DEFAULT_EXTERNAL_MCP_HTTP_PORT = 47391;
|
||||
export const DEFAULT_EXTERNAL_MCP_HTTP_HOST = "127.0.0.1";
|
||||
export const EXTERNAL_MCP_HTTP_PATH = "/mcp";
|
||||
|
||||
function formatHostForUrl(host: string): string {
|
||||
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
|
||||
return host;
|
||||
}
|
||||
|
||||
function isWildcardBind(address: string): boolean {
|
||||
return address === "0.0.0.0" || address === "::";
|
||||
}
|
||||
|
||||
export function buildExternalMcpHttpUrl(listenAddress: string, port: number, clientHost = ""): string {
|
||||
const host = isWildcardBind(listenAddress) ? clientHost.trim() : listenAddress;
|
||||
if (!host || isWildcardBind(host)) return "";
|
||||
return `http://${formatHostForUrl(host)}:${port}${EXTERNAL_MCP_HTTP_PATH}`;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function buildCodexHttpToml(url: string): string {
|
||||
return [
|
||||
"[mcp_servers.netcatty-http]",
|
||||
`url = ${JSON.stringify(url)}`,
|
||||
`bearer_token_env_var = ${JSON.stringify(EXTERNAL_MCP_HTTP_BEARER_ENV)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildCodexHttpEnvLine(token: string): string {
|
||||
return `export ${EXTERNAL_MCP_HTTP_BEARER_ENV}=${shellQuote(token)}`;
|
||||
}
|
||||
|
||||
export function buildClaudeHttpAddCommand(url: string, token: string): string {
|
||||
return [
|
||||
"claude mcp add --transport http netcatty-http",
|
||||
shellQuote(url),
|
||||
"--header",
|
||||
shellQuote(`Authorization: Bearer ${token}`),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildGenericHttpMcpJson(url: string, token: string): string {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
"netcatty-http": {
|
||||
type: "http",
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
}
|
||||
@@ -595,6 +595,58 @@ test("applyGroupDefaults keeps host-level skipEcdsaHostKey instead of group defa
|
||||
assert.equal(result.skipEcdsaHostKey, false);
|
||||
});
|
||||
|
||||
test("resolveGroupDefaults uses the nearest group X11 toggle and stays off otherwise", () => {
|
||||
const configs: GroupConfig[] = [
|
||||
{ path: "prod", x11Forwarding: true },
|
||||
{ path: "prod/edge", x11Forwarding: false },
|
||||
];
|
||||
assert.equal(resolveGroupDefaults("prod", configs).x11Forwarding, true);
|
||||
assert.equal(resolveGroupDefaults("prod/web", configs).x11Forwarding, true);
|
||||
assert.equal(resolveGroupDefaults("prod/edge", configs).x11Forwarding, false);
|
||||
assert.equal(resolveGroupDefaults("prod/edge/api", configs).x11Forwarding, false);
|
||||
assert.equal(resolveGroupDefaults("other", configs).x11Forwarding, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults inherits group X11 forwarding when the host leaves it unset", () => {
|
||||
assert.equal(applyGroupDefaults(host(), { x11Forwarding: true }).x11Forwarding, true);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults keeps an explicit host X11 toggle ahead of the group", () => {
|
||||
assert.equal(
|
||||
applyGroupDefaults(host({ x11Forwarding: false }), { x11Forwarding: true }).x11Forwarding,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
applyGroupDefaults(host({ x11Forwarding: true }), { x11Forwarding: false }).x11Forwarding,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults leaves X11 forwarding off when neither host nor group sets it", () => {
|
||||
assert.equal(applyGroupDefaults(host(), {}).x11Forwarding, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults does not inherit X11 forwarding onto non-SSH transports", () => {
|
||||
const groupDefaults = { x11Forwarding: true as const };
|
||||
assert.equal(applyGroupDefaults(host({ moshEnabled: true }), groupDefaults).x11Forwarding, undefined);
|
||||
assert.equal(applyGroupDefaults(host({ etEnabled: true }), groupDefaults).x11Forwarding, undefined);
|
||||
assert.equal(applyGroupDefaults(host({ protocol: "telnet" }), groupDefaults).x11Forwarding, undefined);
|
||||
assert.equal(applyGroupDefaults(host({ protocol: "serial" }), groupDefaults).x11Forwarding, undefined);
|
||||
assert.equal(applyGroupDefaults(host({ protocol: "local" }), groupDefaults).x11Forwarding, undefined);
|
||||
assert.equal(
|
||||
applyGroupDefaults(host(), { ...groupDefaults, moshEnabled: true }).x11Forwarding,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
applyGroupDefaults(host(), { ...groupDefaults, protocol: "telnet" }).x11Forwarding,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
applyGroupDefaults(host({ moshEnabled: false }), { ...groupDefaults, moshEnabled: true }).x11Forwarding,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults inherits algorithm overrides from the group", () => {
|
||||
const overrides = { serverHostKey: ["ssh-rsa", "ssh-dss"] };
|
||||
const result = applyGroupDefaults(host(), { algorithms: overrides });
|
||||
@@ -678,6 +730,7 @@ test("applyGroupDefaults does not inherit SSH transport/input defaults into loca
|
||||
legacyAlgorithms: true,
|
||||
skipEcdsaHostKey: true,
|
||||
agentForwarding: true,
|
||||
x11Forwarding: true,
|
||||
hostChain: { hostIds: ["bastion-1"] },
|
||||
charset: "UTF-8",
|
||||
fontSize: 14,
|
||||
@@ -702,6 +755,7 @@ test("applyGroupDefaults does not inherit SSH transport/input defaults into loca
|
||||
assert.equal(result.legacyAlgorithms, undefined);
|
||||
assert.equal(result.skipEcdsaHostKey, undefined);
|
||||
assert.equal(result.agentForwarding, undefined);
|
||||
assert.equal(result.x11Forwarding, undefined);
|
||||
assert.equal(result.hostChain, undefined);
|
||||
// Display/appearance defaults still apply to local shells.
|
||||
assert.equal(result.charset, "UTF-8");
|
||||
|
||||
+11
-2
@@ -149,7 +149,7 @@ export function resolveGroupDefaults(
|
||||
|
||||
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
|
||||
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
|
||||
'port', 'protocol', 'deviceType', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
|
||||
'port', 'protocol', 'deviceType', 'agentForwarding', 'x11Forwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
|
||||
'legacyAlgorithms', 'skipEcdsaHostKey', 'algorithms',
|
||||
'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
|
||||
'etEnabled', 'etPort',
|
||||
@@ -178,12 +178,13 @@ const SSH_CREDENTIAL_KEYS = new Set<keyof GroupConfig>([
|
||||
|
||||
// SSH transport/input settings the local-shell editor does not expose: input
|
||||
// remapping (backspace), Mosh/Eternal-Terminal transports, and SSH-only
|
||||
// algorithm/agent/chain flags. A local shell never runs through SSH, so these
|
||||
// algorithm/agent/X11/chain flags. A local shell never runs through SSH, so these
|
||||
// defaults would either remap local PTY input (`backspaceBehavior`,
|
||||
// createXTermRuntime) or make flag-first consumers report a purely local
|
||||
// session as Mosh/ET — with no way to override them in the local editor.
|
||||
const SSH_ONLY_TRANSPORT_KEYS = new Set<keyof GroupConfig>([
|
||||
'agentForwarding',
|
||||
'x11Forwarding',
|
||||
'legacyAlgorithms',
|
||||
'skipEcdsaHostKey',
|
||||
'algorithms',
|
||||
@@ -246,11 +247,19 @@ export function applyGroupDefaults(
|
||||
// local CMD/PowerShell/POSIX shell be treated as a network-device CLI
|
||||
// (no normal autocomplete, network semantics for AI/command execution).
|
||||
const isLocalShellHost = host.protocol === 'local';
|
||||
// X11 forwarding only applies to plain SSH. Use the host value when it is
|
||||
// set, otherwise the group transport, so a group Mosh/ET/Telnet default
|
||||
// does not also turn X11 on.
|
||||
const effectiveProtocol = host.protocol ?? groupDefaults.protocol;
|
||||
const inheritsX11Forwarding = (!effectiveProtocol || effectiveProtocol === 'ssh')
|
||||
&& !(host.moshEnabled ?? groupDefaults.moshEnabled)
|
||||
&& !(host.etEnabled ?? groupDefaults.etEnabled);
|
||||
|
||||
for (const key of INHERITABLE_KEYS) {
|
||||
if (shouldSkipGroupSshCredentialBundle && SSH_CREDENTIAL_KEYS.has(key)) continue;
|
||||
if (isSerialHost && SSH_CREDENTIAL_KEYS.has(key)) continue;
|
||||
if (isLocalShellHost && (SSH_CREDENTIAL_KEYS.has(key) || SSH_ONLY_TRANSPORT_KEYS.has(key) || key === 'startupCommand' || key === 'deviceType')) continue;
|
||||
if (key === 'x11Forwarding' && !inheritsX11Forwarding) continue;
|
||||
if (key === 'password' && effective.savePassword === false) continue;
|
||||
if (key === 'telnetIdentityId' && hostHasManualTelnetCredentials) continue;
|
||||
if (key === 'proxyProfileId') {
|
||||
|
||||
@@ -427,6 +427,7 @@ export interface GroupConfig {
|
||||
protocol?: 'ssh' | 'telnet';
|
||||
deviceType?: 'general' | 'network';
|
||||
agentForwarding?: boolean;
|
||||
x11Forwarding?: boolean;
|
||||
proxyProfileId?: string;
|
||||
proxyConfig?: ProxyConfig;
|
||||
hostChain?: HostChainConfig;
|
||||
|
||||
@@ -522,7 +522,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
altAsMeta: false,
|
||||
optionArrowWordJump: false,
|
||||
shiftEnterNewlineEnabled: true,
|
||||
shiftEnterNewlineText: '\\n',
|
||||
shiftEnterNewlineText: '\\e\\r',
|
||||
shiftEnterForceText: false,
|
||||
kittyKeyboardProtocolEnabled: false,
|
||||
scrollOnInput: true,
|
||||
|
||||
@@ -714,6 +714,24 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
labelKey: "ai.externalMcp.title",
|
||||
keywords: ["mcp"],
|
||||
},
|
||||
{
|
||||
id: "ai-external-mcp-http",
|
||||
tab: "ai",
|
||||
aiSubTab: "tools",
|
||||
labelKey: "ai.externalMcp.http.title",
|
||||
descriptionKey: "ai.externalMcp.http.description",
|
||||
sectionKey: "ai.externalMcp.title",
|
||||
keywords: ["http", "sse", "streamable", "wsl", "codex"],
|
||||
},
|
||||
{
|
||||
id: "ai-external-mcp-approval-alert",
|
||||
tab: "ai",
|
||||
aiSubTab: "tools",
|
||||
labelKey: "ai.externalMcp.approvalAlert",
|
||||
descriptionKey: "ai.externalMcp.approvalAlert.description",
|
||||
sectionKey: "ai.externalMcp.title",
|
||||
keywords: ["notification", "approval", "dock"],
|
||||
},
|
||||
{
|
||||
id: "ai-user-skills",
|
||||
tab: "ai",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "./sftpDedicatedLogin";
|
||||
|
||||
test("network-device mode forces a dedicated SFTP login before any banner", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "SSH-2.0-OpenSSH_for_Windows_9.5",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Huawei dash and Ruijie banners force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "SSH-1.99--", "RGOS_SSH", "SSH-2.0-RGOS_SSH"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
// Huawei `-` must not become a global single-channel bastion token.
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("other network-vendor banners force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of [
|
||||
"SSH-2.0-Cisco-1.25",
|
||||
"SSH-2.0-H3C-Comware-7.1.064",
|
||||
"SSH-2.0-ROSSSH",
|
||||
]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("one-channel bastion banners still force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of [
|
||||
"BHostSSH_7.0",
|
||||
"SSH-2.0-TERM-SSHD",
|
||||
"CLOUDBILITY-4.14",
|
||||
"SSH-2.0-CloudLinker",
|
||||
]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary SSH servers keep the shared terminal SFTP transport", () => {
|
||||
assert.equal(sftpRequiresDedicatedLogin({ host: { deviceType: "general" } }), false);
|
||||
assert.equal(sftpRequiresDedicatedLogin({ host: null }), false);
|
||||
assert.equal(sftpRequiresDedicatedLogin({}), false);
|
||||
for (const remoteSshVersion of ["OpenSSH_9.6", "SSH-2.0-OpenSSH_9.6", "JumpServer", ""]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
false,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { detectVendorFromSshVersion, hostRestrictsExtraSshChannels } from "./host";
|
||||
import type { Host } from "./models";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
|
||||
export type SftpDedicatedLoginInput = {
|
||||
host?: Pick<Host, "deviceType"> | null;
|
||||
remoteSshVersion?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* SFTP must dial its own SSH login when an extra subsystem on the terminal
|
||||
* TCP session would drop the shell (#3631).
|
||||
*
|
||||
* Network-device mode is enough on its own, with or without a banner.
|
||||
* A network-vendor identification string (Huawei `-` / `SSH-2.0--`, Ruijie
|
||||
* RGOS, and the other `detectVendorFromSshVersion` matches) and one-channel
|
||||
* bastion software tokens are included here. This does not stamp
|
||||
* session.singleChannelSsh; that flag stays reserved for bastion banners.
|
||||
*/
|
||||
export function sftpRequiresDedicatedLogin(input: SftpDedicatedLoginInput): boolean {
|
||||
if (hostRestrictsExtraSshChannels(input.host)) return true;
|
||||
const remoteSshVersion = input.remoteSshVersion || undefined;
|
||||
return remoteSoftwareRequiresSingleChannel(remoteSshVersion)
|
||||
|| detectVendorFromSshVersion(remoteSshVersion) !== "";
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import type { GroupConfig, Host, ProxyProfile, TerminalSession } from "./models";
|
||||
import {
|
||||
resolveEffectiveTerminalHost,
|
||||
resolveTerminalChainHosts,
|
||||
resolveTerminalSessionHost,
|
||||
} from "./terminalHostResolution";
|
||||
@@ -84,6 +85,45 @@ test("resolveTerminalSessionHost applies group default proxy profiles before ope
|
||||
assert.deepEqual(resolved.proxyConfig, proxyProfiles[0].config);
|
||||
});
|
||||
|
||||
test("resolveEffectiveTerminalHost applies the nearest group X11 default on the connect path", () => {
|
||||
const host: Host = {
|
||||
id: "target",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
group: "prod/web",
|
||||
};
|
||||
const groupConfigs: GroupConfig[] = [
|
||||
{ path: "prod", x11Forwarding: true },
|
||||
{ path: "prod/edge", x11Forwarding: false },
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolveEffectiveTerminalHost({ host, groupConfigs, proxyProfiles: [] }).x11Forwarding,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveTerminalHost({
|
||||
host: { ...host, x11Forwarding: false },
|
||||
groupConfigs,
|
||||
proxyProfiles: [],
|
||||
}).x11Forwarding,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveTerminalHost({
|
||||
host: { ...host, group: "prod/edge/api" },
|
||||
groupConfigs,
|
||||
proxyProfiles: [],
|
||||
}).x11Forwarding,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost defaults missing saved remote sessions to SSH", () => {
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session: {
|
||||
|
||||
@@ -93,7 +93,7 @@ test("normalizeTerminalSettings enables Shift+Enter newline by default", () => {
|
||||
const settings = normalizeTerminalSettings();
|
||||
|
||||
assert.equal(settings.shiftEnterNewlineEnabled, true);
|
||||
assert.equal(settings.shiftEnterNewlineText, "\\n");
|
||||
assert.equal(settings.shiftEnterNewlineText, "\\e\\r");
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings disables Kitty keyboard protocol by default", () => {
|
||||
@@ -114,7 +114,7 @@ test("normalizeTerminalSettings preserves Shift+Enter text", () => {
|
||||
test("normalizeTerminalSettings falls back when Shift+Enter text is not a string", () => {
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ shiftEnterNewlineText: null as never }).shiftEnterNewlineText,
|
||||
"\\n",
|
||||
"\\e\\r",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user