fix: deliver Ctrl+V paste through terminal.input

Hand the bracketed frame to the target terminal with input() so onData
scrolls to the bottom, clears the selection, and delivers the bytes.
The paste trap no longer writes the frame itself.

Fixes #716

Co-authored-by: DDT <1786035110@qq.com>
This commit is contained in:
DDTandDDT committed 2026-10-28 07:09:35 +00:00
1 parent dd09a383f1
commit 6f820ca990
6 files changed
+120 -80

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 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)
**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)
**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. Tests: `test/file-preview-markdown.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)
+12 -50
View File
@@ -193,7 +193,10 @@ Object.assign(CodemanApp.prototype, {
// the result for images. Works on plain HTTP (no Clipboard API needed).
// `target` names the terminal the Ctrl+V came from and its session; both
// default to the primary pane. A second terminal (the split pane) passes its
// own, so text pastes into THAT xterm and images upload to THAT session.
// 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.
_handleImagePaste(target = {}) {
const self = this;
const terminal = target.terminal || this.terminal;
@@ -244,14 +247,17 @@ Object.assign(CodemanApp.prototype, {
if (imageFiles.length > 0) {
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
} else if (text) {
} else if (text && terminal) {
// No image. xterm's paste() emits CSI 200~/201~ only while its DECSET
// 2004 mirror is on, and a terminal replay clears that mirror, so the
// markers never arrive and a copied newline submits (#716). Build the
// frame here, the same bytes keyboard-accessory.js sends, and deliver
// it on the session input path. Do not useMux: the mux fallback strips
// every \r, which is the line separator inside the frame.
self._deliverBracketedPaste(sessionId, text);
// frame here and hand it to this terminal through input(). That fires
// onData as user input (scroll to the bottom, clear the selection).
// The main pane's bracketed-paste branch and a tile's _onTerminalData
// deliver it, including the local-echo flush (#219). Do not also write
// the frame from here.
const body = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r');
terminal.input(`\x1b[200~${body}\x1b[201~`);
}
});
@@ -260,50 +266,6 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
/**
* Plain-text Ctrl+V as one bracketed paste on the session-bound input path.
* Mirrors keyboard-accessory.js `_sendBracketedPaste`: drop nested 200/201
* markers (a clipboard carrying ESC[201~ would end the bracket early), map
* newlines to CR (what xterm's prepareTextForTerminal did), wrap with
* CSI 200~ / CSI 201~. The composer's length cap is not applied here;
* `_sendInputAsync` already splits a large paste and refuses one past its
* own ceiling.
*
* When the local-echo overlay owns this session's prompt, flush its unsent
* text first and delay the frame. That is the onData bracketed-paste branch
* (#219): Codex drops bytes that share a PTY read with a bracketed paste,
* and the typed prefix has to land ahead of the block.
*/
_deliverBracketedPaste(sessionId, text) {
if (!sessionId || typeof text !== 'string' || text === '' || typeof this._sendInputAsync !== 'function') return;
try {
this._predictiveEcho?.clearPredictions?.();
} catch {
// Predictions are visual only.
}
const pasteText = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r');
const framed = `\x1b[200~${pasteText}\x1b[201~`;
const echoOwns =
this.activeSessionId === sessionId &&
this._localEchoEnabled &&
this._localEchoOverlay &&
!this._echoPassthroughSessions?.has?.(sessionId) &&
!this._tilesOwnTerminal?.() &&
typeof this._flushLocalEchoPending === 'function';
if (echoOwns) {
const hadPending = !!this._localEchoOverlay.pendingText;
this._flushLocalEchoPending();
if (hadPending) {
const prefix = this._pendingInput || '';
this._pendingInput = '';
if (prefix) this._sendInputAsync(sessionId, prefix);
setTimeout(() => this._sendInputAsync(sessionId, framed), 80);
return;
}
}
this._sendInputAsync(sessionId, framed);
},
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
* clips (the server streams those to disk under their own cap), 'document'
* for the office and text files the server admits, else null. The one filter
+4 -3
View File
@@ -344,9 +344,10 @@
if (global.app?.tileShortcutFor?.(ev)) return false;
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
// a pasted image uploads to this pane's session and its path is typed
// here, and pasted text goes into this xterm with its bracketed-paste
// markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it
// xterm's default only ever pasted text.
// here. Plain text goes to this pane's session as a Codeman-built
// bracketed-paste frame; this terminal is only refocused and receives
// 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 });
return false;