Merge pull request #726: fix(terminal): don't double a desktop dead key, and bracket Ctrl+V after replay

This commit is contained in:
Codeman maintainer committed 2026-10-28 09:10:09 +01:00
commit 5add56eaf6
10 files changed
+318 -26

No files matched your search

+152 -6
View File
@@ -33,11 +33,30 @@ interface FakeTrap {
addEventListener: (ev: string, fn: TrapListener) => void;
}
interface SentInput {
sessionId: string;
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 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 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. */
@@ -47,7 +66,16 @@ interface Harness {
runTimers: () => void;
}
function loadPasteHarness(): Harness {
interface PasteHarnessOptions {
/** Session the paste trap is aimed at. Defaults to the active session. */
sessionId?: string;
/** 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 {
const traps: FakeTrap[] = [];
const listeners: TrapListener[] = [];
const attached = new Set<FakeTrap>();
@@ -99,20 +127,68 @@ function loadPasteHarness(): 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). 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._uploadAndInsertImages = (files: Array<{ type: string }>) => {
uploadedBatches.push(Array.from(files));
};
if (options.localEchoPending !== undefined) {
pendingEcho = options.localEchoPending;
app._localEchoEnabled = true;
app._pendingInput = '';
app._localEchoOverlay = {
get pendingText() {
return pendingEcho;
},
set pendingText(value: string) {
pendingEcho = value;
},
clear() {
this.pendingText = '';
},
suppressBufferDetection() {},
};
app._flushedOffsets = new Map();
app._flushedTexts = new Map();
app._flushLocalEchoPending = function (this: Record<string, any>) {
const pending = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (pending) this._pendingInput += pending;
};
}
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
(app._handleImagePaste as () => void).call(app);
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 {
firePaste({ text = '', images = [] }) {
@@ -128,6 +204,10 @@ function loadPasteHarness(): Harness {
for (const fn of listeners) fn(event);
},
pastedText,
inputted,
sentInputs,
predictionsCleared: () => predictionsCleared,
pendingEcho: () => pendingEcho,
uploadedBatches,
toasts,
attachedTraps: () => attached.size,
@@ -154,12 +234,73 @@ function loadImageInputApp() {
}
describe('Ctrl+V paste trap', () => {
it('sends clipboard text to the terminal once for a single paste event', () => {
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' });
expect(h.pastedText).toEqual(['hello world']);
// terminal.paste() would drop the markers: the harness mirror is off, which
// 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.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', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' });
expect(h.pastedText).toEqual([]);
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 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(paneInputs).toEqual(['\x1b[200~into pane b\x1b[201~']);
expect(h.inputted).toEqual([]);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('leaves unsent local-echo text for onData, instead of flushing it itself', () => {
const h = loadPasteHarness({ localEchoPending: 'hi' });
h.firePaste({ text: 'world\n' });
h.runTimers();
// 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([]);
});
it('ignores a second paste event for the same Ctrl+V', () => {
@@ -170,7 +311,9 @@ describe('Ctrl+V paste trap', () => {
h.firePaste({ text: 'hello world' });
h.firePaste({ text: 'hello world' });
expect(h.pastedText).toEqual(['hello world']);
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('uploads a pasted image once when the trap sees two paste events', () => {
@@ -182,6 +325,7 @@ describe('Ctrl+V paste trap', () => {
expect(h.uploadedBatches).toHaveLength(1);
expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]);
expect(h.pastedText).toEqual([]);
expect(h.sentInputs).toEqual([]);
});
it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => {
@@ -199,7 +343,9 @@ 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.pastedText).toEqual(['echo hi']);
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([]);
});
@@ -40,6 +40,29 @@ describe('orphaned terminal input recovery wiring', () => {
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
// Headless Chromium is a fine pointer, and create() then returns null (#716).
// The recovery cases below are the soft-keyboard path, so this page reports
// a coarse pointer. Other media queries stay native.
await page.addInitScript(() => {
const native = window.matchMedia.bind(window);
window.matchMedia = (query: string) => {
if (query === '(pointer: coarse)') {
return {
matches: true,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
};
}
return native(query);
};
});
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 });
@@ -50,6 +73,18 @@ describe('orphaned terminal input recovery wiring', () => {
if (server) await server.stop();
}, 60000);
it('does not install recovery when the primary pointer is fine', async () => {
const fine = await browser.newPage();
try {
await fine.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await fine.waitForFunction(() => (window as any).app?.terminal?.textarea, null, { timeout: 30000 });
const installed = await fine.evaluate(() => (window as any).app?._keyCode229Recovery ?? null);
expect(installed).toBeNull();
} finally {
await fine.close();
}
});
/**
* Drive one keystroke through the real textarea and report what reached the
* PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key
+44 -2
View File
@@ -46,9 +46,19 @@ function inputEvent(data: string, overrides: Record<string, unknown> = {}) {
return { data, inputType: 'insertText', isComposing: false, ...overrides };
}
function harness({ screenReader = false } = {}) {
function harness({ screenReader = false, coarse = true, matchMedia = true } = {}) {
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
const exposed: Record<string, any> = {};
if (matchMedia) {
// create() attaches only for a coarse pointer (#716). The recovery cases
// below are the soft-keyboard behaviour, so they opt in. A fine pointer
// (or no matchMedia at all) must get null and no listeners.
exposed.matchMedia = (query: string) => ({
matches: query === '(pointer: coarse)' && coarse,
addEventListener() {},
removeEventListener() {},
});
}
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
const textarea = makeTextarea();
@@ -111,6 +121,31 @@ function loadTerminalInput() {
}
describe('orphaned terminal input recovery', () => {
it('does not attach on a fine pointer, so a desktop dead key is not forwarded twice', () => {
const h = harness({ coarse: false });
expect(h.controller).toBeNull();
expect(h.textarea.listenerCount()).toBe(0);
expect(h.textarea.registrations()).toEqual([]);
});
it('does not attach when matchMedia is missing', () => {
// Fail closed. Treating "unknown" as coarse would put the recovery back on
// a desktop whose matchMedia threw, which is the doubled backtick.
const h = harness({ matchMedia: false });
expect(h.controller).toBeNull();
expect(h.textarea.listenerCount()).toBe(0);
});
it('still recovers a dropped insertText on a coarse pointer', () => {
const h = harness({ coarse: true });
h.keydown();
h.input('`');
expect(h.emitted).toEqual([]);
h.flushTimers();
expect(h.emitted).toEqual(['`']);
expect(h.textarea.listenerCount()).toBeGreaterThan(0);
});
it('forwards the committed text when xterm stayed silent', () => {
const h = harness();
h.keydown();
@@ -432,7 +467,14 @@ describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"',
*/
function editSyncHarness() {
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
const exposed: Record<string, any> = {};
const exposed: Record<string, any> = {
// Autocorrect recovery is the coarse-pointer (soft keyboard) path.
matchMedia: (query: string) => ({
matches: query === '(pointer: coarse)',
addEventListener() {},
removeEventListener() {},
}),
};
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
const textarea = Object.assign(makeTextarea(), { value: '' });
+40
View File
@@ -48,6 +48,17 @@ function loadContext() {
// would run against no controller at all.
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
// The real controller attaches only for a coarse pointer (#716). Tile tests
// that exercise soft-keyboard recovery opt in; a test sets this false to
// prove a desktop fine pointer installs nothing.
__coarsePointer: true,
matchMedia(query: string) {
return {
matches: query === '(pointer: coarse)' && windowStub.__coarsePointer !== false,
addEventListener() {},
removeEventListener() {},
};
},
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
@@ -151,6 +162,7 @@ afterEach(() => {
});
beforeEach(() => {
windowStub.__coarsePointer = true;
FakeTerminal.coreFactory = null;
FakeFit.proposed = { cols: 80, rows: 24 };
FakeSocket.instances = [];
@@ -270,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', () => {
@@ -1183,6 +1216,13 @@ function lineOf(frames: Array<{ d?: string }>) {
type ControllerTile = Tile & { _keyCode229Recovery: unknown };
describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => {
it('does not install the controller on a fine pointer', async () => {
windowStub.__coarsePointer = false;
const { tile, term } = await connectTile(makeApp());
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
expect(term.textareaListeners).toEqual([]);
});
it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => {
const helpers = withCompositionHelpers();
const { tile, term } = await connectTile(makeApp());