` (a detached div with `innerHTML` set starts fetching every `
` 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 ``, 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)
diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js
index d2d5fd60..58bf68ce 100644
--- a/src/web/public/image-input.js
+++ b/src/web/public/image-input.js
@@ -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
diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js
index ba24ec51..a069eeee 100644
--- a/src/web/public/terminal-tile.js
+++ b/src/web/public/terminal-tile.js
@@ -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;
diff --git a/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts
index 238a0d4c..e1aaf32e 100644
--- a/test/image-paste-trap.test.ts
+++ b/test/image-paste-trap.test.ts
@@ -38,15 +38,25 @@ interface SentInput {
data: string;
}
+interface TargetTerminal {
+ input: (data: string) => void;
+ paste?: (text: string) => void;
+ focus?: () => void;
+}
+
interface Harness {
/** Fire a paste event on the trap the last _handleImagePaste() call created. */
firePaste: (payload: { text?: string; images?: string[] }) => void;
/** Text handed to xterm's terminal.paste(), one entry per call. */
pastedText: string[];
- /** Frames handed to _sendInputAsync(), in order. */
+ /** Frames handed to the primary terminal's input(), one entry per call. */
+ inputted: string[];
+ /** Frames handed to _sendInputAsync(), in order. The trap must not deliver text this way. */
sentInputs: SentInput[];
- /** How many times predictive-echo predictions were cleared. */
+ /** How many times predictive-echo predictions were cleared by the trap. */
predictionsCleared: () => number;
+ /** Unsent overlay text still held after the paste. Empty when local echo was not set up. */
+ pendingEcho: () => string;
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
uploadedBatches: Array>;
/** Messages handed to showToast(), one entry per call. */
@@ -59,8 +69,10 @@ interface Harness {
interface PasteHarnessOptions {
/** Session the paste trap is aimed at. Defaults to the active session. */
sessionId?: string;
- /** Unsent overlay text. Enables the local-echo flush path when set. */
+ /** Unsent overlay text. The trap must leave this for onData to flush. */
localEchoPending?: string;
+ /** Terminal the Ctrl+V came from, when it is not the primary pane. */
+ terminal?: TargetTerminal;
}
function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
@@ -115,30 +127,44 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record;
const pastedText: string[] = [];
+ const inputted: string[] = [];
const sentInputs: SentInput[] = [];
const uploadedBatches: Array> = [];
let predictionsCleared = 0;
+ let pendingEcho = '';
const app = new CodemanApp();
app.activeSessionId = 'session-1';
// Replay leaves xterm's DECSET 2004 mirror off. Text must still go out bracketed,
- // and must not ask terminal.paste() to consult that mirror (#716).
+ // and must not ask terminal.paste() to consult that mirror (#716). input() is
+ // what a real xterm fires onData from, with wasUserInput (scroll + clear selection).
app.terminal = {
paste: (text: string) => pastedText.push(text),
+ input: (data: string) => inputted.push(data),
focus: () => {},
modes: { bracketedPasteMode: false },
};
app._sendInputAsync = (sessionId: string, data: string) => {
sentInputs.push({ sessionId, data });
};
- app._predictiveEcho = { clearPredictions: () => { predictionsCleared += 1; } };
+ app._predictiveEcho = {
+ clearPredictions: () => {
+ predictionsCleared += 1;
+ },
+ };
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
uploadedBatches.push(Array.from(files));
};
if (options.localEchoPending !== undefined) {
+ pendingEcho = options.localEchoPending;
app._localEchoEnabled = true;
app._pendingInput = '';
app._localEchoOverlay = {
- pendingText: options.localEchoPending,
+ get pendingText() {
+ return pendingEcho;
+ },
+ set pendingText(value: string) {
+ pendingEcho = value;
+ },
clear() {
this.pendingText = '';
},
@@ -158,7 +184,10 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
- const target = options.sessionId ? { terminal: app.terminal, sessionId: options.sessionId } : undefined;
+ const target =
+ options.sessionId || options.terminal
+ ? { terminal: options.terminal ?? app.terminal, sessionId: options.sessionId || 'session-1' }
+ : undefined;
(app._handleImagePaste as (t?: object) => void).call(app, target);
return {
@@ -175,8 +204,10 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
for (const fn of listeners) fn(event);
},
pastedText,
+ inputted,
sentInputs,
predictionsCleared: () => predictionsCleared,
+ pendingEcho: () => pendingEcho,
uploadedBatches,
toasts,
attachedTraps: () => attached.size,
@@ -203,16 +234,19 @@ function loadImageInputApp() {
}
describe('Ctrl+V paste trap', () => {
- it('sends clipboard text once as a bracketed paste, not through xterm paste()', () => {
+ it('hands clipboard text to terminal.input as one bracketed paste, not through paste() or the session path', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'hello world' });
// terminal.paste() would drop the markers: the harness mirror is off, which
- // is what a replay leaves behind.
+ // is what a replay leaves behind. input() is the public entry that fires
+ // onData with wasUserInput, so the pane scrolls and the selection clears.
+ // Delivery belongs to that onData handler, not a second write from here.
expect(h.pastedText).toEqual([]);
- expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]);
- expect(h.predictionsCleared()).toBe(1);
+ expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
+ expect(h.sentInputs).toEqual([]);
+ expect(h.predictionsCleared()).toBe(0);
});
it('brackets a multi-line paste and strips nested paste markers', () => {
@@ -221,31 +255,51 @@ describe('Ctrl+V paste trap', () => {
h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' });
expect(h.pastedText).toEqual([]);
- expect(h.sentInputs).toEqual([
- { sessionId: 'session-1', data: '\x1b[200~line one\rline two\rnested\r\x1b[201~' },
- ]);
+ expect(h.inputted).toEqual(['\x1b[200~line one\rline two\rnested\r\x1b[201~']);
+ expect(h.sentInputs).toEqual([]);
});
it('addresses the bracketed paste to the pane that received Ctrl+V', () => {
- const h = loadPasteHarness({ sessionId: 'pane-b' });
+ const paneInputs: string[] = [];
+ const pane: TargetTerminal = {
+ input: (data) => paneInputs.push(data),
+ paste: () => {
+ throw new Error('paste() must not run');
+ },
+ focus: () => {},
+ };
+ const h = loadPasteHarness({ sessionId: 'pane-b', terminal: pane });
h.firePaste({ text: 'into pane b' });
- expect(h.sentInputs).toEqual([{ sessionId: 'pane-b', data: '\x1b[200~into pane b\x1b[201~' }]);
+ expect(paneInputs).toEqual(['\x1b[200~into pane b\x1b[201~']);
+ expect(h.inputted).toEqual([]);
+ expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
- it('flushes unsent local-echo text ahead of the bracketed paste', () => {
+ it('leaves unsent local-echo text for onData, instead of flushing it itself', () => {
const h = loadPasteHarness({ localEchoPending: 'hi' });
h.firePaste({ text: 'world\n' });
-
- expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: 'hi' }]);
h.runTimers();
- expect(h.sentInputs).toEqual([
- { sessionId: 'session-1', data: 'hi' },
- { sessionId: 'session-1', data: '\x1b[200~world\r\x1b[201~' },
- ]);
+
+ // terminal.input() fires the main pane's onData, whose bracketed-paste
+ // branch flushes the overlay and delays the frame (#219). Flushing here
+ // as well would put the prefix on the wire twice.
+ expect(h.pendingEcho()).toBe('hi');
+ expect(h.inputted).toEqual(['\x1b[200~world\r\x1b[201~']);
+ expect(h.sentInputs).toEqual([]);
+ expect(h.pastedText).toEqual([]);
+ });
+
+ it('keeps a clipboard that looks like a query reply or a mouse report inside the frame', () => {
+ const h = loadPasteHarness();
+
+ h.firePaste({ text: '\x1b[?1;2c\n\x1b[<0;10;5M\n\x1b[I' });
+
+ expect(h.inputted).toEqual(['\x1b[200~\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~']);
+ expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
@@ -257,7 +311,8 @@ describe('Ctrl+V paste trap', () => {
h.firePaste({ text: 'hello world' });
h.firePaste({ text: 'hello world' });
- expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]);
+ expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
+ expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
@@ -288,7 +343,8 @@ describe('Ctrl+V paste trap', () => {
// Copying from a document can carry both: the text is what the user gets.
h.firePaste({ text: 'echo hi', images: ['application/x-sh'] });
- expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~echo hi\x1b[201~' }]);
+ expect(h.inputted).toEqual(['\x1b[200~echo hi\x1b[201~']);
+ expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
expect(h.uploadedBatches).toEqual([]);
expect(h.toasts).toEqual([]);
diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts
index 4df588d3..64999713 100644
--- a/test/terminal-tile-input.test.ts
+++ b/test/terminal-tile-input.test.ts
@@ -282,6 +282,27 @@ describe('what xterm generates never enters the durable queue', () => {
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
+
+ it('queues a Ctrl+V bracketed-paste frame on the durable path', async () => {
+ // image-input.js hands this shape to terminal.input(). onData must not
+ // drop it as a query reply or send it once as a mouse/focus report: the
+ // same two predicates gate the primary pane before its bracketed-paste branch.
+ const app = makeApp();
+ const { ws, term } = await connectTile(app);
+ ws.open();
+ const input = windowStub.CodemanTerminalInput as {
+ shouldSuppressTerminalQueryResponse(data: string): boolean;
+ isTerminalFocusOrMouseReport(data: string): boolean;
+ };
+ const framed = '\x1b[200~line one\r\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~';
+
+ expect(input.shouldSuppressTerminalQueryResponse(framed)).toBe(false);
+ expect(input.isTerminalFocusOrMouseReport(framed)).toBe(false);
+ term.type(framed);
+
+ expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([[framed, 1, 'c-test']]);
+ expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual([framed]);
+ });
});
describe('TerminalTile leaves the input-socket map', () => {