Compare commits

...
Author SHA1 Message Date
陈大猫 40541bd0b1 fix: bracket multiline compose submissions when supported
Use shell-advertised bracketed paste for compose command blocks even when clipboard bracketing is disabled. Keep the final Enter outside the paste and preserve snippet preferences and compose target routing.

Fixes #4062
2026-11-06 21:30:33 +08:00
7 changed files with 168 additions and 4 deletions

No files matched your search

+7 -1
View File
@@ -3536,6 +3536,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
options?: {
broadcast?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
/** Submit compose blocks through the shell's advertised paste support. */
preferBracketedPaste?: boolean;
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
focus?: boolean;
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
@@ -3584,7 +3586,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
// Compose submits a command block, so keep it in the shell's input buffer
// even when clipboard bracketing is disabled. Otherwise a running command
// can consume the later lines as stdin (#4062). Still require shell support.
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode
&& (options?.preferBracketedPaste || !disableBracketedPasteRef.current)) {
data = wrapBracketedPaste(data);
}
if (!noAutoRun) data = `${data}\r`;
+5 -1
View File
@@ -2499,6 +2499,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (executor) {
pendingSends.push(Promise.resolve(executor(text, false, {
broadcast: false,
preferBracketedPaste: true,
// Preserve both prompt snapshots across an asynchronous wake: the
// peer may clear its prompt classification before the actual write.
...(focusedSensitive || recipientSensitive ? { sensitive: true } : {}),
@@ -2538,7 +2539,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sensitive = isTerminalSensitiveInputActive(targetId);
// Explicitly local: an omitted broadcast option falls back to the
// toolbar switch, which can still be on while compose is active-only.
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
pendingSends.push(Promise.resolve(executor(text, false, {
broadcast: false,
preferBracketedPaste: true,
})).then(
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
));
} else {
+1
View File
@@ -1446,6 +1446,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
let anyRecipientSensitive = false;
let broadcastRecipientIds: readonly string[] = [];
const sent = await executeSnippetCommand(text, false, {
preferBracketedPaste: true,
// Explicit target: active-only stays local while toolbar
// broadcast (keystrokes) can remain on, and send-to-all
// still fans out when the toolbar switch is off.
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { stripVTControlCharacters } from 'node:util';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import { normalizeLineEndings, wrapBracketedPaste } from '../../../lib/utils';
import { AUTO_RUN_SNIPPET_LINE_DELAY_MS, shouldDelayAutoRunSnippetInput } from '../terminalHelpers';
// Exercise the shared production sender without mounting an Electron terminal.
const source = readFileSync(new URL('../../Terminal.tsx', import.meta.url), 'utf8');
const start = source.indexOf(' const executeSnippetCommand = useCallback(');
const end = source.indexOf(' const executeSnippet = useCallback(', start);
assert.ok(start >= 0 && end > start);
const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send = executeSnippetCommand;', {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
type SendOptions = { preferBracketedPaste?: boolean; broadcast?: boolean; multiLineRunMode?: 'paste' | 'lineDelay' };
function setup(bracketedPasteMode = true, disableBracketedPaste = true) {
const writes: Array<{ id: string; data: string; lineDelayMs?: number }> = [];
const broadcasts: string[] = [];
const context = {
send: undefined as unknown as (command: string, noAutoRun?: boolean, options?: SendOptions) => Promise<boolean>,
useCallback: (callback: unknown) => callback,
termRef: { current: { modes: { bracketedPasteMode }, focus() {} } },
sessionRef: { current: 'source' }, sessionId: 'source', hibernatedRef: { current: false },
passwordPromptActiveRef: { current: false }, broadcastPasswordBypassRef: { current: false },
isBroadcastEnabledRef: { current: false },
shouldBroadcastDuringSensitivePrompt: () => true,
isPromptSecurityAlternateScreenActive: () => false,
onBroadcastInputRef: { current: (data: string) => { broadcasts.push(data); return ['peer']; } },
normalizeLineEndings, wrapBracketedPaste, shouldDelayAutoRunSnippetInput, AUTO_RUN_SNIPPET_LINE_DELAY_MS,
disableBracketedPasteRef: { current: disableBracketedPaste },
prepareProgrammaticSudoInput: (data: string) => data,
scrollToBottomAfterProgrammaticInput() {}, markTerminalBroadcastUserInput() {},
terminalBackend: { writeToSession: (id: string, data: string, options: { lineDelayMs?: number }) => {
writes.push({ id, data, lineDelayMs: options.lineDelayMs });
} },
host: { protocol: 'ssh' }, serialConfig: { lineMode: false },
};
runInNewContext(code, context);
return { send: context.send, writes, broadcasts };
}
test('compose brackets multiline commands before the final Enter even when clipboard bracketing is disabled', async () => {
const { send, writes, broadcasts } = setup();
assert.equal(await send('echo one\r\necho two\necho three', false, {
preferBracketedPaste: true, broadcast: true,
}), true);
assert.equal(writes[0].data, '\x1b[200~echo one\necho two\necho three\x1b[201~\r');
assert.equal(writes[0].lineDelayMs, undefined);
assert.deepEqual(broadcasts, [writes[0].data]);
});
test('single-line compose and snippet paste/line-delay preferences stay unchanged', async () => {
const { send, writes } = setup();
await send('echo one', false, { preferBracketedPaste: true });
await send('echo one\necho two', false);
await send('echo one\necho two', true);
await send('echo one\necho two', false, { multiLineRunMode: 'lineDelay' });
assert.deepEqual(writes.map(({ data, lineDelayMs }) => ({ data, lineDelayMs })), [
{ data: 'echo one\r', lineDelayMs: undefined },
{ data: 'echo one\necho two\r', lineDelayMs: undefined },
{ data: 'echo one\necho two', lineDelayMs: undefined },
{ data: 'echo one\necho two\r', lineDelayMs: 250 },
]);
const unsupported = setup(false);
await unsupported.send('echo one\necho two', false, { preferBracketedPaste: true });
assert.equal(unsupported.writes[0].data.includes('\x1b[200~'), false);
const enabled = setup(true, false);
await enabled.send('echo one\necho two', false);
assert.equal(enabled.writes[0].data, '\x1b[200~echo one\necho two\x1b[201~\r');
});
// Python's standard-library PTY lets Bash/Readline exercise the actual terminal
// bytes without depending on node-pty's Electron-specific native ABI.
const bashPty = String.raw`
import json, os, pty, select, signal, sys, time
payload = json.load(sys.stdin)
pid, fd = pty.fork()
if pid == 0:
os.execvpe('bash', ['bash', '--noprofile', '--norc', '-i'],
dict(os.environ, PS1='__COMPOSE_PROMPT__', TERM='xterm-256color',
INPUTRC='/dev/null', HISTFILE='/dev/null', PROMPT_COMMAND=''))
def prompt():
output = b''
deadline = time.monotonic() + 5
while b'__COMPOSE_PROMPT__' not in output:
if time.monotonic() > deadline:
raise RuntimeError('Bash prompt timeout: ' + repr(output))
if select.select([fd], [], [], 0.1)[0]:
output += os.read(fd, 65536)
# Multiple unbracketed commands produce a prompt between each line. Drain
# their output too, so the echo control does not stop at the first prompt.
while time.monotonic() < deadline and select.select([fd], [], [], 0.1)[0]:
output += os.read(fd, 65536)
return output
try:
prompt()
os.write(fd, b"stty -echo; bind 'set enable-bracketed-paste on'\r")
prompt()
os.write(fd, payload.encode())
sys.stdout.write(prompt().decode(errors='replace'))
finally:
os.kill(pid, signal.SIGKILL)
os.close(fd)
os.waitpid(pid, 0)
`;
const hasBashPty = process.platform !== 'win32'
&& spawnSync('python3', ['-c', 'import pty']).status === 0
&& spawnSync('bash', ['-c', '(( BASH_VERSINFO[0] >= 5 ))']).status === 0;
for (const [name, command, expected] of [
['all echo lines run', "echo '__ONE__'\necho '__TWO__'\necho '__THREE__'", ['__ONE__', '__TWO__', '__THREE__']],
['long first command cannot consume later commands as stdin',
"sleep 0.4; read -r -t 0.4 consumed; printf '__STDIN__:%s\\n' \"$consumed\"\necho '__TWO__'\necho '__THREE__'",
['__STDIN__:', '__TWO__', '__THREE__']],
['single-line still runs', "echo '__ONE__'", ['__ONE__']],
] as const) {
test(`Bash PTY compose: ${name}`, { skip: hasBashPty ? false : 'requires Python PTY support and Bash 5+' }, async () => {
const { send, writes } = setup();
await send(command, false, { preferBracketedPaste: true });
const result = spawnSync('python3', ['-c', bashPty], {
input: JSON.stringify(writes[0].data), encoding: 'utf8', timeout: 10000,
});
assert.equal(result.status, 0, result.stderr || String(result.error));
const lines = stripVTControlCharacters(result.stdout).split(/\r?\n/);
assert.deepEqual(lines.filter(line => line.startsWith('__') && !line.startsWith('__COMPOSE_PROMPT__')), expected);
});
}
+1
View File
@@ -260,6 +260,7 @@ export interface TerminalProps {
options?: {
broadcast?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
preferBracketedPaste?: boolean;
focus?: boolean;
},
) => boolean | Promise<boolean>) | null,
@@ -112,6 +112,7 @@ export type SnippetExecutor = (
options?: {
broadcast?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
preferBracketedPaste?: boolean;
/** When false, do not steal keyboard focus (multi-tab fan-out). Default true. */
focus?: boolean;
/**
@@ -15,7 +15,7 @@ const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send =
}).outputText;
type Status = 'connected' | 'connecting' | 'disconnected';
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean }) => boolean | Promise<boolean>;
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean; preferBracketedPaste?: boolean }) => boolean | Promise<boolean>;
function setup(
statuses: Status[],
broadcast = false,
@@ -96,6 +96,24 @@ test('compose send-to-all fans out while toolbar broadcast stays off', async ()
assert.deepEqual(writes, ['0', '1']);
});
for (const sendToAll of [false, true]) {
test(`workspace compose requests supported bracketed paste on each target (all=${sendToAll})`, async () => {
const calls: string[] = [];
const text = 'echo one\necho two\necho three';
const executors = new Map<string, Executor>(['0', '1'].map(id => [id, async (command, noAutoRun, options) => {
assert.equal(command, text);
assert.equal(noAutoRun, false);
assert.equal(options?.preferBracketedPaste, true);
assert.equal(options?.broadcast, false);
calls.push(id);
return true;
}]));
const { send } = setup(['connected', 'connected'], false, executors, new Set(), false, new Set(), sendToAll);
assert.equal(await send(text), true);
assert.deepEqual(calls, sendToAll ? ['0', '1'] : ['0']);
});
}
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
const executors = new Map<string, Executor>([
['0', async () => true],
@@ -231,7 +249,8 @@ test('solo compose send uses the compose-bar target while toolbar broadcast stay
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
sessionId: 'a', sessionRef: { current: 'a' },
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean; preferBracketedPaste?: boolean }) => {
assert.equal(options?.preferBracketedPaste, true);
calls.push(options?.broadcast);
return true;
},