From dd09a383f191d68c8a702c1f48593b162667b3a3 Mon Sep 17 00:00:00 2001 From: DDT <141296842+Hugo-DDT@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:06:45 +0000 Subject: [PATCH 1/2] fix: stop desktop dead-key doubling and bracket Ctrl+V paste KeyCode 229 recovery forwarded a composed character that xterm had already delivered, so a desktop dead-key backtick arrived twice. create() now attaches only when the primary pointer is coarse. Ctrl+V text paste went through terminal.paste(), which drops the CSI 200~/201~ markers after a replay clears xterm's DECSET 2004 mirror. Plain text is now a hand-built bracketed frame on the session input path. Fixes #716 Co-authored-by: DDT <1786035110@qq.com> --- docs/architecture-invariants.md | 2 +- src/web/CLAUDE.md | 4 +- src/web/public/image-input.js | 60 +++++++++-- .../public/terminal-keycode229-recovery.js | 20 ++++ src/web/public/terminal-tile.js | 7 +- src/web/public/terminal-ui.js | 1 + test/image-paste-trap.test.ts | 102 ++++++++++++++++-- ...rminal-keycode229-recovery.browser.test.ts | 35 ++++++ test/terminal-keycode229-recovery.test.ts | 46 +++++++- test/terminal-tile-input.test.ts | 19 ++++ 10 files changed, 274 insertions(+), 22 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d4484540f..d8e4c1bc5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -817,7 +817,7 @@ Feedback is silent on success except ONCE per page load (a feature that works by ### Terminal paste (Ctrl+V) -**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; text goes through `terminal.paste()`, so the bracketed-paste markers survive. Two independent routes deliver that event for one keypress, and a browser may fire both: +**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; plain text is a hand-built bracketed paste (`\x1b[200~` … `\x1b[201~`, nested markers stripped, newlines as `\r`) on the session input path (`_deliverBracketedPaste`). `terminal.paste()` only emits those markers while xterm's DECSET 2004 mirror is on, and a terminal replay clears that mirror (#716), so Ctrl+V builds the frame itself, the same way the mobile composer does. When the local-echo overlay owns that session's prompt, the unsent prefix is flushed first and the frame follows on a delay, which is the onData bracketed-paste ordering (#219). Two independent routes deliver that event for one keypress, and a browser may fire both: 1. **`document.execCommand('paste')`**, which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trusted `paste` event carrying the real clipboard and still returns `false`, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing. 2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well. diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index e2c9cd1ed..c6090e222 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, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **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 hand-built bracketed paste on the session input path (`_deliverBracketedPaste`: 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). When local echo owns the prompt, flush the unsent prefix first and delay the frame (#219). ⚠️ **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) **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 `