` (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. ⚠️ **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)
diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js
index 1f18526d..8429e11a 100644
--- a/src/web/public/image-input.js
+++ b/src/web/public/image-input.js
@@ -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
diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js
index de99bb85..3d1e2e7c 100644
--- a/src/web/public/terminal-tile.js
+++ b/src/web/public/terminal-tile.js
@@ -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:
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 76b919ac..65d09766 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -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;
}
diff --git a/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts
index f117f539..94180a7b 100644
--- a/test/image-paste-trap.test.ts
+++ b/test/image-paste-trap.test.ts
@@ -7,9 +7,11 @@
* Ctrl+V: the `document.execCommand('paste')` the function issues itself, and
* the keydown's own default action, which still runs because xterm's custom key
* handler returns false without cancelling the event. A browser that honours
- * execCommand('paste') therefore fires the trap's listener twice, and the
+ * execCommand('paste') (Firefox) therefore produced two paste events, and the
* clipboard text used to reach the PTY twice with it — while right-click →
- * Paste, which involves no keydown, stayed correct.
+ * Paste, which involves no keydown, stayed correct. Firefox delivers the key's
+ * paste as a later task, so it can also miss the trap and reach xterm's
+ * textarea; the trap therefore cancels the keydown once execCommand delivered.
*
* Loads the browser module into a vm sandbox with a fake document, so the tests
* drive the trap's listener directly rather than through a real browser.
@@ -64,6 +66,13 @@ interface Harness {
/** How many trap divs are still attached to the fake body. */
attachedTraps: () => number;
runTimers: () => void;
+ /** How many times the Ctrl+V keydown handed to _handleImagePaste() was cancelled. */
+ keydownCancels: () => number;
+}
+
+interface PastePayload {
+ text?: string;
+ images?: string[];
}
interface PasteHarnessOptions {
@@ -73,6 +82,14 @@ interface PasteHarnessOptions {
localEchoPending?: string;
/** Terminal the Ctrl+V came from, when it is not the primary pane. */
terminal?: TargetTerminal;
+ /**
+ * What `document.execCommand('paste')` delivers to the trap before it
+ * returns. Firefox dispatches a real paste event from inside the call;
+ * Chromium and WebKit refuse the command and dispatch nothing (the default).
+ */
+ execCommandDelivers?: PastePayload;
+ /** Call _handleImagePaste() without the keydown, as an older caller would. */
+ omitKeyEvent?: boolean;
}
function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
@@ -81,6 +98,19 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const attached = new Set();
const timers: Array<() => void> = [];
+ const firePaste = ({ text = '', images = [] }: PastePayload) => {
+ const items = images.map((type) => ({ type, getAsFile: () => ({ type }) }));
+ const event = {
+ clipboardData: {
+ items,
+ getData: () => text,
+ },
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ };
+ for (const fn of listeners) fn(event);
+ };
+
const documentObj = {
createElement: (): FakeTrap => {
const trap: FakeTrap = {
@@ -106,8 +136,12 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
},
},
// A browser that honours the command fires the trap's paste listener from
- // here as well; the tests model that by firing the listener twice.
- execCommand: () => true,
+ // inside this call. Firefox then returns false, because the trap cancels
+ // the event and the command never completes.
+ execCommand: () => {
+ if (options.execCommandDelivers) firePaste(options.execCommandDelivers);
+ return false;
+ },
getElementById: () => null,
};
@@ -184,25 +218,24 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
- const target =
+ let keydownCancels = 0;
+ const keyEvent = {
+ type: 'keydown',
+ key: 'v',
+ ctrlKey: true,
+ preventDefault: () => {
+ keydownCancels += 1;
+ },
+ };
+ const target: Record =
options.sessionId || options.terminal
? { terminal: options.terminal ?? app.terminal, sessionId: options.sessionId || 'session-1' }
- : undefined;
+ : {};
+ if (!options.omitKeyEvent) target.keyEvent = keyEvent;
(app._handleImagePaste as (t?: object) => void).call(app, target);
return {
- firePaste({ text = '', images = [] }) {
- const items = images.map((type) => ({ type, getAsFile: () => ({ type }) }));
- const event = {
- clipboardData: {
- items,
- getData: () => text,
- },
- preventDefault: () => {},
- stopPropagation: () => {},
- };
- for (const fn of listeners) fn(event);
- },
+ firePaste,
pastedText,
inputted,
sentInputs,
@@ -215,6 +248,7 @@ function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const pending = timers.splice(0, timers.length);
for (const fn of pending) fn();
},
+ keydownCancels: () => keydownCancels,
};
}
@@ -378,6 +412,49 @@ describe('Ctrl+V paste trap', () => {
h.runTimers();
expect(h.attachedTraps()).toBe(0);
});
+
+ // Firefox delivers the execCommand paste inside the call and the key's own
+ // paste as a later task. When the trap's cleanup timer runs first, that
+ // later paste lands on xterm's textarea instead of the trap, and xterm
+ // pastes it a second time. Cancelling the keydown is what stops it.
+ it('cancels the keydown when execCommand already delivered the paste', () => {
+ const h = loadPasteHarness({ execCommandDelivers: { text: 'hello world' } });
+
+ expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
+ expect(h.keydownCancels()).toBe(1);
+ });
+
+ it('cancels the split-pane keydown the same way', () => {
+ const paneInputs: string[] = [];
+ const pane: TargetTerminal = { input: (data) => paneInputs.push(data), focus: () => {} };
+ const h = loadPasteHarness({
+ sessionId: 'pane-b',
+ terminal: pane,
+ execCommandDelivers: { text: 'into pane b' },
+ });
+
+ expect(paneInputs).toEqual(['\x1b[200~into pane b\x1b[201~']);
+ expect(h.keydownCancels()).toBe(1);
+ });
+
+ it('leaves the keydown alone when execCommand delivered nothing', () => {
+ // Chromium and WebKit: the key's default action is the only route into
+ // the trap, so cancelling it would lose the paste entirely.
+ const h = loadPasteHarness();
+ expect(h.keydownCancels()).toBe(0);
+
+ h.firePaste({ text: 'hello world' });
+
+ expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
+ expect(h.keydownCancels()).toBe(0);
+ });
+
+ it('still pastes once when called without the keydown', () => {
+ const h = loadPasteHarness({ execCommandDelivers: { text: 'hello world' }, omitKeyEvent: true });
+ h.firePaste({ text: 'hello world' });
+
+ expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
+ });
});
describe('image upload insertion policy', () => {
diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts
index 64999713..ec262d72 100644
--- a/test/terminal-tile-input.test.ts
+++ b/test/terminal-tile-input.test.ts
@@ -741,10 +741,13 @@ describe('TerminalTile links and paste follow THIS pane', () => {
app._handleImagePaste = paste;
const { term } = await connectTile(app);
- const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' });
+ const keyEvent = { type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' };
+ const handled = term.keyHandler!(keyEvent);
expect(handled).toBe(false);
- expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' });
+ // The keydown rides along so the trap can cancel it once execCommand('paste')
+ // has delivered (Firefox), instead of letting a second paste reach xterm.
+ expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile', keyEvent });
});
it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => {