` (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/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts
index 94180a7b..0b1961c1 100644
--- a/test/image-paste-trap.test.ts
+++ b/test/image-paste-trap.test.ts
@@ -455,6 +455,22 @@ describe('Ctrl+V paste trap', () => {
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
});
+
+ it('is handed the keydown by the main pane, pinned at the source', () => {
+ // The Ctrl+V branch lives inside initTerminal's attachCustomKeyEventHandler
+ // closure, over a real xterm this harness cannot build, so the call is pinned
+ // at the source, like the Ctrl+C rule in terminal-copy-clean.test.ts. A tile's
+ // call is driven for real in terminal-tile-input.test.ts. Without the keydown
+ // the trap cannot cancel it, and Firefox pastes twice again.
+ const terminalUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
+ const start = terminalUi.indexOf('// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.');
+ expect(start).toBeGreaterThan(-1);
+ const end = terminalUi.indexOf('// Ctrl+Z (SIGTSTP/job-control suspend)', start);
+ expect(end).toBeGreaterThan(start);
+ const branch = terminalUi.slice(start, end);
+ expect(branch).toContain('this._handleImagePaste({ keyEvent: ev });');
+ expect(branch).not.toContain('this._handleImagePaste()');
+ });
});
describe('image upload insertion policy', () => {