fix(paste): cancel the Ctrl+V keydown once the paste trap has the clipboard

Ctrl+V in Firefox still pasted the clipboard twice some of the time.
Right-click → Paste never did.

`_handleImagePaste()` focuses a hidden contenteditable trap, calls
`document.execCommand('paste')`, and reads the clipboard from the paste
event that lands on the trap. Firefox dispatches that event synchronously,
inside the `execCommand` call. It then delivers the key's own paste as a
separate task, about 10 ms later in Playwright's Firefox. The one-shot flag
from b87bc687 drops that second event, but only if it still reaches the
trap. The trap removes itself and refocuses the terminal on a
`setTimeout(0)` queued by the first event, and that timer races the second
paste. When the timer wins, the second paste lands on xterm's textarea,
xterm's own `handlePasteEvent` sends the clipboard again, and the flag
never sees it.

The trap now cancels the keydown when its paste listener has already run
by the time `execCommand` returns, so Firefox never issues the second
paste. Both callers pass the keydown as `target.keyEvent`: the main pane's
key handler in terminal-ui.js and a tile's in terminal-tile.js. Chromium
and WebKit refuse the command and dispatch nothing from it, so their
keydown is left alone, because its default action delivers their only
paste event.

Measured in Playwright's Firefox with the trap removed before the key's
paste arrived: the paste reached xterm's textarea and the text went in
twice. With the keydown cancelled, the key's paste never fired. End to end
on an isolated instance with a shell session, 20 Ctrl+V presses gave 20
pastes in both Firefox and Chromium.

test/image-paste-trap.test.ts gains a harness option in which
`execCommand` delivers the paste from inside the call, as Firefox does.
New cases cover the keydown being cancelled for the main pane and for a
tile, being left alone when `execCommand` delivers nothing, and a caller
that passes no keydown. The two cancel cases fail without this change.

test/terminal-tile-input.test.ts now expects the tile to pass its keydown
along with its terminal and session.

Docs: the Terminal paste section of docs/architecture-invariants.md and
the paste-trap entry in src/web/CLAUDE.md now describe the race and the
rule. The section's old claim that the key's default action alone always
reaches the trap is replaced, since in Firefox it can miss it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael GrundbergandClaude Opus 5.5 committed 2026-11-10 08:20:41 +01:00
1 parent f62c1f7e2f
commit 6c5d53ebfe
7 files changed
+126 -26

No files matched your search

+4 -2
View File
@@ -948,9 +948,11 @@ Feedback is silent on success except ONCE per page load (a feature that works by
Measured against a live install, one `Ctrl+V` each: Firefox delivers two paste events, Chromium and WebKit one. Handling both wrote the clipboard text to the PTY twice, which is why `Ctrl+V` pasted twice while right-click → Paste pasted once. That menu path involves no keydown, so route 2 cannot exist for it.
⚠️ **Route 2 alone is enough in all three engines, so route 1 is redundant where it can be measured.** Strip the `execCommand('paste')` call out and each of the three still delivers exactly one paste event to the trap, Firefox included. The call is kept anyway, because the trap technique was written for the mobile engines that a desktop measurement cannot reach, and a browser that resolves the key's default action against the element focused when the keydown began would send its paste to xterm's textarea instead. Text paste survives that on xterm's own `handlePasteEvent`; image paste does not, since the trap's listener is the only place clipboard image blobs are read. Removing the call is therefore a decision about mobile coverage, not a cleanup.
⚠️ **Once route 1 has delivered, the trap cancels the keydown.** Firefox dispatches the `execCommand` paste synchronously, inside the call. It delivers the key's own paste as a separate task about 10 ms later, measured in Playwright's Firefox. That later event races the `setTimeout(0)` that removes the trap and refocuses the terminal. When the timer wins, the event lands on xterm's textarea, and xterm's own `handlePasteEvent` sends the clipboard a second time; the trap's one-shot flag never sees that event. This race is why a one-shot flag alone left `Ctrl+V` pasting twice "sometimes". Both callers, the main pane's key handler and a tile's, pass the keydown as `target.keyEvent`. `_handleImagePaste()` calls its `preventDefault()` when the paste listener has already run by the time `execCommand` returns, so the browser never issues route 2. Chromium and WebKit dispatch nothing from route 1, so they keep the default action, which delivers their only paste event. ⚠️ Never cancel the keydown unconditionally: in those engines that loses the paste entirely.
The guard is a one-shot flag on each trap rather than a browser test or a reading of `execCommand`'s return value, so any count of events produces one insert. Tests: `test/image-paste-trap.test.ts`.
⚠️ **The `execCommand('paste')` call stays.** The trap technique was written for mobile engines that a desktop measurement cannot reach. A browser that resolves the key's default action against the element focused when the keydown began would send its paste to xterm's textarea. Text paste survives that on xterm's own `handlePasteEvent`; image paste does not, since the trap's listener is the only place clipboard image blobs are read. Removing the call is therefore a decision about mobile coverage, not a cleanup.
The one-shot flag on each trap stays as a second guard, so any count of events that reach the trap produces one insert. Tests: `test/image-paste-trap.test.ts`.
### Settings surface: App Settings, Session Options, Add Case
+1 -1
View File
@@ -20,7 +20,7 @@
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ **Once `execCommand` has delivered, the trap cancels the keydown** (`target.keyEvent`, passed by both the main pane and a tile): Firefox's second paste is a separate task that races the trap's removal, and when it loses it lands on xterm's textarea and pastes again, which the flag cannot see. Never cancel the keydown unconditionally; Chromium and WebKit get their only paste from its default action. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. ⚠️ **Right-to-left text** (#715) has three parts on that one pipeline: `_renderMarkdown` pins `pre, code` to `dir="ltr"`, `_linkifyFilePaths` pins the anchors it builds the same way (`dir="auto"` skips a descendant that carries a `dir`, so `npm install` or a path at the head of a Persian sentence does not decide its direction; a markdown link's label is prose and stays unpinned), and `_applyBlockDirection()` gives each OUTERMOST prose block `dir="auto"`, keeping a `dir` the agent wrote and what it covers. ⚠️ That pass runs LAST, on the DOM, in both consumers (`_buildResponseViewerMessage`, `_renderFilePreviewText`), never in `_renderMarkdown` (its string must hold no `dir="auto"`) and never inside the linkifier (which returns early on a stale constants.js): an element already carrying `dir="auto"` re-resolves its direction on every insertion under it, so a code-only or path-only document went quadratic and a 200 KB list froze the tab for 40 s. ⚠️ The `.rv-text` block rules use logical properties (`padding-inline-start`, `border-inline-start`, `border-inline-end`, `text-align: start`, logical corner radii), never physical left and right, or an RTL block keeps its bullets and quote bar on the wrong side. The terminal pane is out of reach: xterm.js has no RTL support (xtermjs/xterm.js#701). Tests: `test/file-preview-markdown.test.ts`, `test/response-viewer-rtl.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
+17 -1
View File
@@ -196,11 +196,13 @@ Object.assign(CodemanApp.prototype, {
// own. Images upload to THAT session. Plain text goes to that pane's session
// as a Codeman-built bracketed-paste frame: the terminal is only refocused
// and receives input(framed). onData delivers the frame; it is not written
// into the xterm buffer.
// into the xterm buffer. `target.keyEvent` is the Ctrl+V keydown itself,
// which the trap cancels when execCommand('paste') has already delivered.
_handleImagePaste(target = {}) {
const self = this;
const terminal = target.terminal || this.terminal;
const sessionId = target.sessionId || this.activeSessionId;
const keyEvent = target.keyEvent;
// Create a hidden contenteditable div to receive the paste
const trap = document.createElement('div');
@@ -267,6 +269,20 @@ Object.assign(CodemanApp.prototype, {
// Trigger the browser's native paste via execCommand
// (this fires the paste event on our focused trap element)
document.execCommand('paste');
// Firefox dispatches the execCommand paste synchronously, inside the call
// above, and then delivers the key's own paste as a SEPARATE task about
// 10 ms later. That second event races the setTimeout above that removes
// the trap and refocuses the terminal. When the timer wins, the second
// paste lands on xterm's textarea, xterm's own paste handler sends the
// clipboard again, and the one-shot flag never sees it. That race is why
// Ctrl+V still pasted twice only sometimes. Once execCommand has delivered
// a paste, cancel the keydown so the browser never issues its own.
// Chromium and WebKit refuse the command and dispatch nothing here, so
// they keep the default action, which delivers their only paste event.
if (pasteConsumed && keyEvent && typeof keyEvent.preventDefault === 'function') {
keyEvent.preventDefault();
}
},
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
+1 -1
View File
@@ -349,7 +349,7 @@
// input(framed), which onData delivers. Mirrors terminal-ui.js's own
// Ctrl+V gate; without it xterm's default only ever pasted text.
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId, keyEvent: ev });
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend), as terminal-ui.js swallows it:
+3 -1
View File
@@ -924,7 +924,9 @@ Object.assign(CodemanApp.prototype, {
// Route through our paste trap which handles both images and text.
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
if (this.activeSessionId && this._handleImagePaste) {
this._handleImagePaste();
// The trap cancels this keydown itself when execCommand('paste')
// already delivered the clipboard (Firefox), so pass the event in.
this._handleImagePaste({ keyEvent: ev });
}
return false;
}
+95 -18
View File
@@ -7,9 +7,11 @@
* Ctrl+V: the `document.execCommand('paste')` the function issues itself, and
* the keydown's own default action, which still runs because xterm's custom key
* handler returns false without cancelling the event. A browser that honours
* execCommand('paste') therefore fires the trap's listener twice, and the
* execCommand('paste') (Firefox) therefore produced two paste events, and the
* clipboard text used to reach the PTY twice with it — while right-click →
* Paste, which involves no keydown, stayed correct.
* Paste, which involves no keydown, stayed correct. Firefox delivers the key's
* paste as a later task, so it can also miss the trap and reach xterm's
* textarea; the trap therefore cancels the keydown once execCommand delivered.
*
* Loads the browser module into a vm sandbox with a fake document, so the tests
* drive the trap's listener directly rather than through a real browser.
@@ -64,6 +66,13 @@ interface Harness {
/** How many trap divs are still attached to the fake body. */
attachedTraps: () => number;
runTimers: () => void;
/** How many times the Ctrl+V keydown handed to _handleImagePaste() was cancelled. */
keydownCancels: () => number;
}
interface PastePayload {
text?: string;
images?: string[];
}
interface PasteHarnessOptions {
@@ -73,6 +82,14 @@ interface PasteHarnessOptions {
localEchoPending?: string;
/** Terminal the Ctrl+V came from, when it is not the primary pane. */
terminal?: TargetTerminal;
/**
* What `document.execCommand('paste')` delivers to the trap before it
* returns. Firefox dispatches a real paste event from inside the call;
* Chromium and WebKit refuse the command and dispatch nothing (the default).
*/
execCommandDelivers?: PastePayload;
/** Call _handleImagePaste() without the keydown, as an older caller would. */
omitKeyEvent?: boolean;
}
function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
@@ -81,6 +98,19 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const attached = new Set<FakeTrap>();
const timers: Array<() => void> = [];
const firePaste = ({ text = '', images = [] }: PastePayload) => {
const items = images.map((type) => ({ type, getAsFile: () => ({ type }) }));
const event = {
clipboardData: {
items,
getData: () => text,
},
preventDefault: () => {},
stopPropagation: () => {},
};
for (const fn of listeners) fn(event);
};
const documentObj = {
createElement: (): FakeTrap => {
const trap: FakeTrap = {
@@ -106,8 +136,12 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
},
},
// A browser that honours the command fires the trap's paste listener from
// here as well; the tests model that by firing the listener twice.
execCommand: () => true,
// inside this call. Firefox then returns false, because the trap cancels
// the event and the command never completes.
execCommand: () => {
if (options.execCommandDelivers) firePaste(options.execCommandDelivers);
return false;
},
getElementById: () => null,
};
@@ -184,25 +218,24 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
const target =
let keydownCancels = 0;
const keyEvent = {
type: 'keydown',
key: 'v',
ctrlKey: true,
preventDefault: () => {
keydownCancels += 1;
},
};
const target: Record<string, unknown> =
options.sessionId || options.terminal
? { terminal: options.terminal ?? app.terminal, sessionId: options.sessionId || 'session-1' }
: undefined;
: {};
if (!options.omitKeyEvent) target.keyEvent = keyEvent;
(app._handleImagePaste as (t?: object) => void).call(app, target);
return {
firePaste({ text = '', images = [] }) {
const items = images.map((type) => ({ type, getAsFile: () => ({ type }) }));
const event = {
clipboardData: {
items,
getData: () => text,
},
preventDefault: () => {},
stopPropagation: () => {},
};
for (const fn of listeners) fn(event);
},
firePaste,
pastedText,
inputted,
sentInputs,
@@ -215,6 +248,7 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const pending = timers.splice(0, timers.length);
for (const fn of pending) fn();
},
keydownCancels: () => keydownCancels,
};
}
@@ -378,6 +412,49 @@ describe('Ctrl+V paste trap', () => {
h.runTimers();
expect(h.attachedTraps()).toBe(0);
});
// Firefox delivers the execCommand paste inside the call and the key's own
// paste as a later task. When the trap's cleanup timer runs first, that
// later paste lands on xterm's textarea instead of the trap, and xterm
// pastes it a second time. Cancelling the keydown is what stops it.
it('cancels the keydown when execCommand already delivered the paste', () => {
const h = loadPasteHarness({ execCommandDelivers: { text: 'hello world' } });
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.keydownCancels()).toBe(1);
});
it('cancels the split-pane keydown the same way', () => {
const paneInputs: string[] = [];
const pane: TargetTerminal = { input: (data) => paneInputs.push(data), focus: () => {} };
const h = loadPasteHarness({
sessionId: 'pane-b',
terminal: pane,
execCommandDelivers: { text: 'into pane b' },
});
expect(paneInputs).toEqual(['\x1b[200~into pane b\x1b[201~']);
expect(h.keydownCancels()).toBe(1);
});
it('leaves the keydown alone when execCommand delivered nothing', () => {
// Chromium and WebKit: the key's default action is the only route into
// the trap, so cancelling it would lose the paste entirely.
const h = loadPasteHarness();
expect(h.keydownCancels()).toBe(0);
h.firePaste({ text: 'hello world' });
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.keydownCancels()).toBe(0);
});
it('still pastes once when called without the keydown', () => {
const h = loadPasteHarness({ execCommandDelivers: { text: 'hello world' }, omitKeyEvent: true });
h.firePaste({ text: 'hello world' });
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
});
});
describe('image upload insertion policy', () => {
+5 -2
View File
@@ -741,10 +741,13 @@ describe('TerminalTile links and paste follow THIS pane', () => {
app._handleImagePaste = paste;
const { term } = await connectTile(app);
const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' });
const keyEvent = { type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' };
const handled = term.keyHandler!(keyEvent);
expect(handled).toBe(false);
expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' });
// The keydown rides along so the trap can cancel it once execCommand('paste')
// has delivered (Firefox), instead of letting a second paste reach xterm.
expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile', keyEvent });
});
it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => {