fix: deliver Ctrl+V paste through terminal.input

Hand the bracketed frame to the target terminal with input() so onData
scrolls to the bottom, clears the selection, and delivers the bytes.
The paste trap no longer writes the frame itself.

Fixes #716

Co-authored-by: DDT <1786035110@qq.com>
This commit is contained in:
DDTandDDT committed 2026-10-28 07:09:35 +00:00
1 parent dd09a383f1
commit 6f820ca990
6 files changed
+120 -80

No files matched your search

+81 -25
View File
@@ -38,15 +38,25 @@ interface SentInput {
data: string;
}
interface TargetTerminal {
input: (data: string) => void;
paste?: (text: string) => void;
focus?: () => void;
}
interface Harness {
/** Fire a paste event on the trap the last _handleImagePaste() call created. */
firePaste: (payload: { text?: string; images?: string[] }) => void;
/** Text handed to xterm's terminal.paste(), one entry per call. */
pastedText: string[];
/** Frames handed to _sendInputAsync(), in order. */
/** Frames handed to the primary terminal's input(), one entry per call. */
inputted: string[];
/** Frames handed to _sendInputAsync(), in order. The trap must not deliver text this way. */
sentInputs: SentInput[];
/** How many times predictive-echo predictions were cleared. */
/** How many times predictive-echo predictions were cleared by the trap. */
predictionsCleared: () => number;
/** Unsent overlay text still held after the paste. Empty when local echo was not set up. */
pendingEcho: () => string;
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
uploadedBatches: Array<Array<{ type: string }>>;
/** Messages handed to showToast(), one entry per call. */
@@ -59,8 +69,10 @@ interface Harness {
interface PasteHarnessOptions {
/** Session the paste trap is aimed at. Defaults to the active session. */
sessionId?: string;
/** Unsent overlay text. Enables the local-echo flush path when set. */
/** Unsent overlay text. The trap must leave this for onData to flush. */
localEchoPending?: string;
/** Terminal the Ctrl+V came from, when it is not the primary pane. */
terminal?: TargetTerminal;
}
function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
@@ -115,30 +127,44 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const pastedText: string[] = [];
const inputted: string[] = [];
const sentInputs: SentInput[] = [];
const uploadedBatches: Array<Array<{ type: string }>> = [];
let predictionsCleared = 0;
let pendingEcho = '';
const app = new CodemanApp();
app.activeSessionId = 'session-1';
// Replay leaves xterm's DECSET 2004 mirror off. Text must still go out bracketed,
// and must not ask terminal.paste() to consult that mirror (#716).
// and must not ask terminal.paste() to consult that mirror (#716). input() is
// what a real xterm fires onData from, with wasUserInput (scroll + clear selection).
app.terminal = {
paste: (text: string) => pastedText.push(text),
input: (data: string) => inputted.push(data),
focus: () => {},
modes: { bracketedPasteMode: false },
};
app._sendInputAsync = (sessionId: string, data: string) => {
sentInputs.push({ sessionId, data });
};
app._predictiveEcho = { clearPredictions: () => { predictionsCleared += 1; } };
app._predictiveEcho = {
clearPredictions: () => {
predictionsCleared += 1;
},
};
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
uploadedBatches.push(Array.from(files));
};
if (options.localEchoPending !== undefined) {
pendingEcho = options.localEchoPending;
app._localEchoEnabled = true;
app._pendingInput = '';
app._localEchoOverlay = {
pendingText: options.localEchoPending,
get pendingText() {
return pendingEcho;
},
set pendingText(value: string) {
pendingEcho = value;
},
clear() {
this.pendingText = '';
},
@@ -158,7 +184,10 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
const target = options.sessionId ? { terminal: app.terminal, sessionId: options.sessionId } : undefined;
const target =
options.sessionId || options.terminal
? { terminal: options.terminal ?? app.terminal, sessionId: options.sessionId || 'session-1' }
: undefined;
(app._handleImagePaste as (t?: object) => void).call(app, target);
return {
@@ -175,8 +204,10 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
for (const fn of listeners) fn(event);
},
pastedText,
inputted,
sentInputs,
predictionsCleared: () => predictionsCleared,
pendingEcho: () => pendingEcho,
uploadedBatches,
toasts,
attachedTraps: () => attached.size,
@@ -203,16 +234,19 @@ function loadImageInputApp() {
}
describe('Ctrl+V paste trap', () => {
it('sends clipboard text once as a bracketed paste, not through xterm paste()', () => {
it('hands clipboard text to terminal.input as one bracketed paste, not through paste() or the session path', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'hello world' });
// terminal.paste() would drop the markers: the harness mirror is off, which
// is what a replay leaves behind.
// is what a replay leaves behind. input() is the public entry that fires
// onData with wasUserInput, so the pane scrolls and the selection clears.
// Delivery belongs to that onData handler, not a second write from here.
expect(h.pastedText).toEqual([]);
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]);
expect(h.predictionsCleared()).toBe(1);
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.predictionsCleared()).toBe(0);
});
it('brackets a multi-line paste and strips nested paste markers', () => {
@@ -221,31 +255,51 @@ describe('Ctrl+V paste trap', () => {
h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' });
expect(h.pastedText).toEqual([]);
expect(h.sentInputs).toEqual([
{ sessionId: 'session-1', data: '\x1b[200~line one\rline two\rnested\r\x1b[201~' },
]);
expect(h.inputted).toEqual(['\x1b[200~line one\rline two\rnested\r\x1b[201~']);
expect(h.sentInputs).toEqual([]);
});
it('addresses the bracketed paste to the pane that received Ctrl+V', () => {
const h = loadPasteHarness({ sessionId: 'pane-b' });
const paneInputs: string[] = [];
const pane: TargetTerminal = {
input: (data) => paneInputs.push(data),
paste: () => {
throw new Error('paste() must not run');
},
focus: () => {},
};
const h = loadPasteHarness({ sessionId: 'pane-b', terminal: pane });
h.firePaste({ text: 'into pane b' });
expect(h.sentInputs).toEqual([{ sessionId: 'pane-b', data: '\x1b[200~into pane b\x1b[201~' }]);
expect(paneInputs).toEqual(['\x1b[200~into pane b\x1b[201~']);
expect(h.inputted).toEqual([]);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('flushes unsent local-echo text ahead of the bracketed paste', () => {
it('leaves unsent local-echo text for onData, instead of flushing it itself', () => {
const h = loadPasteHarness({ localEchoPending: 'hi' });
h.firePaste({ text: 'world\n' });
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: 'hi' }]);
h.runTimers();
expect(h.sentInputs).toEqual([
{ sessionId: 'session-1', data: 'hi' },
{ sessionId: 'session-1', data: '\x1b[200~world\r\x1b[201~' },
]);
// terminal.input() fires the main pane's onData, whose bracketed-paste
// branch flushes the overlay and delays the frame (#219). Flushing here
// as well would put the prefix on the wire twice.
expect(h.pendingEcho()).toBe('hi');
expect(h.inputted).toEqual(['\x1b[200~world\r\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('keeps a clipboard that looks like a query reply or a mouse report inside the frame', () => {
const h = loadPasteHarness();
h.firePaste({ text: '\x1b[?1;2c\n\x1b[<0;10;5M\n\x1b[I' });
expect(h.inputted).toEqual(['\x1b[200~\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
@@ -257,7 +311,8 @@ describe('Ctrl+V paste trap', () => {
h.firePaste({ text: 'hello world' });
h.firePaste({ text: 'hello world' });
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]);
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
@@ -288,7 +343,8 @@ describe('Ctrl+V paste trap', () => {
// Copying from a document can carry both: the text is what the user gets.
h.firePaste({ text: 'echo hi', images: ['application/x-sh'] });
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~echo hi\x1b[201~' }]);
expect(h.inputted).toEqual(['\x1b[200~echo hi\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
expect(h.uploadedBatches).toEqual([]);
expect(h.toasts).toEqual([]);
+21
View File
@@ -282,6 +282,27 @@ describe('what xterm generates never enters the durable queue', () => {
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
it('queues a Ctrl+V bracketed-paste frame on the durable path', async () => {
// image-input.js hands this shape to terminal.input(). onData must not
// drop it as a query reply or send it once as a mouse/focus report: the
// same two predicates gate the primary pane before its bracketed-paste branch.
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
const input = windowStub.CodemanTerminalInput as {
shouldSuppressTerminalQueryResponse(data: string): boolean;
isTerminalFocusOrMouseReport(data: string): boolean;
};
const framed = '\x1b[200~line one\r\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~';
expect(input.shouldSuppressTerminalQueryResponse(framed)).toBe(false);
expect(input.isTerminalFocusOrMouseReport(framed)).toBe(false);
term.type(framed);
expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([[framed, 1, 'c-test']]);
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual([framed]);
});
});
describe('TerminalTile leaves the input-socket map', () => {