Merge #799: cancel the Ctrl+V keydown once the paste trap has the clipboard (Firefox double paste)

This commit is contained in:
Codeman maintainer committed 2026-11-11 10:21:44 +01:00
commit 1d7288dc4f
7 files changed
+126 -26

No files matched your search

+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;
}