From e8af04621f11c9b8927cb0fedeabf8b672bf7b1b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 14:45:40 -0700 Subject: [PATCH] feat(tui): adopt Code onHighlight and detectLinks Fixes CL-10643 --- src/tui/{url-links.ts => code-links.ts} | 111 +++++++------ src/tui/link-spans.ts | 203 +++++++++++++++++++++++- src/tui/link-wrap.ts | 174 -------------------- src/tui/shell/index.ts | 2 +- src/tui/shell/row-retext.ts | 2 +- src/tui/shell/transcript.ts | 51 +++--- src/tui/url-click.test.ts | 64 +++++++- src/tui/url-links.test.ts | 9 +- 8 files changed, 358 insertions(+), 258 deletions(-) rename src/tui/{url-links.ts => code-links.ts} (82%) delete mode 100644 src/tui/link-wrap.ts diff --git a/src/tui/url-links.ts b/src/tui/code-links.ts similarity index 82% rename from src/tui/url-links.ts rename to src/tui/code-links.ts index b4291a57b..bc951179b 100644 --- a/src/tui/url-links.ts +++ b/src/tui/code-links.ts @@ -1,7 +1,8 @@ /** * Transcript link targets: painted-line geometry plus markdown resolution - * through childless library renderers. Span scanning, wrapped-URL fusion, - * and the open gate live in link-spans, link-wrap, link-open. + * through childless library renderers. Span scanning and wrapped-URL fusion + * live in link-spans; the open gate lives in link-open. Fenced code gets + * bare URLs from Code `onHighlight` / `onChunks` (`detectLinks`). */ import { CodeRenderable, @@ -10,20 +11,22 @@ import { TextAttributes, TextRenderable, bold as boldChunk, + detectLinks, fg as fgChunk, link as linkChunk, underline as underlineChunk, type CliRenderer, type LineInfo, type MouseEvent, + type SimpleHighlight, type TextChunk, } from "@opentui/core"; import { stringWidth } from "./view/height.js"; import { isOpenableUrl, isUrlOpenClick, openUrl } from "./link-open.js"; import { findLinks, + linkHits, trimUrlEnd, - type LinkHit, type LinkSpan, } from "./link-spans.js"; import { UI } from "./theme.js"; @@ -198,69 +201,54 @@ export function isUnderlined(attributes: number): boolean { */ const CONCEALABLE = new Set(["[", "]", "(", ")"]); -/** Inline `[label](target)` links in one source line: label and target - * both open the target. Images are skipped (no click behavior is - * specified). */ -function findMarkdownLinks(line: string): LinkHit[] { - const spans: LinkHit[] = []; - const pattern = /\[([^\]]*)\]\(([^)\s]+)\)/g; - for (const match of line.matchAll(pattern)) { +/** + * Inline `[label](target)` and bare http(s) ranges, resolved by + * `detectLinks`. Images are `markup.raw` so neither the label nor the + * destination opens. A label that is itself a URL stays a bare hit (it + * opens the label text, not the markdown target). + */ +function markdownHighlights(line: string): SimpleHighlight[] { + const highlights: SimpleHighlight[] = []; + for (const match of line.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)) { + const start = match.index ?? 0; + highlights.push([start, start + match[0].length, "markup.raw"]); + } + for (const match of line.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)) { const index = match.index ?? 0; if (index > 0 && line[index - 1] === "!") continue; const url = match[2] ?? ""; const rawStart = index + match[0].lastIndexOf(url); const end = trimUrlEnd(line, rawStart, rawStart + url.length); if (end <= rawStart) continue; - const target = line.slice(rawStart, end); const label = match[1] ?? ""; - if (label.length > 0) - spans.push({ - url: target, - start: index + 1, - end: index + 1 + label.length, - }); - spans.push({ url: target, start: rawStart, end }); + if (label.length > 0 && !labelIsBareUrl(label)) { + highlights.push([ + index + 1, + index + 1 + label.length, + "markup.link.label", + ]); + } + highlights.push([rawStart, end, "markup.link.url"]); } - return spans; + return highlights; } -/** Whole `![label](target)` ranges: bare-URL scanning cannot tell image - * markup from links, so the resolver discards bare matches touching them; - * images stay non-openable. */ -function findImageRanges(line: string): { start: number; end: number }[] { - const ranges: { start: number; end: number }[] = []; - for (const match of line.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)) { - const start = match.index ?? 0; - ranges.push({ start, end: start + match[0].length }); - } - return ranges; +/** True when the label text is exactly one bare http(s) URL. */ +function labelIsBareUrl(label: string): boolean { + const hits = findLinks(label); + return ( + hits.length === 1 && hits[0]?.start === 0 && hits[0].end === label.length + ); } /** - * Link target under one source offset: bare URLs first (URL-shaped labels - * stay clickable), then inline `[label](target)` spans. A bare match fused - * across a span boundary or inside image markup is a matcher artifact, so - * only a bare match fully inside one span — or touching none — counts. + * Link target under one source offset. `detectLinks` applies bare URLs and + * the markdown label/destination highlights above; image raw ranges are + * omitted. */ function markdownUrlAt(line: string, offset: number): string | null { - const spans = findMarkdownLinks(line); - const images = findImageRanges(line); - for (const hit of findLinks(line)) { - if (offset >= hit.start && offset < hit.end) { - const fused = spans.some( - (span) => - hit.start < span.end && - hit.end > span.start && - (hit.start < span.start || hit.end > span.end), - ); - const imaged = images.some( - (image) => hit.start < image.end && hit.end > image.start, - ); - if (!fused && !imaged) return hit.url; - } - } - for (const span of spans) { - if (offset >= span.start && offset < span.end) return span.url; + for (const hit of linkHits(line, markdownHighlights(line))) { + if (offset >= hit.start && offset < hit.end) return hit.url; } return null; } @@ -417,3 +405,24 @@ export function armMarkdownLinks( press = null; }; } + +/** + * Fenced code is a Code renderable without markdown's link callbacks. + * `onHighlight` keeps the tree-sitter groups; `onChunks` is `detectLinks`, + * so a bare URL becomes an OSC-8 span and survives native wrap. Markdown + * prose already sets both — do not replace those, or conceal regresses. + */ +export function armCodeLinks(node: Renderable): void { + const walk = (current: Renderable): void => { + if (current instanceof CodeRenderable) { + if (current.onHighlight === undefined) { + current.onHighlight = (highlights) => highlights; + } + if (current.onChunks === undefined) { + current.onChunks = (chunks, context) => detectLinks(chunks, context); + } + } + for (const child of current.getChildren()) walk(child); + }; + walk(node); +} diff --git a/src/tui/link-spans.ts b/src/tui/link-spans.ts index 538579117..0107ad2d7 100644 --- a/src/tui/link-spans.ts +++ b/src/tui/link-spans.ts @@ -1,4 +1,12 @@ /** URL spans: bare http(s) scanning plus splitting styled runs on hits. */ +import { + detectLinks, + type SimpleHighlight, + type TextChunk, +} from "@opentui/core"; +import { stringWidth } from "./view/height.js"; +import { isOpenableUrl } from "./link-open.js"; + export interface LinkSpan { readonly text: string; readonly fg: string; @@ -12,7 +20,6 @@ export interface LinkHit { readonly end: number; } -const URL_PATTERN = /https?:\/\/[^\s<>"'`\]]+/gi; const TRAILING_PUNCTUATION = new Set([ ".", ",", @@ -27,14 +34,34 @@ const TRAILING_PUNCTUATION = new Set([ ">", ]); -/** http(s) runs inside plain text, without trailing prose punctuation. */ +/** + * http(s) runs inside plain text. Bare detection is OpenTUI `detectLinks` + * so a painted fragment and a Code chunk agree on the same target. + */ export function findLinks(text: string): LinkHit[] { + return linkHits(text, []); +} + +/** Linked ranges `detectLinks` reports for one string. */ +export function linkHits( + text: string, + highlights: readonly SimpleHighlight[], +): LinkHit[] { + if (text.length === 0) return []; + const chunk: TextChunk = { __isChunk: true, text }; + const linked = detectLinks([chunk], { + content: text, + highlights: [...highlights], + sourceRanges: [{ start: 0, end: text.length }], + }); 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 }); + let offset = 0; + for (const part of linked) { + const url = part.link?.url; + if (url !== undefined && part.text.length > 0) { + hits.push({ url, start: offset, end: offset + part.text.length }); + } + offset += part.text.length; } return hits; } @@ -124,3 +151,165 @@ export function sliceSpans( }); return spans; } + +/** + * Rejoin URL fragments split across continuation lines so every fragment + * highlights and opens the full URL. + * + * `wrapWidth` is the painted width the row was wrapped at. A chain starts + * on a full line ending in a URL run and continues through full lines; a + * short line ends it unless no text follows (then a natural break, not a + * wrap). A chain is accepted when its fragments reassemble to one of + * `sourceUrls`, the row's real links: wrap can orphan a short fragment + * with text after it, indistinguishable from a natural break by geometry. + * Without known source URLs the joined candidate must still scan as + * exactly one clean http(s) URL, so a break cannot fuse two unrelated + * runs. The scan is `detectLinks` via `findLinks`. + */ +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 + // at the wrap edge is not part of the seed. + 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 under the module's chain rules. A hitless seed continues only + * through a full first line — a short line behind a bare scheme reads as + * prose. + */ +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 (a bare `▍`). A blank source line is a natural break, not one. + * Paint trims trailing space, so the pad compares exactly. + */ +function isWrapEndLine(text: string | undefined): boolean { + if (text === undefined) return true; + return text.trimEnd() === "▍"; +} diff --git a/src/tui/link-wrap.ts b/src/tui/link-wrap.ts deleted file mode 100644 index 5f12c8a05..000000000 --- a/src/tui/link-wrap.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Wrapped links: fuse a URL broken across continuation lines into one target. - */ -import { stringWidth } from "./view/height.js"; -import { isOpenableUrl } from "./link-open.js"; -import { - findLinks, - sliceSpans, - trimUrlEnd, - type LinkSpan, - type SliceHit, -} from "./link-spans.js"; - -/** - * Rejoin URL fragments split across continuation lines so every fragment - * highlights and opens the full URL. - * - * `wrapWidth` is the painted width the row was wrapped at. A chain starts - * on a full line ending in a URL run and continues through full lines; a - * short line ends it unless no text follows (then a natural break, not a - * wrap). A chain is accepted when its fragments reassemble to one of - * `sourceUrls`, the row's real links: wrap can orphan a short fragment - * with text after it, indistinguishable from a natural break by geometry. - * Without known source URLs the joined candidate must still scan as - * exactly one clean http(s) URL, so a break cannot fuse two unrelated - * runs. - */ -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 - // at the wrap edge is not part of the seed. - 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 under the module's chain rules. A hitless seed continues only - * through a full first line — a short line behind a bare scheme reads as - * prose. - */ -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 (a bare `▍`). A blank source line is a natural break, not one. - * Paint trims trailing space, so the pad compares exactly. - */ -function isWrapEndLine(text: string | undefined): boolean { - if (text === undefined) return true; - return text.trimEnd() === "▍"; -} diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 548a3ed2b..9bfb7209c 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -72,7 +72,7 @@ import { submitPrompt, syncPromptHighlights, } from "./prompt.js"; -import { armMarkdownLinks } from "../url-links.js"; +import { armMarkdownLinks } from "../code-links.js"; import { NO_WORKER_WAIT } from "../worker-wait.js"; import { NO_OPERATOR_INPUT_REQUIRED } from "../operator-input-required.js"; import { NO_QUEUED_INPUT } from "../queued-input.js"; diff --git a/src/tui/shell/row-retext.ts b/src/tui/shell/row-retext.ts index b150143b1..900d179d4 100644 --- a/src/tui/shell/row-retext.ts +++ b/src/tui/shell/row-retext.ts @@ -15,7 +15,7 @@ import { } from "@opentui/core"; import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; -import { paintLinkLine } from "../url-links.js"; +import { paintLinkLine } from "../code-links.js"; import { splitLinkSpans } from "../link-spans.js"; import { splitTrailingArrow, diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts index 9ef3eedbf..8073ebb0b 100644 --- a/src/tui/shell/transcript.ts +++ b/src/tui/shell/transcript.ts @@ -13,9 +13,17 @@ import { } from "@opentui/core"; import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; -import { armLinkLine, buildLinkLine, paintLinkLine } from "../url-links.js"; -import { findLinks, splitLinkSpans } from "../link-spans.js"; -import { splitWrappedLinkSpans } from "../link-wrap.js"; +import { + armCodeLinks, + armLinkLine, + buildLinkLine, + paintLinkLine, +} from "../code-links.js"; +import { + findLinks, + splitLinkSpans, + splitWrappedLinkSpans, +} from "../link-spans.js"; import { nextStreamMarkdownState, splitAtSettledBlock, @@ -212,6 +220,7 @@ function retextStreamRowBody( bodyNode.width = width; bodyNode.content = content; bodyNode.streaming = row.streaming === true; + armCodeLinks(bodyNode); return true; } @@ -238,11 +247,13 @@ function retextStreamRowBody( frozenNode.width = width; if (transition.paintFrozen) { frozenNode.content = split.frozen; + armCodeLinks(frozenNode); liveNode.marginTop = split.gapRows; } liveNode.width = width; liveNode.content = split.live; liveNode.streaming = streaming; + armCodeLinks(liveNode); return true; } @@ -435,28 +446,30 @@ function createMarkdownBody( const split = splitAtSettledBlock(content); if (split === null) { // Native incremental block stability: only the trailing block is unstable. - return new MarkdownRenderable(ctx, { + const body = new MarkdownRenderable(ctx, { ...markdownBodyOptions(ctx, gutter, width), content, streaming: row.streaming === true, }); + armCodeLinks(body); + return body; } const column = new BoxRenderable(ctx, { flexDirection: "column", width }); - column.add( - new MarkdownRenderable(ctx, { - ...markdownBodyOptions(ctx, gutter, width), - content: split.frozen, - streaming: false, - }), - ); - column.add( - new MarkdownRenderable(ctx, { - ...markdownBodyOptions(ctx, gutter, width), - content: split.live, - streaming: row.streaming === true, - marginTop: split.gapRows, - }), - ); + const frozen = new MarkdownRenderable(ctx, { + ...markdownBodyOptions(ctx, gutter, width), + content: split.frozen, + streaming: false, + }); + const live = new MarkdownRenderable(ctx, { + ...markdownBodyOptions(ctx, gutter, width), + content: split.live, + streaming: row.streaming === true, + marginTop: split.gapRows, + }); + armCodeLinks(frozen); + armCodeLinks(live); + column.add(frozen); + column.add(live); splitBodyMemory.set(column, { content, frozen: split.frozen, diff --git a/src/tui/url-click.test.ts b/src/tui/url-click.test.ts index fc13a35de..58ffa77e6 100644 --- a/src/tui/url-click.test.ts +++ b/src/tui/url-click.test.ts @@ -9,14 +9,14 @@ * (docs/TUI.md); headless, the mock delivers it like any click. */ import { describe, expect, test } from "bun:test"; -import { TextRenderable } from "@opentui/core"; +import { TextRenderable, CodeRenderable, type Renderable } from "@opentui/core"; import { defined } from "../../testkit/defined.js"; import { withTestRenderer, type Harness } from "./harness"; import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome"; import type { AppShell } from "./shell/internals"; import { withAppShell } from "./test-helpers"; -import { isUnderlined, markdownLinkAt, paintLinkLine } from "./url-links"; +import { isUnderlined, markdownLinkAt, paintLinkLine } from "./code-links"; import { isOpenableUrl, resetUrlOpener, setUrlOpener } from "./link-open"; import { splitLinkSpans } from "./link-spans"; import { type StreamRow } from "./stream"; @@ -279,6 +279,33 @@ describe("Ctrl+clicking a transcript URL", () => { }, 40); }); + test("a fenced-code bare URL is an OSC8 link and opens on Ctrl+click", async () => { + await withUrlShell(async (shell, h, opened) => { + appendStreamRow(shell, { + role: "assistant", + text: "```js\nsee https://example.com/fence ok\n```", + }); + const cell = await waitForPaintedCell(h, "example.com/fence"); + const fence = codeNodes(shell.renderer.root).find((node) => + node.content.includes("example.com/fence"), + ); + expect(fence?.onHighlight).toBeDefined(); + expect(fence?.onChunks).toBeDefined(); + const linked = await waitForLinkAt( + h, + cell.x, + cell.y, + "https://example.com/fence", + ); + expect(linked).toBe("https://example.com/fence"); + await h.mockMouse.click(cell.x, cell.y, 0, { + modifiers: { ctrl: true }, + }); + await h.renderOnce(); + expect(opened).toEqual(["https://example.com/fence"]); + }); + }); + test("assistant markdown bare URL and link label open on Ctrl+click", async () => { await withUrlShell(async (shell, h, opened) => { // Markdown blocks paint through childless library renderers, so only @@ -529,6 +556,39 @@ describe("transcript markdown resolver edges (CL-7955)", () => { }); }); +/** Code renderables under `root`, including fenced blocks inside markdown. */ +function codeNodes(root: Renderable): CodeRenderable[] { + const found: CodeRenderable[] = []; + const walk = (node: Renderable): void => { + if (node instanceof CodeRenderable) found.push(node); + for (const child of node.getChildren()) walk(child); + }; + walk(root); + return found; +} + +/** Poll until the cell's OSC-8 target is `url` (highlighting is async). */ +async function waitForLinkAt( + h: { + renderOnce: () => Promise; + renderer: { getLinkAt(x: number, y: number): string | null }; + }, + x: number, + y: number, + url: string, + timeoutMs = 2000, +): Promise { + const deadline = Date.now() + timeoutMs; + let found: string | null = null; + for (;;) { + await h.renderOnce(); + found = h.renderer.getLinkAt(x, y); + if (found === url) return found; + if (Date.now() > deadline) return found; + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + /** Poll until `needle` paints, rendering between tries. */ async function waitForPaintedCell( h: { diff --git a/src/tui/url-links.test.ts b/src/tui/url-links.test.ts index 1642b74bb..29a10200b 100644 --- a/src/tui/url-links.test.ts +++ b/src/tui/url-links.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, afterEach } from "bun:test"; -import { hitUrlAt, linkColumnHits } from "./url-links.js"; +import { hitUrlAt, linkColumnHits } from "./code-links.js"; import { isOpenableUrl, isUrlOpenClick, @@ -8,8 +8,11 @@ import { setUrlOpener, resetUrlOpener, } from "./link-open.js"; -import { findLinks, splitLinkSpans } from "./link-spans.js"; -import { splitWrappedLinkSpans } from "./link-wrap.js"; +import { + findLinks, + splitLinkSpans, + splitWrappedLinkSpans, +} from "./link-spans.js"; afterEach(() => { resetUrlOpener();