From 3e89d22215f8a7b372f15c1b49f42ce2c419be31 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 18:15:56 -0700 Subject: [PATCH 1/8] Open transcript URLs on modifier click Markdown prose stays terminal-owned: its renderers expose no text-leaf API to highlight or hit-test. --- docs/TUI.md | 28 ++- src/tui/product-host.ts | 10 +- src/tui/shell/row-retext.ts | 8 +- src/tui/shell/transcript.ts | 72 ++++-- src/tui/url-click.test.ts | 126 +++++++++++ src/tui/url-links.ts | 363 +++++++++++++++++++++++++++++++ tests/unit/tui/url-links.test.ts | 146 +++++++++++++ 7 files changed, 728 insertions(+), 25 deletions(-) create mode 100644 src/tui/url-click.test.ts create mode 100644 src/tui/url-links.ts create mode 100644 tests/unit/tui/url-links.test.ts diff --git a/docs/TUI.md b/docs/TUI.md index 27a04d152..296d34505 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -819,6 +819,28 @@ running its own selection. Two chords cover remaining copy needs: 52 escape sequence when no helper is available, e.g. over SSH). On a coalesced tool lane the copy resolves to the most recent call's full output; a single-call row copies its own output, exactly as before. +- **URL click-through (CL-7346).** Holding the platform modifier over an + `http(s)` URL in a plain or structured-text transcript row underlines it; + pressing and releasing on the same URL opens it in the default browser, + while a press that releases anywhere else stays a selection gesture: + - **macOS: Cmd+click.** The terminal itself owns this chord: every link + span carries OSC-8 metadata, so the emulator opens the URL and the app + never sees the press. + - **Linux/Windows: Ctrl+click.** The app opens the URL through the + platform opener (`open` on macOS as fallback, `xdg-open`, `cmd /c +start`). + - Without the modifier, nothing changes: clicks still expand rows and + drags still select-and-copy. With mouse capture off (Alt+M), the + terminal owns every click and the app sees none, so there is nothing to + fight over. Non-`http(s)` targets (`file:`, `mailto:`, `javascript:`, + …) never open anywhere — the gate parses the scheme, it does not + prefix-match. Hover highlighting needs pointer-motion reports, so the + main shell enables them (`enableMouseMovement`, DEC ?1003) alongside + the existing capture; the pickers and setup screens stay opted out. + - Markdown prose (assistant messages) is not covered: the renderer paints + it through childless code renderers with no stable text-leaf API to + highlight or hit-test, so those links stay terminal business until the + library exposes one. Arrow keys never scroll anything — inside the prompt they are caret motion or, at the buffer's edges, prompt-history recall; inside an open overlay's @@ -889,7 +911,11 @@ terminal. It cannot observe: Alt+letter, or similar modifier combinations depends on the terminal negotiating the kitty keyboard protocol (or an equivalent) with the actual host terminal emulator — the headless harness has no such negotiation to - fail or succeed at. + fail or succeed at. URL click-through (CL-7346) inherits this: the unit + and headless suites pin the gating (Ctrl+press opens, plain click and + Ctrl+drag do not, non-`http(s)` never opens) with a mocked opener, but + only a real terminal can show whether it delivers the held Ctrl on motion + and press events, or resolves the `open`/`xdg-open` spawn into a browser. - **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and OSC 52 fallback are exercised with mocked spawn functions in tests; no test round-trips through a real `pbcopy`/`xclip`/terminal clipboard. diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 3b1a9417b..7058d7522 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -280,10 +280,14 @@ export async function mountProductHost( // Cost accepted: this suppresses the terminal's *native* drag-select // in the main shell. OpenTUI selection still works and auto-copies // on mouse-up; Alt+M hands the mouse back when native select is wanted. - // enableMouseMovement stays off (no ?1003): only clicks and wheel - // are needed. + // enableMouseMovement stays on (?1003): URL hover highlighting + // (CL-7346) needs pointer motion with the modifier held — clicks and + // wheel alone never report where an unpressed pointer is. Cost + // accepted alongside the native-drag-select one above: a motion event + // per pointer move while capture is on; Alt+M still hands the mouse + // back when native select is wanted. useMouse: config.useMouse ?? true, - enableMouseMovement: false, + enableMouseMovement: true, // A plain terminal sends a bare CR for both Enter and Shift+Enter, so // the modifier only arrives once the kitty keyboard protocol is // negotiated. Empty object, not explicit flags: this matches what diff --git a/src/tui/shell/row-retext.ts b/src/tui/shell/row-retext.ts index 02b28fc97..2438e6bc7 100644 --- a/src/tui/shell/row-retext.ts +++ b/src/tui/shell/row-retext.ts @@ -7,7 +7,6 @@ */ import { BoxRenderable, - StyledText, TextRenderable, TextTableRenderable, bold as boldChunk, @@ -17,6 +16,7 @@ import { } from "@opentui/core"; import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; +import { splitLinkSpans, paintLinkLine } from "../url-links.js"; import { splitTrailingArrow, expandedRowLines, @@ -125,13 +125,15 @@ function retextBodyLine( const split = splitTrailingArrow(line); if (node instanceof TextRenderable) { if (split !== null) return false; - node.content = new StyledText(diffLineChunks(line)); + // A URL appearing or disappearing repaints on the same node; re-arming + // refreshes the hit ranges, so hover never resolves against stale text. + paintLinkLine(node, [splitLinkSpans(line)]); return true; } if (!(node instanceof BoxRenderable) || split === null) return false; const [bodyNode] = node.getChildren(); if (!(bodyNode instanceof TextRenderable)) return false; - bodyNode.content = new StyledText(diffLineChunks(split.body)); + paintLinkLine(bodyNode, [splitLinkSpans(split.body)]); return true; } diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts index 57ec96171..0f1ede42e 100644 --- a/src/tui/shell/transcript.ts +++ b/src/tui/shell/transcript.ts @@ -12,6 +12,12 @@ import { } from "@opentui/core"; import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; +import { + buildLinkLine, + findLinks, + paintLinkLine, + splitLinkSpans, +} from "../url-links.js"; import { splitAtSettledHeading, withholdIncompleteHeading, @@ -198,7 +204,7 @@ function retextStreamRowBody( return false; if (node instanceof TextRenderable) { if (isMarkdownRow(row)) return false; - node.content = paintStreamRow(row, layout).content; + paintPlainRowNode(node, paintStreamRow(row, layout)); return true; } @@ -347,11 +353,7 @@ export function buildRowNode( } if (!isMarkdownRow(row)) { - const painted = paintStreamRow(row, layout); - return new TextRenderable(ctx, { - content: painted.content, - fg: painted.fg, - }); + return buildPlainRowNode(ctx, paintStreamRow(row, layout)); } const gutter = streamRowGutter(row, layout); @@ -375,6 +377,44 @@ function markdownBodyOptions(gutter: PaintedStreamLine, width: number) { } as const; } +/** + * A literal-text row's paint node: always a single text node, as before. Rows + * holding URLs paint styled text (URL spans carry OSC-8 metadata) and arm as + * Ctrl+click targets; URL-free rows paint the plain string they always have. + */ +function buildPlainRowNode( + ctx: CliRenderer, + painted: PaintedStreamLine, +): TextRenderable { + const node = new TextRenderable(ctx, { + content: painted.content, + fg: painted.fg, + }); + paintPlainRowNode(node, painted); + return node; +} + +/** + * Rewrite a plain row's text on its existing node. The node never changes + * shape, so a URL appearing or disappearing repaints in place instead of + * forcing a rebuild. + */ +function paintPlainRowNode( + node: TextRenderable, + painted: PaintedStreamLine, +): void { + const lines = painted.content.split("\n"); + if (!lines.some((line) => findLinks(line).length > 0)) { + node.content = painted.content; + node.fg = painted.fg; + return; + } + paintLinkLine( + node, + lines.map((line) => splitLinkSpans([{ text: line, fg: painted.fg }])), + ); +} + /** * A markdown row's body. Most rows have no settled heading yet (no heading at * all, or the only one is still the open tail), and paint through a single @@ -396,10 +436,10 @@ function createMarkdownBody( const content = markdownContent(row); const split = splitAtSettledHeading(content); if (split === null) { + // Native incremental block stability: only the trailing block is unstable. return new MarkdownRenderable(ctx, { ...markdownBodyOptions(gutter, width), content, - // Native incremental block stability: only the trailing block is unstable. streaming: row.streaming === true, }); } @@ -454,8 +494,9 @@ function createStyledLinesRowRenderable( /** * One painted body line. A line ending in an expand arrow is split so the - * arrow is its own renderable and can answer a click; every other line is a - * single text node, as before. + * arrow is its own renderable and can answer a click; a line holding URLs + * paints styled text and arms as a Ctrl+click target (see url-links.ts); + * every other line is a single text node, as before. */ function bodyLineNode( ctx: CliRenderer, @@ -464,17 +505,12 @@ function bodyLineNode( ): TextRenderable | BoxRenderable { const split = onToggle === undefined ? null : splitTrailingArrow(line); if (split === null || onToggle === undefined) { - return new TextRenderable(ctx, { - content: new StyledText(diffLineChunks(line)), - }); + return buildLinkLine(ctx, splitLinkSpans(line)); } const wrapper = new BoxRenderable(ctx, { flexDirection: "row", flexGrow: 1 }); - wrapper.add( - new TextRenderable(ctx, { - content: new StyledText(diffLineChunks(split.body)), - flexShrink: 0, - }), - ); + const body = buildLinkLine(ctx, splitLinkSpans(split.body)); + body.flexShrink = 0; + wrapper.add(body); wrapper.add( new TextRenderable(ctx, { content: new StyledText(diffLineChunks([split.arrow])), diff --git a/src/tui/url-click.test.ts b/src/tui/url-click.test.ts new file mode 100644 index 000000000..3b57149d6 --- /dev/null +++ b/src/tui/url-click.test.ts @@ -0,0 +1,126 @@ +/** + * URL click-through (CL-7346): Ctrl+click opens an http(s) URL in the + * default browser; a plain click keeps today's row behavior. + * + * The opener is mocked (setUrlOpener) — no test spawns a real browser. + * Whether a real terminal reports the Ctrl modifier is a harness blind + * spot (docs/TUI.md); headless, the mock delivers it like any click. + */ +import { describe, expect, test } from "bun:test"; + +import { defined } from "../../tests/helpers/defined.js"; +import { withTestRenderer } from "./harness"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { isUnderlined, resetUrlOpener, setUrlOpener } from "./url-links"; +import { type StreamRow } from "./stream"; + +const CALL: StreamRow = { + role: "tool", + text: "", + meta: "web_fetch", + verb: "Web Fetch", + summary: "see https://www.example.com/docs for details", + detail: [[{ text: "url: https://www.example.com/docs", fg: "#f7ead5" }]], +}; + +/** Screen position of the first cell of `needle`, or null when not painted. */ +function findCell( + frame: string, + needle: string, +): { readonly x: number; readonly y: number } | null { + const lines = frame.split("\n"); + for (const [y, line] of lines.entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + return null; +} + +describe("Ctrl+clicking a transcript URL", () => { + test("opens it, while plain click and Ctrl+drag do not", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + appendStreamRow(shell, CALL); + await h.renderOnce(); + + const link = findCell(h.captureCharFrame(), "example.com"); + expect(link).not.toBeNull(); + const at = defined(link); + + await h.mockMouse.click(at.x, at.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual(["https://www.example.com/docs"]); + + opened.length = 0; + await h.mockMouse.click(at.x, at.y); + await h.renderOnce(); + expect(opened).toEqual([]); + expect(shell.streamLog[0]?.expanded).not.toBe(true); + + await h.mockMouse.drag(at.x, at.y, at.x + 12, at.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual([]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("Ctrl+hover underlines the link until the pointer leaves it", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + try { + appendStreamRow(shell, CALL); + await h.renderOnce(); + + const link = findCell(h.captureCharFrame(), "example.com"); + expect(link).not.toBeNull(); + const at = defined(link); + + const linkSpanUnderlined = (): boolean => + defined(h.captureSpans().lines[at.y]).spans.some( + (span) => + span.text.includes("example.com") && + isUnderlined(span.attributes), + ); + + await h.mockMouse.moveTo(at.x, at.y, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(linkSpanUnderlined()).toBe(true); + + await h.mockMouse.moveTo(at.x, at.y); + await h.renderOnce(); + expect(linkSpanUnderlined()).toBe(false); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts new file mode 100644 index 000000000..c094a3c12 --- /dev/null +++ b/src/tui/url-links.ts @@ -0,0 +1,363 @@ +/** + * URL click-through (CL-7346): Ctrl+click opens http(s) URLs in the + * transcript's plain and structured text rows, and holding Ctrl over one + * highlights it first. + * + * Markdown prose is out of scope: the library paints it through childless + * code renderers with no stable text-leaf API to arm or hit-test, so + * assistant-message links stay terminal business for now (see docs/TUI.md). + * + * The gesture is modifier-gated end to end. Without the modifier nothing here + * runs: rows keep today's expand and selection behavior, and with mouse + * capture off (Alt+M) the terminal owns every click because OpenTUI never + * sees one. Only http(s) targets ever open; every other scheme is ignored. + */ +import { + StyledText, + TextAttributes, + TextRenderable, + bold as boldChunk, + fg as fgChunk, + link as linkChunk, + underline as underlineChunk, + type CliRenderer, + type MouseEvent, + type TextChunk, +} from "@opentui/core"; +import { stringWidth } from "./view/height.js"; +import { UI } from "./theme.js"; + +/** A styled text run split so URL runs carry their target. */ +export interface LinkSpan { + readonly text: string; + readonly fg: string; + readonly bold?: boolean | undefined; + readonly url: string | null; +} + +interface LinkHit { + readonly url: string; + readonly start: number; + readonly end: number; +} + +const URL_PATTERN = /https?:\/\/[^\s<>"'`\]]+/g; +const TRAILING_PUNCTUATION = new Set([ + ".", + ",", + ";", + ":", + "!", + "?", + "'", + '"', + "]", + "}", + ">", +]); + +/** + * Only http(s) targets ever open. Markdown authors can point a link at any + * scheme (`javascript:`, `file:`, `mailto:`), so the gate parses rather than + * prefix-matching. + */ +export function isOpenableUrl(url: string): boolean { + try { + const protocol = new URL(url).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + +/** http(s) runs inside plain text, without trailing prose punctuation. */ +export function findLinks(text: string): LinkHit[] { + const hits: LinkHit[] = []; + URL_PATTERN.lastIndex = 0; + for (const match of text.matchAll(URL_PATTERN)) { + let end = match.index + match[0].length; + while (end > match.index) { + const tail = text[end - 1]; + if (tail === undefined || !TRAILING_PUNCTUATION.has(tail)) break; + end -= 1; + } + let depth = 0; + for (let i = match.index; i < end; i += 1) { + if (text[i] === "(") depth += 1; + if (text[i] === ")") depth -= 1; + } + while (end > match.index && text[end - 1] === ")" && depth < 0) { + end -= 1; + depth += 1; + } + if (end > match.index) + hits.push({ url: text.slice(match.index, end), start: match.index, end }); + } + return hits; +} + +/** Split styled segments so URL runs become their own spans. */ +export function splitLinkSpans( + segments: readonly { text: string; fg: string; bold?: boolean | undefined }[], +): LinkSpan[] { + const spans: LinkSpan[] = []; + for (const segment of segments) { + const hits = findLinks(segment.text); + if (hits.length === 0) { + spans.push({ + text: segment.text, + fg: segment.fg, + bold: segment.bold, + url: null, + }); + continue; + } + let cursor = 0; + for (const hit of hits) { + if (hit.start > cursor) + spans.push({ + text: segment.text.slice(cursor, hit.start), + fg: segment.fg, + bold: segment.bold, + url: null, + }); + spans.push({ + text: hit.url, + fg: segment.fg, + bold: segment.bold, + url: hit.url, + }); + cursor = hit.end; + } + if (cursor < segment.text.length) + spans.push({ + text: segment.text.slice(cursor), + fg: segment.fg, + bold: segment.bold, + url: null, + }); + } + return spans; +} + +/** Native chunks for one span: link spans carry OSC-8 metadata. */ +export function linkSpanChunks( + span: LinkSpan, + highlighted: boolean, +): TextChunk[] { + let chunk = fgChunk(span.fg)(span.text); + if (span.bold === true) chunk = boldChunk(chunk); + if (span.url !== null) chunk = linkChunk(span.url)(chunk); + if (highlighted && span.url !== null) + chunk = underlineChunk(fgChunk(UI.inFlightBright)(chunk)); + return [chunk]; +} + +/** + * The open gesture: left press while Ctrl is held. Cmd on macOS is the + * terminal's own OSC-8 click (it handles Cmd+click itself and the app never + * sees the press); Ctrl is what SGR mouse reports carry on every platform. + */ +export function isUrlOpenClick( + event: Pick, +): boolean { + return event.button === 0 && event.modifiers.ctrl === true; +} + +export type UrlOpener = (url: string) => void; + +function defaultUrlOpener(url: string): void { + const command = + process.platform === "darwin" + ? ["open", url] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; + try { + Bun.spawn(command, { + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }).unref(); + } catch { + // Fire-and-forget from a hover/click handler with no status line to + // report to; a missing opener must not break the transcript. + } +} + +let currentOpener: UrlOpener = defaultUrlOpener; + +/** Test seam: swap the browser opener, `resetUrlOpener` restores it. */ +export function setUrlOpener(opener: UrlOpener): void { + currentOpener = opener; +} + +export function resetUrlOpener(): void { + currentOpener = defaultUrlOpener; +} + +/** Open an http(s) URL in the default browser; anything else is ignored. */ +export function openUrl(url: string): void { + if (!isOpenableUrl(url)) return; + try { + currentOpener(url); + } catch { + // Same fire-and-forget contract as the default opener above. + } +} + +/** + * One painted line's openable-URL column ranges. Every armed text node keeps + * a single text node shape — retext and selection never see anything else — + * and resolves clicks through these ranges instead. + */ +export interface LinkColumnHit { + readonly url: string; + /** Inclusive column where the URL starts. */ + readonly start: number; + /** Exclusive column where it ends. */ + readonly end: number; +} + +/** Column ranges of the openable URLs across one line's spans. */ +export function linkColumnHits(spans: readonly LinkSpan[]): LinkColumnHit[] { + const hits: LinkColumnHit[] = []; + let column = 0; + for (const span of spans) { + const width = stringWidth(span.text); + if (span.url !== null && isOpenableUrl(span.url)) + hits.push({ url: span.url, start: column, end: column + width }); + column += width; + } + return hits; +} + +/** The URL under a column, if the column lands on one. */ +export function hitUrlAt( + hits: readonly LinkColumnHit[], + column: number, +): string | null { + for (const hit of hits) { + if (column >= hit.start && column < hit.end) return hit.url; + } + return null; +} + +/** + * One link line's paint node: always a single text node, whether or not it + * holds URLs, so retext and selection see the shape they always have. URL + * spans carry OSC-8 metadata, which is also what lets the terminal own + * Cmd+click on macOS. + */ +export function buildLinkLine( + ctx: CliRenderer, + spans: readonly LinkSpan[], +): TextRenderable { + const node = new TextRenderable(ctx, { + content: new StyledText( + spans.flatMap((span) => linkSpanChunks(span, false)), + ), + }); + armLinkLine(node, [spans]); + return node; +} + +/** + * Rewrite a link line's text on its existing node and re-arm it. The node + * never changes shape, so unlike a span-row split this always succeeds — + * callers rebuild only when their own layout (line count, arrow presence) + * changes, exactly as before. + */ +export function paintLinkLine( + node: TextRenderable, + lines: readonly (readonly LinkSpan[])[], +): void { + node.content = new StyledText(linkLinesChunks(lines, null)); + armLinkLine(node, lines); +} + +/** + * Chunks for caller-built per-line spans, joining lines with newlines and + * marking the highlighted URL underlined while keeping its own color. + */ +function linkLinesChunks( + lines: readonly (readonly LinkSpan[])[], + highlighted: string | null, +): TextChunk[] { + const chunks: TextChunk[] = []; + for (const [index, spans] of lines.entries()) { + if (index > 0) chunks.push({ __isChunk: true, text: "\n" }); + for (const span of spans) + chunks.push(...linkSpanChunks(span, span.url === highlighted)); + } + return chunks; +} + +/** + * Arm a text node as a link hit target over caller-built per-line spans: + * Ctrl+hover highlights the URL under the pointer, Ctrl+press and release on + * the same URL opens it. A no-op when no line holds a URL, so URL-free nodes + * carry no handlers at all; handler assignment replaces, so re-arming after + * a retext never stacks. + * + * The press deliberately keeps bubbling — stopping it would break drag-select + * starting on a URL — and the open fires on release only when the pointer + * resolves to the same URL it pressed on, so a Ctrl+drag still selects. + * Columns map over the unwrapped line; on a wrapped line the continuation + * rows resolve against the same ranges, and the press/release equality check + * keeps a stray resolution from opening. + */ +export function armLinkLine( + node: TextRenderable, + lines: readonly (readonly LinkSpan[])[], +): void { + const hits = lines.map(linkColumnHits); + if (!hits.some((line) => line.length > 0)) return; + const at = (event: MouseEvent): string | null => { + // Events carry terminal-absolute coordinates with no per-node transform, + // so map through the node's own screen position (scroll-aware through the + // translate chain, matching the dispatch hit test). + const line = Math.max(0, Math.min(hits.length - 1, event.y - node.screenY)); + return hitUrlAt(hits[line] ?? [], event.x - node.screenX); + }; + const repaint = (highlighted: string | null): void => { + node.content = new StyledText(linkLinesChunks(lines, highlighted)); + }; + let press: string | null = null; + let hover: string | null = null; + node.onMouseDown = (event) => { + press = isUrlOpenClick(event) ? at(event) : null; + }; + node.onMouseUp = (event) => { + const start = press; + press = null; + if (start !== null && isUrlOpenClick(event) && at(event) === start) + openUrl(start); + }; + node.onMouseOver = (event) => { + if (event.modifiers.ctrl !== true) return; + const url = at(event); + if (url !== hover) { + hover = url; + repaint(url); + } + }; + node.onMouseMove = (event) => { + const url = event.modifiers.ctrl === true ? at(event) : null; + if (url !== hover) { + hover = url; + repaint(url); + } + }; + node.onMouseOut = () => { + press = null; + if (hover !== null) { + hover = null; + repaint(null); + } + }; +} + +export function isUnderlined(attributes: number): boolean { + return (attributes & TextAttributes.UNDERLINE) !== 0; +} diff --git a/tests/unit/tui/url-links.test.ts b/tests/unit/tui/url-links.test.ts new file mode 100644 index 000000000..ec64ffadc --- /dev/null +++ b/tests/unit/tui/url-links.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { + findLinks, + isOpenableUrl, + isUrlOpenClick, + openUrl, + setUrlOpener, + resetUrlOpener, + splitLinkSpans, +} from "../../../src/tui/url-links.js"; + +afterEach(() => { + resetUrlOpener(); +}); + +describe("isOpenableUrl", () => { + test("opens http and https targets", () => { + expect(isOpenableUrl("http://example.com")).toBe(true); + expect(isOpenableUrl("https://example.com/docs?q=1#frag")).toBe(true); + expect(isOpenableUrl("https://localhost:11434")).toBe(true); + }); + + test("never opens non-http(s) schemes", () => { + expect(isOpenableUrl("ftp://example.com/x")).toBe(false); + expect(isOpenableUrl("file:///etc/hosts")).toBe(false); + expect(isOpenableUrl("mailto:a@b.com")).toBe(false); + expect(isOpenableUrl("javascript:alert(1)")).toBe(false); + expect(isOpenableUrl("data:text/plain,hi")).toBe(false); + }); + + test("rejects non-URLs", () => { + expect(isOpenableUrl("")).toBe(false); + expect(isOpenableUrl("not a url")).toBe(false); + expect(isOpenableUrl("example.com")).toBe(false); + }); +}); + +describe("findLinks", () => { + test("finds an http(s) URL with exact offsets", () => { + const text = "see https://example.com/docs ok"; + expect(findLinks(text)).toEqual([ + { + url: "https://example.com/docs", + start: 4, + end: 4 + "https://example.com/docs".length, + }, + ]); + }); + + test("finds several URLs on one line", () => { + const hits = findLinks("a https://one.example b http://two.example/c"); + expect(hits.map((hit) => hit.url)).toEqual([ + "https://one.example", + "http://two.example/c", + ]); + }); + + test("strips trailing prose punctuation", () => { + expect(findLinks("see https://example.com/x.").map((h) => h.url)).toEqual([ + "https://example.com/x", + ]); + expect( + findLinks("(see https://example.com/x), ok").map((h) => h.url), + ).toEqual(["https://example.com/x"]); + }); + + test("keeps balanced parens, drops a wrapping one", () => { + expect( + findLinks("https://en.wikipedia.org/wiki/PC_(personal)").map( + (h) => h.url, + ), + ).toEqual(["https://en.wikipedia.org/wiki/PC_(personal)"]); + expect(findLinks("(https://example.com/y)").map((h) => h.url)).toEqual([ + "https://example.com/y", + ]); + }); + + test("ignores non-http(s) schemes and bare words", () => { + expect(findLinks("grab ftp://x/y or mailto:a@b, see example.com")).toEqual( + [], + ); + }); +}); + +describe("splitLinkSpans", () => { + test("passes URL-free segments through untouched", () => { + expect(splitLinkSpans([{ text: "plain", fg: "#fff", bold: true }])).toEqual( + [{ text: "plain", fg: "#fff", bold: true, url: null }], + ); + }); + + test("splits a URL run into its own span, keeping style", () => { + expect( + splitLinkSpans([{ text: "see https://example.com/x ok", fg: "#abc" }]), + ).toEqual([ + { text: "see ", fg: "#abc", bold: undefined, url: null }, + { + text: "https://example.com/x", + fg: "#abc", + bold: undefined, + url: "https://example.com/x", + }, + { text: " ok", fg: "#abc", bold: undefined, url: null }, + ]); + }); +}); + +describe("isUrlOpenClick", () => { + test("is a left press with Ctrl held, and nothing else", () => { + const none = { shift: false, alt: false, ctrl: false } as const; + const ctrl = { shift: false, alt: false, ctrl: true } as const; + const base = { x: 1, y: 1, modifiers: none } as const; + expect(isUrlOpenClick({ ...base, button: 0, modifiers: ctrl })).toBe(true); + expect(isUrlOpenClick({ ...base, button: 0 })).toBe(false); + expect(isUrlOpenClick({ ...base, button: 2, modifiers: ctrl })).toBe(false); + expect(isUrlOpenClick({ ...base, button: 1, modifiers: ctrl })).toBe(false); + }); +}); + +describe("openUrl", () => { + test("calls the opener with the exact URL (mocked)", () => { + const calls: string[] = []; + setUrlOpener((url) => { + calls.push(url); + }); + openUrl("https://example.com/docs?a=1"); + expect(calls).toEqual(["https://example.com/docs?a=1"]); + }); + + test("never calls the opener for non-http(s) targets", () => { + const calls: string[] = []; + setUrlOpener((url) => { + calls.push(url); + }); + openUrl("file:///etc/hosts"); + openUrl("javascript:alert(1)"); + expect(calls).toEqual([]); + }); + + test("a throwing opener does not propagate", () => { + setUrlOpener(() => { + throw new Error("no browser"); + }); + expect(() => openUrl("https://example.com")).not.toThrow(); + }); +}); From 569eff59cdf2a336e0bdb100245ff59a92225f86 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 18:37:58 -0700 Subject: [PATCH 2/8] Disarm stale URL handlers, match uppercase schemes, hedge OSC-8 docs A retext that dropped the last URL left the old hit ranges armed, so Ctrl+clicking the old columns still opened. The armer now clears its handlers when no line holds a URL. --- docs/TUI.md | 14 +++++-- src/tui/shell/transcript.ts | 4 ++ src/tui/url-click.test.ts | 67 +++++++++++++++++++++++++++++++- src/tui/url-links.ts | 18 ++++++--- tests/unit/tui/url-links.test.ts | 15 +++++++ 5 files changed, 108 insertions(+), 10 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 296d34505..791fdb96d 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -823,12 +823,17 @@ running its own selection. Two chords cover remaining copy needs: `http(s)` URL in a plain or structured-text transcript row underlines it; pressing and releasing on the same URL opens it in the default browser, while a press that releases anywhere else stays a selection gesture: - - **macOS: Cmd+click.** The terminal itself owns this chord: every link - span carries OSC-8 metadata, so the emulator opens the URL and the app - never sees the press. + - **macOS: Cmd+click.** The terminal itself owns this chord: link spans + carry OSC-8 metadata, so an emulator with OSC-8 support opens the URL + and the app never sees the press. Per-terminal: Terminal.app does not + support OSC-8, so Cmd+click does nothing there; iTerm2 3.5+, Ghostty, + WezTerm, Kitty, and VS Code support it. - **Linux/Windows: Ctrl+click.** The app opens the URL through the platform opener (`open` on macOS as fallback, `xdg-open`, `cmd /c start`). + - **Right-click safety.** The open gesture requires a left (button-0) + press with the modifier held, so Ctrl+right-click never opens a URL — + context menus stay safe. - Without the modifier, nothing changes: clicks still expand rows and drags still select-and-copy. With mouse capture off (Alt+M), the terminal owns every click and the app sees none, so there is nothing to @@ -915,7 +920,8 @@ terminal. It cannot observe: and headless suites pin the gating (Ctrl+press opens, plain click and Ctrl+drag do not, non-`http(s)` never opens) with a mocked opener, but only a real terminal can show whether it delivers the held Ctrl on motion - and press events, or resolves the `open`/`xdg-open` spawn into a browser. + and press events, whether it honors OSC-8 for Cmd+click, or resolves the + `open`/`xdg-open` spawn into a browser. - **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and OSC 52 fallback are exercised with mocked spawn functions in tests; no test round-trips through a real `pbcopy`/`xclip`/terminal clipboard. diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts index 0f1ede42e..40d3df55c 100644 --- a/src/tui/shell/transcript.ts +++ b/src/tui/shell/transcript.ts @@ -13,6 +13,7 @@ import { import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; import { + armLinkLine, buildLinkLine, findLinks, paintLinkLine, @@ -407,6 +408,9 @@ function paintPlainRowNode( if (!lines.some((line) => findLinks(line).length > 0)) { node.content = painted.content; node.fg = painted.fg; + // Route through the armer so a retext that drops the last URL disarms + // the handlers a previous arming installed (armLinkLine clears them). + armLinkLine(node, []); return; } paintLinkLine( diff --git a/src/tui/url-click.test.ts b/src/tui/url-click.test.ts index 3b57149d6..38b62bb9e 100644 --- a/src/tui/url-click.test.ts +++ b/src/tui/url-click.test.ts @@ -7,12 +7,19 @@ * spot (docs/TUI.md); headless, the mock delivers it like any click. */ import { describe, expect, test } from "bun:test"; +import { TextRenderable } from "@opentui/core"; import { defined } from "../../tests/helpers/defined.js"; import { withTestRenderer } from "./harness"; import { appendStreamRow } from "./shell/chrome"; import { createAppShell } from "./shell/index"; -import { isUnderlined, resetUrlOpener, setUrlOpener } from "./url-links"; +import { + isUnderlined, + paintLinkLine, + resetUrlOpener, + setUrlOpener, + splitLinkSpans, +} from "./url-links"; import { type StreamRow } from "./stream"; const CALL: StreamRow = { @@ -123,4 +130,62 @@ describe("Ctrl+clicking a transcript URL", () => { { width: 80, height: 24 }, ); }); + + test("retexting the URL away disarms the node: old columns open nothing", async () => { + await withTestRenderer( + async (h) => { + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + const line = "see https://example.com/x ok"; + const node = new TextRenderable(h.renderer, { content: line }); + h.root.add(node); + paintLinkLine(node, [splitLinkSpans([{ text: line, fg: "#fff" }])]); + await h.renderOnce(); + + const link = findCell(h.captureCharFrame(), "example.com"); + expect(link).not.toBeNull(); + const at = defined(link); + + await h.mockMouse.click(at.x, at.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual(["https://example.com/x"]); + + // Retext the URL away, exactly as the row retext path does. The + // handlers are setter-only (no getter to assert on), so pin the + // disarm behaviorally: the old columns open nothing and hover + // leaves the painted text intact. + const retexted = "see nothing here"; + paintLinkLine(node, [ + splitLinkSpans([{ text: retexted, fg: "#fff" }]), + ]); + await h.renderOnce(); + const frame = h.captureCharFrame(); + expect(frame).toContain("nothing here"); + expect(frame).not.toContain("example.com"); + + opened.length = 0; + await h.mockMouse.click(at.x, at.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual([]); + + const before = h.captureCharFrame(); + await h.mockMouse.moveTo(at.x, at.y, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(h.captureCharFrame()).toBe(before); + } finally { + resetUrlOpener(); + } + }, + { width: 80, height: 24 }, + ); + }); }); diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts index c094a3c12..69b841a5d 100644 --- a/src/tui/url-links.ts +++ b/src/tui/url-links.ts @@ -41,7 +41,7 @@ interface LinkHit { readonly end: number; } -const URL_PATTERN = /https?:\/\/[^\s<>"'`\]]+/g; +const URL_PATTERN = /https?:\/\/[^\s<>"'`\]]+/gi; const TRAILING_PUNCTUATION = new Set([ ".", ",", @@ -296,9 +296,10 @@ function linkLinesChunks( /** * Arm a text node as a link hit target over caller-built per-line spans: * Ctrl+hover highlights the URL under the pointer, Ctrl+press and release on - * the same URL opens it. A no-op when no line holds a URL, so URL-free nodes - * carry no handlers at all; handler assignment replaces, so re-arming after - * a retext never stacks. + * the same URL opens it. When no line holds a URL the node is disarmed — any + * handlers a previous arming installed are cleared — so a retext that drops + * the last URL leaves no stale hit target behind; handler assignment + * replaces, so re-arming after a retext never stacks. * * The press deliberately keeps bubbling — stopping it would break drag-select * starting on a URL — and the open fires on release only when the pointer @@ -312,7 +313,14 @@ export function armLinkLine( lines: readonly (readonly LinkSpan[])[], ): void { const hits = lines.map(linkColumnHits); - if (!hits.some((line) => line.length > 0)) return; + if (!hits.some((line) => line.length > 0)) { + node.onMouseDown = undefined; + node.onMouseUp = undefined; + node.onMouseOver = undefined; + node.onMouseMove = undefined; + node.onMouseOut = undefined; + return; + } const at = (event: MouseEvent): string | null => { // Events carry terminal-absolute coordinates with no per-node transform, // so map through the node's own screen position (scroll-aware through the diff --git a/tests/unit/tui/url-links.test.ts b/tests/unit/tui/url-links.test.ts index ec64ffadc..ade07d4a3 100644 --- a/tests/unit/tui/url-links.test.ts +++ b/tests/unit/tui/url-links.test.ts @@ -80,6 +80,12 @@ describe("findLinks", () => { [], ); }); + + test("matches uppercase schemes", () => { + expect(findLinks("see HTTP://EXAMPLE.COM/x ok").map((h) => h.url)).toEqual([ + "HTTP://EXAMPLE.COM/x", + ]); + }); }); describe("splitLinkSpans", () => { @@ -143,4 +149,13 @@ describe("openUrl", () => { }); expect(() => openUrl("https://example.com")).not.toThrow(); }); + + test("an uppercase URL round-trips through the opener", () => { + const calls: string[] = []; + setUrlOpener((url) => { + calls.push(url); + }); + openUrl("HTTP://EXAMPLE.COM/x"); + expect(calls).toEqual(["HTTP://EXAMPLE.COM/x"]); + }); }); From c59b60cd377c972117d0cf4ba4c2ebfba9f7b10a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 18:46:21 -0700 Subject: [PATCH 3/8] Route stale-handler regression through the row retext path --- src/tui/url-click.test.ts | 67 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/tui/url-click.test.ts b/src/tui/url-click.test.ts index 38b62bb9e..77dfd9fe7 100644 --- a/src/tui/url-click.test.ts +++ b/src/tui/url-click.test.ts @@ -11,7 +11,7 @@ import { TextRenderable } from "@opentui/core"; import { defined } from "../../tests/helpers/defined.js"; import { withTestRenderer } from "./harness"; -import { appendStreamRow } from "./shell/chrome"; +import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome"; import { createAppShell } from "./shell/index"; import { isUnderlined, @@ -188,4 +188,69 @@ describe("Ctrl+clicking a transcript URL", () => { { width: 80, height: 24 }, ); }); + + test("retexting a plain row's URL away through the row path disarms it", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + // A user row paints literal text through paintPlainRowNode, so the + // arm and the later disarm both run on the production row path. + appendStreamRow(shell, { + role: "user", + text: "see https://example.com/x ok", + }); + await h.renderOnce(); + + const link = findCell(h.captureCharFrame(), "example.com"); + expect(link).not.toBeNull(); + const at = defined(link); + + await h.mockMouse.click(at.x, at.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual(["https://example.com/x"]); + + // Retext in place through replaceStreamRowAt -> retextStreamRow -> + // paintPlainRowNode's URL-free branch. Reverting that branch's + // disarm must fail this test (stale handlers survive on the node). + replaceStreamRowAt(shell, 0, { + role: "user", + text: "see nothing here", + }); + await h.renderOnce(); + const frame = h.captureCharFrame(); + expect(frame).toContain("nothing here"); + expect(frame).not.toContain("example.com"); + + opened.length = 0; + await h.mockMouse.click(at.x, at.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual([]); + + const before = h.captureCharFrame(); + await h.mockMouse.moveTo(at.x, at.y, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(h.captureCharFrame()).toBe(before); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); }); From b6563947da1cb9a9959ada05a126554d106cd965 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 21:51:30 -0700 Subject: [PATCH 4/8] Open Windows transcript URLs without cmd /c cmd.exe re-parses the assembled command line, so &, | and && in an attacker-influenceable transcript URL would execute as command separators. Route win32 through rundll32 url.dll,FileProtocolHandler with the URL as a plain argv element instead; no shell is involved on any platform. --- docs/TUI.md | 6 +++--- src/tui/url-links.ts | 20 ++++++++++++++------ tests/unit/tui/url-links.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 791fdb96d..be8342bd0 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -829,8 +829,8 @@ running its own selection. Two chords cover remaining copy needs: support OSC-8, so Cmd+click does nothing there; iTerm2 3.5+, Ghostty, WezTerm, Kitty, and VS Code support it. - **Linux/Windows: Ctrl+click.** The app opens the URL through the - platform opener (`open` on macOS as fallback, `xdg-open`, `cmd /c -start`). + platform opener (`open` on macOS as fallback, `xdg-open`, `rundll32 + url.dll,FileProtocolHandler` — argv spawns, never through a shell). - **Right-click safety.** The open gesture requires a left (button-0) press with the modifier held, so Ctrl+right-click never opens a URL — context menus stay safe. @@ -921,7 +921,7 @@ terminal. It cannot observe: Ctrl+drag do not, non-`http(s)` never opens) with a mocked opener, but only a real terminal can show whether it delivers the held Ctrl on motion and press events, whether it honors OSC-8 for Cmd+click, or resolves the - `open`/`xdg-open` spawn into a browser. + `open`/`xdg-open`/`rundll32` spawn into a browser. - **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and OSC 52 fallback are exercised with mocked spawn functions in tests; no test round-trips through a real `pbcopy`/`xclip`/terminal clipboard. diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts index 69b841a5d..e86426bcd 100644 --- a/src/tui/url-links.ts +++ b/src/tui/url-links.ts @@ -166,13 +166,21 @@ export function isUrlOpenClick( export type UrlOpener = (url: string) => void; +/** + * Argv for opening a URL with the platform handler, without a shell. Windows + * must never route through `cmd /c start`: cmd.exe re-parses the assembled + * command line, so `&`, `|` and `&&` in an attacker-influenceable transcript + * URL would execute as command separators. `rundll32 url.dll,FileProtocolHandler` + * takes the URL as a plain argv element instead. + */ +export function platformUrlCommand(platform: string, url: string): string[] { + if (platform === "darwin") return ["open", url]; + if (platform === "win32") return ["rundll32", "url.dll,FileProtocolHandler", url]; + return ["xdg-open", url]; +} + function defaultUrlOpener(url: string): void { - const command = - process.platform === "darwin" - ? ["open", url] - : process.platform === "win32" - ? ["cmd", "/c", "start", "", url] - : ["xdg-open", url]; + const command = platformUrlCommand(process.platform, url); try { Bun.spawn(command, { stdout: "ignore", diff --git a/tests/unit/tui/url-links.test.ts b/tests/unit/tui/url-links.test.ts index ade07d4a3..7d972a986 100644 --- a/tests/unit/tui/url-links.test.ts +++ b/tests/unit/tui/url-links.test.ts @@ -4,6 +4,7 @@ import { isOpenableUrl, isUrlOpenClick, openUrl, + platformUrlCommand, setUrlOpener, resetUrlOpener, splitLinkSpans, @@ -123,6 +124,29 @@ describe("isUrlOpenClick", () => { }); }); +describe("platformUrlCommand", () => { + test("windows opens without cmd /c so metacharacters never parse", () => { + const url = "https://example.com/x?a=1&b=2"; + expect(platformUrlCommand("win32", url)).toEqual([ + "rundll32", + "url.dll,FileProtocolHandler", + url, + ]); + expect(platformUrlCommand("win32", url)).not.toContain("cmd"); + }); + + test("darwin and linux use their openers with the URL as argv", () => { + expect(platformUrlCommand("darwin", "https://example.com")).toEqual([ + "open", + "https://example.com", + ]); + expect(platformUrlCommand("linux", "https://example.com")).toEqual([ + "xdg-open", + "https://example.com", + ]); + }); +}); + describe("openUrl", () => { test("calls the opener with the exact URL (mocked)", () => { const calls: string[] = []; From 05fab1585e88dbd80c2d50cc8654a2d5c6646cc5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 21:55:52 -0700 Subject: [PATCH 5/8] Wrap the Windows opener argv for the formatter --- src/tui/url-links.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts index e86426bcd..d990c543c 100644 --- a/src/tui/url-links.ts +++ b/src/tui/url-links.ts @@ -175,7 +175,8 @@ export type UrlOpener = (url: string) => void; */ export function platformUrlCommand(platform: string, url: string): string[] { if (platform === "darwin") return ["open", url]; - if (platform === "win32") return ["rundll32", "url.dll,FileProtocolHandler", url]; + if (platform === "win32") + return ["rundll32", "url.dll,FileProtocolHandler", url]; return ["xdg-open", url]; } From e2342c64cab4430a31695a2fe012b41a1f65da6e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 23:16:11 -0700 Subject: [PATCH 6/8] Fix TUI.md list continuation indent for the formatter --- docs/TUI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TUI.md b/docs/TUI.md index be8342bd0..50127a130 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -830,7 +830,7 @@ running its own selection. Two chords cover remaining copy needs: WezTerm, Kitty, and VS Code support it. - **Linux/Windows: Ctrl+click.** The app opens the URL through the platform opener (`open` on macOS as fallback, `xdg-open`, `rundll32 - url.dll,FileProtocolHandler` — argv spawns, never through a shell). +url.dll,FileProtocolHandler` — argv spawns, never through a shell). - **Right-click safety.** The open gesture requires a left (button-0) press with the modifier held, so Ctrl+right-click never opens a URL — context menus stay safe. From 9ed55099d9645b4a0a79fc9182a9de2a127b9360 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 11:42:50 -0700 Subject: [PATCH 7/8] Arm wrapped URLs whole and cover agent plain rows --- docs/TUI.md | 2 + src/tui/shell/transcript.ts | 37 +++- src/tui/stream.ts | 19 ++ src/tui/url-click.test.ts | 136 +++++++++++++++ src/tui/url-links.ts | 290 ++++++++++++++++++++++++++----- tests/unit/tui/url-links.test.ts | 113 ++++++++++++ 6 files changed, 552 insertions(+), 45 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 50127a130..ecb19e07b 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -842,6 +842,8 @@ url.dll,FileProtocolHandler` — argv spawns, never through a shell). prefix-match. Hover highlighting needs pointer-motion reports, so the main shell enables them (`enableMouseMovement`, DEC ?1003) alongside the existing capture; the pickers and setup screens stay opted out. + - Wrapped URLs resolve whole: a URL broken across continuation lines + highlights and opens as the one target from any of its fragments. - Markdown prose (assistant messages) is not covered: the renderer paints it through childless code renderers with no stable text-leaf API to highlight or hit-test, so those links stay terminal business until the diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts index 40d3df55c..05889520f 100644 --- a/src/tui/shell/transcript.ts +++ b/src/tui/shell/transcript.ts @@ -18,6 +18,7 @@ import { findLinks, paintLinkLine, splitLinkSpans, + splitWrappedLinkSpans, } from "../url-links.js"; import { splitAtSettledHeading, @@ -33,6 +34,7 @@ import { isSentenceRow, MAIN_AGENT, paintStreamRow, + plainRowWrapWidth, rowGroupGap, streamRowGutter, toolRowLines, @@ -205,7 +207,12 @@ function retextStreamRowBody( return false; if (node instanceof TextRenderable) { if (isMarkdownRow(row)) return false; - paintPlainRowNode(node, paintStreamRow(row, layout)); + paintPlainRowNode( + node, + row, + paintStreamRow(row, layout), + plainRowWrapWidth(row, layout), + ); return true; } @@ -354,7 +361,12 @@ export function buildRowNode( } if (!isMarkdownRow(row)) { - return buildPlainRowNode(ctx, paintStreamRow(row, layout)); + return buildPlainRowNode( + ctx, + row, + paintStreamRow(row, layout), + plainRowWrapWidth(row, layout), + ); } const gutter = streamRowGutter(row, layout); @@ -385,16 +397,27 @@ function markdownBodyOptions(gutter: PaintedStreamLine, width: number) { */ function buildPlainRowNode( ctx: CliRenderer, + row: StreamRow, painted: PaintedStreamLine, + wrapWidth: number, ): TextRenderable { const node = new TextRenderable(ctx, { content: painted.content, fg: painted.fg, }); - paintPlainRowNode(node, painted); + paintPlainRowNode(node, row, painted, wrapWidth); return node; } +/** + * Links a plain row's pre-wrap text holds: wrapped fragments reassemble to + * one of these, which is what tells a real wrap across a short fragment line + * apart from a natural line break after the fact. + */ +function plainRowSourceUrls(row: StreamRow): string[] { + return findLinks(`${row.text}\n${row.summary ?? ""}`).map((hit) => hit.url); +} + /** * Rewrite a plain row's text on its existing node. The node never changes * shape, so a URL appearing or disappearing repaints in place instead of @@ -402,7 +425,9 @@ function buildPlainRowNode( */ function paintPlainRowNode( node: TextRenderable, + row: StreamRow, painted: PaintedStreamLine, + wrapWidth: number, ): void { const lines = painted.content.split("\n"); if (!lines.some((line) => findLinks(line).length > 0)) { @@ -415,7 +440,11 @@ function paintPlainRowNode( } paintLinkLine( node, - lines.map((line) => splitLinkSpans([{ text: line, fg: painted.fg }])), + splitWrappedLinkSpans( + lines.map((text) => ({ text: text.trimEnd(), fg: painted.fg })), + wrapWidth, + plainRowSourceUrls(row), + ), ); } diff --git a/src/tui/stream.ts b/src/tui/stream.ts index 8b153e260..09f2cc692 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -387,6 +387,25 @@ function userBubbleLines(text: string, width: number): string[] { ]; } +/** + * The painted width a plain row was wrapped at, for reassembling URLs split + * across continuation lines. Mirrors the three plain-row layouts: the bubble + * wraps its body inside the bar, while thinking and gutter-indented rows fill + * the full width. Keep in sync with userBubbleLines/thinkingLines/indentBody. + */ +export function plainRowWrapWidth(row: StreamRow, layout: RowLayout): number { + if (row.role !== "user") return layout.width; + const barWidth = stringWidth(`${BUBBLE_BAR} `); + const body = Math.max( + 1, + Math.min( + layout.width - barWidth, + Math.ceil(layout.width * BUBBLE_MAX_SHARE), + ), + ); + return body + barWidth; +} + /** * Columns a reasoning block is inset by. The inset plus the faintest text in * the palette is the whole of reasoning's chrome — it carries no marker of its diff --git a/src/tui/url-click.test.ts b/src/tui/url-click.test.ts index 77dfd9fe7..8dd8d3335 100644 --- a/src/tui/url-click.test.ts +++ b/src/tui/url-click.test.ts @@ -253,4 +253,140 @@ describe("Ctrl+clicking a transcript URL", () => { { width: 80, height: 24 }, ); }); + + test("a wrapped URL in a thinking row opens the full target", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 40, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + // Agent thinking paints through the same plain-row path as user + // rows; the long URL wraps mid-run at this width. Before the fix + // the row armed the first fragment as its own truncated target. + const full = + "https://example.com/abcdefghijklmnopqrstuvwxyz0123456789"; + appendStreamRow(shell, { + role: "system", + meta: "thinking", + text: `checking ${full} today`, + }); + await h.renderOnce(); + + const link = findCell(h.captureCharFrame(), "example.com"); + expect(link).not.toBeNull(); + const at = defined(link); + + await h.mockMouse.click(at.x, at.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual([full]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 40, height: 24 }, + ); + }); + + test("a URL wrapped across bubble lines opens the full target", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 40, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + // The bubble body is narrower than the terminal, so the long URL + // wraps across continuation rows. Every fragment must resolve to + // the one target, not to its own truncated text. + const full = + "https://example.com/abcdefghijklmnopqrstuvwxyz0123456789"; + appendStreamRow(shell, { + role: "user", + text: `see ${full} ok`, + }); + await h.renderOnce(); + + const link = findCell(h.captureCharFrame(), "example.com"); + expect(link).not.toBeNull(); + const at = defined(link); + + await h.mockMouse.click(at.x, at.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual([full]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 40, height: 24 }, + ); + }); + + test("assistant markdown links stay terminal business (no opener call)", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + // Markdown prose paints through childless library renderers with + // no text-leaf API to arm or hit-test (docs/TUI.md), so neither + // the bare URL nor the explicit link label opens through us. + // The real terminal owns those cells; this pins that remainder. + appendStreamRow(shell, { + role: "assistant", + text: "see https://example.com/docs and [guide](https://example.com/guide) ok", + }); + // Assistant rows are markdown; their blocks highlight + // asynchronously (see shell.test.ts), so the frame only carries + // the prose after a settle. + await new Promise((resolve) => setTimeout(resolve, 250)); + await h.renderOnce(); + + const bare = findCell(h.captureCharFrame(), "example.com/docs"); + expect(bare).not.toBeNull(); + await h.mockMouse.click(defined(bare).x, defined(bare).y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual([]); + + const label = findCell(h.captureCharFrame(), "guide"); + expect(label).not.toBeNull(); + await h.mockMouse.click(defined(label).x, defined(label).y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual([]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); }); diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts index d990c543c..ade5b9561 100644 --- a/src/tui/url-links.ts +++ b/src/tui/url-links.ts @@ -75,69 +75,277 @@ export function findLinks(text: string): LinkHit[] { const hits: LinkHit[] = []; URL_PATTERN.lastIndex = 0; for (const match of text.matchAll(URL_PATTERN)) { - let end = match.index + match[0].length; - while (end > match.index) { - const tail = text[end - 1]; - if (tail === undefined || !TRAILING_PUNCTUATION.has(tail)) break; - end -= 1; - } - let depth = 0; - for (let i = match.index; i < end; i += 1) { - if (text[i] === "(") depth += 1; - if (text[i] === ")") depth -= 1; - } - while (end > match.index && text[end - 1] === ")" && depth < 0) { - end -= 1; - depth += 1; - } + const end = trimUrlEnd(text, match.index, match.index + match[0].length); if (end > match.index) hits.push({ url: text.slice(match.index, end), start: match.index, end }); } return hits; } +/** + * The end of a URL match once prose punctuation is out: trailing sentence + * punctuation never belongs to the link, and a closing paren only does when + * the match opened one to balance it. + */ +function trimUrlEnd(text: string, start: number, end: number): number { + let trimmed = end; + while (trimmed > start) { + const tail = text[trimmed - 1]; + if (tail === undefined || !TRAILING_PUNCTUATION.has(tail)) break; + trimmed -= 1; + } + let depth = 0; + for (let i = start; i < trimmed; i += 1) { + if (text[i] === "(") depth += 1; + if (text[i] === ")") depth -= 1; + } + while (trimmed > start && text[trimmed - 1] === ")" && depth < 0) { + trimmed -= 1; + depth += 1; + } + return trimmed; +} + /** Split styled segments so URL runs become their own spans. */ export function splitLinkSpans( segments: readonly { text: string; fg: string; bold?: boolean | undefined }[], ): LinkSpan[] { const spans: LinkSpan[] = []; for (const segment of segments) { - const hits = findLinks(segment.text); - if (hits.length === 0) { + spans.push( + ...sliceSpans( + segment, + findLinks(segment.text).map((hit): SliceHit => ({ + ...hit, + text: hit.url, + })), + ), + ); + } + return spans; +} + +/** A hit with the exact text its span paints (trimmed of prose punctuation). */ +interface SliceHit extends LinkHit { + readonly text: string; +} + +/** Cut one segment on explicit hits; a hitless segment stays one null span. */ +function sliceSpans( + segment: { text: string; fg: string; bold?: boolean | undefined }, + hits: readonly SliceHit[], +): LinkSpan[] { + if (hits.length === 0) { + return [ + { text: segment.text, fg: segment.fg, bold: segment.bold, url: null }, + ]; + } + const spans: LinkSpan[] = []; + let cursor = 0; + for (const hit of hits) { + if (hit.start > cursor) spans.push({ - text: segment.text, + text: segment.text.slice(cursor, hit.start), fg: segment.fg, bold: segment.bold, url: null, }); + spans.push({ + text: hit.text, + fg: segment.fg, + bold: segment.bold, + url: hit.url, + }); + cursor = hit.end; + } + if (cursor < segment.text.length) + spans.push({ + text: segment.text.slice(cursor), + fg: segment.fg, + bold: segment.bold, + url: null, + }); + return spans; +} + +/** + * Split pre-wrapped plain-row lines so a URL broken across continuation lines + * resolves to one target: every fragment highlights and opens the full URL. + * + * `wrapWidth` is the painted width the row was wrapped at. Only a full line + * ending in a URL run can start a chain, and only a full line the run + * reaches the end of continues one — a short line ends the chain unless + * nothing textual follows it (end of text, bubble padding), because a short + * line with text after it is a natural break, not a wrap. A chain is accepted + * when its fragments reassemble to one of `sourceUrls`, the links the row's + * pre-wrap text actually holds: word wrap can orphan a short fragment line + * with wrapped text after it (indistinguishable from a natural break by + * geometry alone), and the source is what tells the two apart. Without known + * source URLs the joined candidate still has to scan as exactly one clean + * http(s) URL, which keeps an unfortunate line break (a full line that + * happens to end in a URL, followed by a word) from fusing two unrelated + * runs. That coincidence is indistinguishable from a real wrap after the + * fact, so it stays a documented approximation: it needs a URL ending + * exactly at the wrap edge. A seed with no detectable hit on its own line + * (a hard split inside the scheme or host) only continues through a full + * first line: the full line broke at a wrap edge, while a short next line + * behind a bare scheme reads as prose that happens to scan, not a wrap — + * unless the fragments reassemble to a known source URL, which settles it. + */ +export function splitWrappedLinkSpans( + lines: readonly { text: string; fg: string }[], + wrapWidth: number, + sourceUrls: readonly string[] = [], +): LinkSpan[][] { + const hits = lines.map((line) => + findLinks(line.text).map((hit): SliceHit => ({ ...hit, text: hit.url })), + ); + let index = 0; + while (index < lines.length) { + const line = lines[index]; + const seed = line === undefined ? null : wrapSeed(line.text, wrapWidth); + if (line === undefined || seed === null) { + index += 1; continue; } - let cursor = 0; - for (const hit of hits) { - if (hit.start > cursor) - spans.push({ - text: segment.text.slice(cursor, hit.start), - fg: segment.fg, - bold: segment.bold, - url: null, - }); - spans.push({ - text: hit.url, - fg: segment.fg, - bold: segment.bold, - url: hit.url, - }); - cursor = hit.end; + const chain = followWrapChain( + lines, + index + 1, + seed, + (hits[index] ?? []).some((hit) => hit.end >= seed.end), + wrapWidth, + sourceUrls, + ); + if (chain === null) { + index += 1; + continue; } - if (cursor < segment.text.length) - spans.push({ - text: segment.text.slice(cursor), - fg: segment.fg, - bold: segment.bold, - url: null, + hits[index] = (hits[index] ?? []).filter((hit) => hit.start < seed.start); + hits[index]?.push({ + url: chain.full, + start: seed.start, + end: seed.end, + text: seed.text, + }); + for (const run of chain.runs) { + hits[run.line] = (hits[run.line] ?? []).filter( + (hit) => hit.end <= run.start || hit.start >= run.end, + ); + hits[run.line]?.push({ + url: chain.full, + start: run.start, + end: run.end, + text: lines[run.line]?.text.slice(run.start, run.end) ?? "", }); + } + index = chain.endLine + 1; } - return spans; + return lines.map((line, i) => + sliceSpans( + line, + [...(hits[i] ?? [])].sort((a, b) => a.start - b.start), + ), + ); +} + +/** A full line's trailing URL run seeds a wrapped chain, if URL-shaped. */ +function wrapSeed( + text: string, + wrapWidth: number, +): { + readonly start: number; + readonly end: number; + readonly text: string; +} | null { + if (stringWidth(text) !== wrapWidth) return null; + const run = text.match(/[^\s]+$/)?.[0] ?? ""; + // The :// marks the run as URL-shaped even when a hard split inside the + // scheme or host leaves no detectable hit; the joined candidate still has + // to scan as one clean URL before anything merges. Prose punctuation the + // wrap left at the edge is not part of the seed, same as for a hit. + if (!run.includes("://")) return null; + const start = text.length - run.length; + const end = trimUrlEnd(text, start, text.length); + if (end <= start) return null; + return { start, end, text: text.slice(start, end) }; +} + +/** Fragments a chain picks up past its seed line, through its final line. */ +interface WrapChain { + readonly full: string; + readonly runs: readonly { + readonly line: number; + readonly start: number; + readonly end: number; + }[]; + readonly endLine: number; +} + +/** + * Walk continuation lines past their indent, fusing leading runs onto the + * seed. A run ending mid-line ends the chain; a run reaching its line's end + * continues it only through a full line, and a short line ends the chain + * unless nothing textual follows it (end of text, bubble padding) — a short + * line with text after it is a natural break, not a wrap. A hitless seed + * only continues through a full first line, because a short next line behind + * a bare scheme reads as prose that happens to scan. Against known source + * URLs the chain also ends the moment its fragments reassemble to one of + * them, which is what resolves a wrap the geometry alone cannot see: a + * short fragment line with wrapped text after it. Without source URLs the + * joined candidate has to scan as one clean URL instead. + */ +function followWrapChain( + lines: readonly { text: string; fg: string }[], + from: number, + seed: { readonly start: number; readonly end: number; readonly text: string }, + seedAnchored: boolean, + wrapWidth: number, + sourceUrls: readonly string[], +): WrapChain | null { + let full = seed.text; + const runs: { line: number; start: number; end: number }[] = []; + let line = from; + for (;;) { + const text = lines[line]?.text; + if (text === undefined) break; + const start = text.match(/^[\s▍]*/)?.[0].length ?? 0; + const raw = text.slice(start).match(/^[^\s]+/)?.[0] ?? ""; + if (raw.length === 0) { + if (runs.length === 0 || !isWrapEndLine(text)) return null; + break; + } + const end = trimUrlEnd(text, start, start + raw.length); + if (end <= start) return null; + full += text.slice(start, end); + runs.push({ line, start, end }); + if (sourceUrls.includes(full)) return { full, runs, endLine: line }; + if (runs.length === 1 && !seedAnchored && stringWidth(text) !== wrapWidth) + return null; + if (end !== text.length) break; + if (stringWidth(text) === wrapWidth) { + line += 1; + continue; + } + if (!isWrapEndLine(lines[line + 1]?.text)) return null; + break; + } + if (runs.length === 0) return null; + if (sourceUrls.length > 0) return null; + if (!isOpenableUrl(full)) return null; + const check = findLinks(full); + if (check.length !== 1 || check[0]?.url !== full) return null; + return { full, runs, endLine: runs[runs.length - 1]?.line ?? from }; +} + +/** + * A line nothing textual follows on: the end of the text, or a user-bubble + * pad row (the bare bar with no body). A blank source line is not one — it + * is a natural break. Paint trims each line's trailing space, so the pad + * compares exactly. + */ +function isWrapEndLine(text: string | undefined): boolean { + if (text === undefined) return true; + return text.trimEnd() === "▍"; } /** Native chunks for one span: link spans carry OSC-8 metadata. */ diff --git a/tests/unit/tui/url-links.test.ts b/tests/unit/tui/url-links.test.ts index 7d972a986..ce7330c8f 100644 --- a/tests/unit/tui/url-links.test.ts +++ b/tests/unit/tui/url-links.test.ts @@ -8,6 +8,7 @@ import { setUrlOpener, resetUrlOpener, splitLinkSpans, + splitWrappedLinkSpans, } from "../../../src/tui/url-links.js"; afterEach(() => { @@ -112,6 +113,118 @@ describe("splitLinkSpans", () => { }); }); +describe("splitWrappedLinkSpans", () => { + const urls = (rows: { url: string | null }[][]): (string | null)[][] => + rows.map((row) => row.map((span) => span.url)); + + test("a URL broken across two lines resolves to one target", () => { + const full = "https://example.com/ab"; + const rows = splitWrappedLinkSpans( + [ + { text: "x https://example.co", fg: "#abc" }, + { text: "m/ab", fg: "#abc" }, + ], + 20, + ); + expect(rows).toEqual([ + [ + { text: "x ", fg: "#abc", bold: undefined, url: null }, + { text: "https://example.co", fg: "#abc", bold: undefined, url: full }, + ], + [{ text: "m/ab", fg: "#abc", bold: undefined, url: full }], + ]); + }); + + test("a chain runs through a full middle line to a mid-line end", () => { + const full = "https://example.com/ab"; + const rows = splitWrappedLinkSpans( + [ + { text: "o https://", fg: "#abc" }, + { text: "example.co", fg: "#abc" }, + { text: "m/ab end", fg: "#abc" }, + ], + 10, + ); + expect(urls(rows)).toEqual([[null, full], [full], [full, null]]); + }); + + test("a short seed line never starts a chain", () => { + const rows = splitWrappedLinkSpans( + [ + { text: "x https://", fg: "#abc" }, + { text: "example.com", fg: "#abc" }, + ], + 10, + ); + expect(urls(rows)).toEqual([[null], [null]]); + }); + + test("a short continuation with text after it is a natural break", () => { + const rows = splitWrappedLinkSpans( + [ + { text: "x https://example.co", fg: "#abc" }, + { text: "m/ab", fg: "#abc" }, + { text: "more words here", fg: "#abc" }, + ], + 20, + ); + expect(urls(rows)).toEqual([[null, "https://example.co"], [null], [null]]); + }); + + test("a blank continuation line breaks the chain", () => { + const rows = splitWrappedLinkSpans( + [ + { text: "o https://", fg: "#abc" }, + { text: "", fg: "#abc" }, + ], + 10, + ); + expect(urls(rows)).toEqual([[null], [null]]); + }); + + test("a user-bubble pad row ends the chain", () => { + const full = "https://example.com/ab"; + const rows = splitWrappedLinkSpans( + [ + { text: "o https://", fg: "#abc" }, + { text: "example.co", fg: "#abc" }, + { text: "m/ab", fg: "#abc" }, + { text: "▍", fg: "#abc" }, + ], + 10, + ); + expect(urls(rows)).toEqual([[null, full], [full], [full], [null]]); + }); + + test("a hitless seed with a short tail fuses against its source URL", () => { + // Geometry alone reads this as prose that happens to scan (see the + // short-seed test above), but the row's pre-wrap text settles it: the + // fragments reassemble to a link the row actually holds. + const full = "https://x.y"; + const rows = splitWrappedLinkSpans( + [ + { text: "o https://", fg: "#abc" }, + { text: "x.y", fg: "#abc" }, + ], + 10, + [full], + ); + expect(urls(rows)).toEqual([[null, full], [full]]); + }); + + test("a fused candidate the source never held stays unfused", () => { + const rows = splitWrappedLinkSpans( + [ + { text: "x https://example.co", fg: "#abc" }, + { text: "m/ab", fg: "#abc" }, + ], + 20, + ["https://other.example/z"], + ); + expect(urls(rows)).toEqual([[null, "https://example.co"], [null]]); + }); +}); + describe("isUrlOpenClick", () => { test("is a left press with Ctrl held, and nothing else", () => { const none = { shift: false, alt: false, ctrl: false } as const; From bb436d066fe7cdb17271f2944ea8799e74811ebe Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 13:09:48 -0700 Subject: [PATCH 8/8] Open markdown transcript URLs with Ctrl+click Assistant markdown paints through library renderables the app cannot arm, so those links stayed terminal-owned. The renderer now exposes getLinkAt link hit-testing (OpenTUI 0.5.11), so a bubbling transcript-root handler can resolve markdown clicks and open them the same way armed plain rows already do. --- bun.lock | 36 ++++---- docs/TUI.md | 10 ++- package.json | 18 ++-- src/tui/shell/index.ts | 5 ++ src/tui/url-click.test.ts | 176 ++++++++++++++++++++++++++++++++++---- src/tui/url-links.ts | 54 ++++++++++-- 6 files changed, 246 insertions(+), 53 deletions(-) diff --git a/bun.lock b/bun.lock index 235c3021c..bff566dc4 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,7 @@ "@intx/types": "workspace:*", "@intx/workflow-host": "0.3.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@opentui/core": "0.5.10", + "@opentui/core": "0.5.11", "arktype": "catalog:", "highlight.js": "^11.11.1", "isomorphic-git": "catalog:", @@ -40,14 +40,14 @@ "ws": "^8.21.0", }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.5.10", - "@opentui/core-darwin-x64": "0.5.10", - "@opentui/core-linux-arm64": "0.5.10", - "@opentui/core-linux-arm64-musl": "0.5.10", - "@opentui/core-linux-x64": "0.5.10", - "@opentui/core-linux-x64-musl": "0.5.10", - "@opentui/core-win32-arm64": "0.5.10", - "@opentui/core-win32-x64": "0.5.10", + "@opentui/core-darwin-arm64": "0.5.11", + "@opentui/core-darwin-x64": "0.5.11", + "@opentui/core-linux-arm64": "0.5.11", + "@opentui/core-linux-arm64-musl": "0.5.11", + "@opentui/core-linux-x64": "0.5.11", + "@opentui/core-linux-x64-musl": "0.5.11", + "@opentui/core-win32-arm64": "0.5.11", + "@opentui/core-win32-x64": "0.5.11", }, }, "packages/first-class-providers": { @@ -293,23 +293,23 @@ "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], - "@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="], + "@opentui/core": ["@opentui/core@0.5.11", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", "@opentui/core-darwin-x64": "0.5.11", "@opentui/core-linux-arm64": "0.5.11", "@opentui/core-linux-arm64-musl": "0.5.11", "@opentui/core-linux-x64": "0.5.11", "@opentui/core-linux-x64-musl": "0.5.11", "@opentui/core-win32-arm64": "0.5.11", "@opentui/core-win32-x64": "0.5.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-yP/8GliJDiJNm8YYJKvgWuy6xyCEd8d4GwBVOIzCFOI7ZIbGP8GOTvmwIjRW4Paw20pTttWMWyRoQiCvOXvH2g=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-zBIsRFHlLUYFNhapRSNt9dz4mC8gZ4Wxcfy3A+2AwqsgCipcr2FkIuAXYqN08q+IvqFX7DfqgIFGWDNedHTPUg=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+xeR2LYibvIi/qQetRjJR008sFRve60QuDcO8ItxUwzFeKTDzl5CEiZpBXfm5I4FhRNuyuj0TSPIFadMvrjFQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-pSOXqOADrv+zINOgR3FDFA9zVRaim3zl8/yhtO+X9rEJ6f34z3gDund0Gf88hNJSMpZK5xWtipEVm28RY5VF8w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-MyqOnSs8pTYG2xmFr1xt6xZIuHu2Xu4pkle9my9JdE+WClmusHf0YN9Eas6jQLAq5XUi34r22wMeK0juk93zyw=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-MGGRXIDJ//HyaqC5ndSr7/CUl+ICdYEAMcjbA00UWsthh4ZO/rYhPkyqOaTn5ck6+G4ca3/+M76J+jImMYTjNg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-sMEGX9rhiPd1gBa190jzj5uIdzKCMImxHmr22hJLxIRWqejxWvqGnRBkW68wT8NfMViDiMtZWavgWL7GgmYCAQ=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q=="], diff --git a/docs/TUI.md b/docs/TUI.md index ecb19e07b..abe8e68f1 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -844,10 +844,12 @@ url.dll,FileProtocolHandler` — argv spawns, never through a shell). the existing capture; the pickers and setup screens stay opted out. - Wrapped URLs resolve whole: a URL broken across continuation lines highlights and opens as the one target from any of its fragments. - - Markdown prose (assistant messages) is not covered: the renderer paints - it through childless code renderers with no stable text-leaf API to - highlight or hit-test, so those links stay terminal business until the - library exposes one. + - Markdown prose (assistant messages) is click-to-open only: the renderer + paints it through childless code renderers with no node to arm, so there + is no hover underline. A bubbling handler on the transcript root resolves + the click through the renderer's `getLinkAt` link map (OpenTUI 0.5.11+) + and opens on press-and-release over the same URL — armed rows keep their + hover underline and open through their own node handlers. Arrow keys never scroll anything — inside the prompt they are caret motion or, at the buffer's edges, prompt-history recall; inside an open overlay's diff --git a/package.json b/package.json index 571d6a661..b574ab7c2 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@intx/types": "workspace:*", "@intx/workflow-host": "0.3.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@opentui/core": "0.5.10", + "@opentui/core": "0.5.11", "arktype": "catalog:", "highlight.js": "^11.11.1", "isomorphic-git": "catalog:" @@ -115,13 +115,13 @@ "ws": "^8.21.0" }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.5.10", - "@opentui/core-darwin-x64": "0.5.10", - "@opentui/core-linux-arm64": "0.5.10", - "@opentui/core-linux-arm64-musl": "0.5.10", - "@opentui/core-linux-x64": "0.5.10", - "@opentui/core-linux-x64-musl": "0.5.10", - "@opentui/core-win32-arm64": "0.5.10", - "@opentui/core-win32-x64": "0.5.10" + "@opentui/core-darwin-arm64": "0.5.11", + "@opentui/core-darwin-x64": "0.5.11", + "@opentui/core-linux-arm64": "0.5.11", + "@opentui/core-linux-arm64-musl": "0.5.11", + "@opentui/core-linux-x64": "0.5.11", + "@opentui/core-linux-x64-musl": "0.5.11", + "@opentui/core-win32-arm64": "0.5.11", + "@opentui/core-win32-x64": "0.5.11" } } diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 4dfecb6c8..c49809da0 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -72,6 +72,7 @@ import { submitPrompt, syncPromptHighlights, } from "./prompt.js"; +import { armMarkdownLinks } from "../url-links.js"; import { createShellKeyHandlers, routePromptWheelToTranscript, @@ -211,6 +212,10 @@ export function createAppShell( // every row to say so. Position is legible from the content itself. transcript.verticalScrollBar.visible = false; transcript.horizontalScrollBar.visible = false; + // Markdown blocks have no node of ours to arm; this bubbling handler is + // what makes their links Ctrl+click-to-open (armed rows stop propagation + // after opening, so a click opens exactly once either way). + armMarkdownLinks(transcript, ctx); // Leading filler that bottom-anchors a short transcript; see // `syncTranscriptSpacer`. Zero height until the first sync call. diff --git a/src/tui/url-click.test.ts b/src/tui/url-click.test.ts index 8dd8d3335..71ad11d8b 100644 --- a/src/tui/url-click.test.ts +++ b/src/tui/url-click.test.ts @@ -1,6 +1,9 @@ /** * URL click-through (CL-7346): Ctrl+click opens an http(s) URL in the - * default browser; a plain click keeps today's row behavior. + * default browser; a plain click keeps today's row behavior. Armed + * plain/structured rows open through their own node handlers (with hover + * highlight); assistant markdown opens through the bubbling transcript + * handler — click only, no hover highlight. * * The opener is mocked (setUrlOpener) — no test spawns a real browser. * Whether a real terminal reports the Ctrl modifier is a harness blind @@ -339,7 +342,7 @@ describe("Ctrl+clicking a transcript URL", () => { ); }); - test("assistant markdown links stay terminal business (no opener call)", async () => { + test("assistant markdown bare URL and link label open on Ctrl+click", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -352,31 +355,120 @@ describe("Ctrl+clicking a transcript URL", () => { opened.push(url); }); try { - // Markdown prose paints through childless library renderers with - // no text-leaf API to arm or hit-test (docs/TUI.md), so neither - // the bare URL nor the explicit link label opens through us. - // The real terminal owns those cells; this pins that remainder. + // Markdown blocks paint through childless library renderers, so + // their clicks are only visible through the bubbling transcript + // handler armed by createAppShell. appendStreamRow(shell, { role: "assistant", text: "see https://example.com/docs and [guide](https://example.com/guide) ok", }); // Assistant rows are markdown; their blocks highlight - // asynchronously (see shell.test.ts), so the frame only carries - // the prose after a settle. - await new Promise((resolve) => setTimeout(resolve, 250)); + // asynchronously (see shell.test.ts), so wait for the paint + // instead of sleeping a fixed settle. + const bare = await waitForPaintedCell(h, "example.com/docs"); + await h.mockMouse.click(bare.x, bare.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual(["https://example.com/docs"]); + + opened.length = 0; + const label = await waitForPaintedCell(h, "guide"); + await h.mockMouse.click(label.x, label.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual(["https://example.com/guide"]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("plain click on a markdown link does not open", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + appendStreamRow(shell, { + role: "assistant", + text: "see https://example.com/docs ok", + }); + const bare = await waitForPaintedCell(h, "example.com/docs"); + await h.mockMouse.click(bare.x, bare.y); await h.renderOnce(); + expect(opened).toEqual([]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); - const bare = findCell(h.captureCharFrame(), "example.com/docs"); - expect(bare).not.toBeNull(); - await h.mockMouse.click(defined(bare).x, defined(bare).y, 0, { + test("a markdown link to a non-http(s) target never opens", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + appendStreamRow(shell, { + role: "assistant", + text: "see [target](custom://thing/pull/1) ok", + }); + const label = await waitForPaintedCell(h, "target"); + await h.mockMouse.click(label.x, label.y, 0, { modifiers: { ctrl: true }, }); await h.renderOnce(); expect(opened).toEqual([]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); - const label = findCell(h.captureCharFrame(), "guide"); - expect(label).not.toBeNull(); - await h.mockMouse.click(defined(label).x, defined(label).y, 0, { + test("Ctrl+press on a markdown link, release off it, does not open", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + appendStreamRow(shell, { + role: "assistant", + text: "see https://example.com/docs and more prose here ok", + }); + const bare = await waitForPaintedCell(h, "example.com/docs"); + await h.mockMouse.drag(bare.x, bare.y, bare.x + 30, bare.y, 0, { modifiers: { ctrl: true }, }); await h.renderOnce(); @@ -389,4 +481,58 @@ describe("Ctrl+clicking a transcript URL", () => { { width: 80, height: 24 }, ); }); + + test("Ctrl+click on an armed plain-row link opens exactly once", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const opened: string[] = []; + setUrlOpener((url) => { + opened.push(url); + }); + try { + // The armed row's own release handler opens and stops propagation; + // the transcript-root markdown handler must not see the same + // gesture and open the (getLinkAt-resolved) target a second time. + appendStreamRow(shell, { + role: "user", + text: "see https://example.com/x ok", + }); + const link = await waitForPaintedCell(h, "example.com"); + await h.mockMouse.click(link.x, link.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual(["https://example.com/x"]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); }); + +/** Poll until `needle` paints, rendering between tries. */ +async function waitForPaintedCell( + h: { + renderOnce: () => Promise; + captureCharFrame: () => string; + }, + needle: string, + timeoutMs = 2000, +): Promise<{ readonly x: number; readonly y: number }> { + const deadline = Date.now() + timeoutMs; + for (;;) { + await h.renderOnce(); + const cell = findCell(h.captureCharFrame(), needle); + if (cell !== null) return cell; + if (Date.now() > deadline) throw new Error(`never painted: ${needle}`); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts index ade5b9561..b97daaddf 100644 --- a/src/tui/url-links.ts +++ b/src/tui/url-links.ts @@ -1,11 +1,11 @@ /** * URL click-through (CL-7346): Ctrl+click opens http(s) URLs in the - * transcript's plain and structured text rows, and holding Ctrl over one - * highlights it first. - * - * Markdown prose is out of scope: the library paints it through childless - * code renderers with no stable text-leaf API to arm or hit-test, so - * assistant-message links stay terminal business for now (see docs/TUI.md). + * transcript. Plain and structured rows are armed per node (armLinkLine): + * holding Ctrl over a link highlights it, press-and-release on the same URL + * opens it. Assistant markdown paints through childless library renderers + * with no node to arm, so it is covered by a bubbling handler on the + * transcript root (armMarkdownLinks) that resolves clicks through the + * renderer's getLinkAt link map — click-to-open only, no hover highlight. * * The gesture is modifier-gated end to end. Without the modifier nothing here * runs: rows keep today's expand and selection behavior, and with mouse @@ -22,6 +22,7 @@ import { underline as underlineChunk, type CliRenderer, type MouseEvent, + type Renderable, type TextChunk, } from "@opentui/core"; import { stringWidth } from "./view/height.js"; @@ -556,8 +557,16 @@ export function armLinkLine( node.onMouseUp = (event) => { const start = press; press = null; - if (start !== null && isUrlOpenClick(event) && at(event) === start) + if (start !== null && isUrlOpenClick(event) && at(event) === start) { openUrl(start); + // Armed rows register in the same link map getLinkAt reads, so the + // leaf's open would otherwise be repeated by the transcript-root + // armMarkdownLinks handler this event bubbles to. This handler runs + // first in the bubble; stopping propagation starves the root of the + // release and keeps exactly one open per gesture. The press + // deliberately keeps bubbling so drag-select still works. + event.stopPropagation(); + } }; node.onMouseOver = (event) => { if (event.modifiers.ctrl !== true) return; @@ -586,3 +595,34 @@ export function armLinkLine( export function isUnderlined(attributes: number): boolean { return (attributes & TextAttributes.UNDERLINE) !== 0; } + +/** + * Arm a transcript ancestor as the markdown click target: mouse events bubble + * up from the hit leaf, and markdown blocks paint through childless library + * renderers with no node of ours to arm, so this ancestor handler is the only + * hook that sees their clicks. Ctrl+press stores the link under the pointer + * (getLinkAt reads the same terminal-absolute coordinates events carry); the + * open fires on release only over the same URL, so a press on a link that + * drags away never opens. Armed rows stop propagation after opening + * themselves, so a click there still opens exactly once; everything goes + * through openUrl, which gates to http(s) — markdown links can carry any + * scheme and getLinkAt hands the raw target back. + */ +export function armMarkdownLinks( + target: Renderable, + renderer: CliRenderer, +): void { + let press: string | null = null; + target.onMouseDown = (event) => { + press = isUrlOpenClick(event) ? renderer.getLinkAt(event.x, event.y) : null; + }; + target.onMouseUp = (event) => { + const start = press; + press = null; + if (start === null || !isUrlOpenClick(event)) return; + if (renderer.getLinkAt(event.x, event.y) === start) openUrl(start); + }; + target.onMouseOut = () => { + press = null; + }; +}