From 6c5d53ebfe0b27a03e80c61a9984a4e17bc61183 Mon Sep 17 00:00:00 2001 From: Michael Grundberg Date: Tue, 10 Nov 2026 08:05:39 +0100 Subject: [PATCH] fix(paste): cancel the Ctrl+V keydown once the paste trap has the clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/architecture-invariants.md | 6 +- src/web/CLAUDE.md | 2 +- src/web/public/image-input.js | 18 ++++- src/web/public/terminal-tile.js | 2 +- src/web/public/terminal-ui.js | 4 +- test/image-paste-trap.test.ts | 113 ++++++++++++++++++++++++++----- test/terminal-tile-input.test.ts | 7 +- 7 files changed, 126 insertions(+), 26 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d280b477..99c76198 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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 diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 9268b440..1ebebf36 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -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 `
`) 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 `