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 27a04d152..abe8e68f1 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -819,6 +819,37 @@ 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: 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`, `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. + - 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. + - 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 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 @@ -889,7 +920,12 @@ 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, whether it honors OSC-8 for Cmd+click, or resolves the + `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/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/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/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/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..05889520f 100644 --- a/src/tui/shell/transcript.ts +++ b/src/tui/shell/transcript.ts @@ -12,6 +12,14 @@ import { } from "@opentui/core"; import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; +import { + armLinkLine, + buildLinkLine, + findLinks, + paintLinkLine, + splitLinkSpans, + splitWrappedLinkSpans, +} from "../url-links.js"; import { splitAtSettledHeading, withholdIncompleteHeading, @@ -26,6 +34,7 @@ import { isSentenceRow, MAIN_AGENT, paintStreamRow, + plainRowWrapWidth, rowGroupGap, streamRowGutter, toolRowLines, @@ -198,7 +207,12 @@ function retextStreamRowBody( return false; if (node instanceof TextRenderable) { if (isMarkdownRow(row)) return false; - node.content = paintStreamRow(row, layout).content; + paintPlainRowNode( + node, + row, + paintStreamRow(row, layout), + plainRowWrapWidth(row, layout), + ); return true; } @@ -347,11 +361,12 @@ export function buildRowNode( } if (!isMarkdownRow(row)) { - const painted = paintStreamRow(row, layout); - return new TextRenderable(ctx, { - content: painted.content, - fg: painted.fg, - }); + return buildPlainRowNode( + ctx, + row, + paintStreamRow(row, layout), + plainRowWrapWidth(row, layout), + ); } const gutter = streamRowGutter(row, layout); @@ -375,6 +390,64 @@ 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, + row: StreamRow, + painted: PaintedStreamLine, + wrapWidth: number, +): TextRenderable { + const node = new TextRenderable(ctx, { + content: painted.content, + fg: painted.fg, + }); + 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 + * forcing a rebuild. + */ +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)) { + 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( + node, + splitWrappedLinkSpans( + lines.map((text) => ({ text: text.trimEnd(), fg: painted.fg })), + wrapWidth, + plainRowSourceUrls(row), + ), + ); +} + /** * 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 +469,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 +527,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 +538,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/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 new file mode 100644 index 000000000..71ad11d8b --- /dev/null +++ b/src/tui/url-click.test.ts @@ -0,0 +1,538 @@ +/** + * URL click-through (CL-7346): Ctrl+click opens an http(s) URL in the + * 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 + * 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, replaceStreamRowAt } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { + isUnderlined, + paintLinkLine, + resetUrlOpener, + setUrlOpener, + splitLinkSpans, +} 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 }, + ); + }); + + 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 }, + ); + }); + + 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 }, + ); + }); + + 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 bare URL and link label open on Ctrl+click", 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 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 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 }, + ); + }); + + 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 }, + ); + }); + + 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(); + expect(opened).toEqual([]); + } finally { + resetUrlOpener(); + shell.dispose(); + } + }, + { 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 new file mode 100644 index 000000000..b97daaddf --- /dev/null +++ b/src/tui/url-links.ts @@ -0,0 +1,628 @@ +/** + * URL click-through (CL-7346): Ctrl+click opens http(s) URLs in the + * 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 + * 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 Renderable, + 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<>"'`\]]+/gi; +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)) { + 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) { + 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.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; + } + const chain = followWrapChain( + lines, + index + 1, + seed, + (hits[index] ?? []).some((hit) => hit.end >= seed.end), + wrapWidth, + sourceUrls, + ); + if (chain === null) { + index += 1; + continue; + } + 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 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. */ +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; + +/** + * 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 = platformUrlCommand(process.platform, 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. 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 + * 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)) { + 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 + // 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); + // 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; + 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; +} + +/** + * 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; + }; +} diff --git a/tests/unit/tui/url-links.test.ts b/tests/unit/tui/url-links.test.ts new file mode 100644 index 000000000..ce7330c8f --- /dev/null +++ b/tests/unit/tui/url-links.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { + findLinks, + isOpenableUrl, + isUrlOpenClick, + openUrl, + platformUrlCommand, + setUrlOpener, + resetUrlOpener, + splitLinkSpans, + splitWrappedLinkSpans, +} 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( + [], + ); + }); + + test("matches uppercase schemes", () => { + expect(findLinks("see HTTP://EXAMPLE.COM/x ok").map((h) => h.url)).toEqual([ + "HTTP://EXAMPLE.COM/x", + ]); + }); +}); + +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("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; + 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("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[] = []; + 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(); + }); + + 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"]); + }); +});