From 18f33abe1c66414b8babb4c76d551a0e7e3ea891 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 13:44:33 -0700 Subject: [PATCH] Split transcript link helpers into focused modules --- src/tui/link-open.ts | 87 +++++++ src/tui/link-spans.ts | 126 ++++++++++ src/tui/link-wrap.ts | 192 ++++++++++++++ src/tui/url-links.ts | 416 ++----------------------------- tests/unit/tui/url-links.test.ts | 52 ++++ 5 files changed, 479 insertions(+), 394 deletions(-) create mode 100644 src/tui/link-open.ts create mode 100644 src/tui/link-spans.ts create mode 100644 src/tui/link-wrap.ts diff --git a/src/tui/link-open.ts b/src/tui/link-open.ts new file mode 100644 index 000000000..94b3fb5a9 --- /dev/null +++ b/src/tui/link-open.ts @@ -0,0 +1,87 @@ +/** + * Opening: the openable-URL gate plus the browser opener behind it. + * + * openUrl is the single opener choke point: every armed-row and markdown + * release path opens through it, and its isOpenableUrl check is the gate + * that decides. The isOpenableUrl pre-filters in linkColumnHits and + * followWrapChain stay as defense-in-depth — they keep non-http(s) targets + * out of highlight and wrap-fusion geometry — and are deliberately not + * consolidated into this one call site. + */ +import type { MouseEvent } from "@opentui/core"; + +/** + * 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; + } +} + +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]; +} + +/** + * 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; +} + +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. + } +} diff --git a/src/tui/link-spans.ts b/src/tui/link-spans.ts new file mode 100644 index 000000000..d781b8392 --- /dev/null +++ b/src/tui/link-spans.ts @@ -0,0 +1,126 @@ +/** URL spans: bare http(s) scanning plus splitting styled runs on hits. */ +export interface LinkSpan { + readonly text: string; + readonly fg: string; + readonly bold?: boolean | undefined; + readonly url: string | null; +} + +export interface LinkHit { + readonly url: string; + readonly start: number; + readonly end: number; +} + +const URL_PATTERN = /https?:\/\/[^\s<>"'`\]]+/gi; +const TRAILING_PUNCTUATION = new Set([ + ".", + ",", + ";", + ":", + "!", + "?", + "'", + '"', + "]", + "}", + ">", +]); + +/** 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. + */ +export 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). */ +export interface SliceHit extends LinkHit { + readonly text: string; +} + +/** Cut one segment on explicit hits; a hitless segment stays one null span. */ +export 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; +} diff --git a/src/tui/link-wrap.ts b/src/tui/link-wrap.ts new file mode 100644 index 000000000..6670a587f --- /dev/null +++ b/src/tui/link-wrap.ts @@ -0,0 +1,192 @@ +/** + * 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"; + +/** + * 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() === "▍"; +} diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts index f18fd986c..738574cb2 100644 --- a/src/tui/url-links.ts +++ b/src/tui/url-links.ts @@ -1,16 +1,7 @@ /** - * 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 - * markdownLinkAt below — 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. + * Transcript links: painted-line geometry (armed rows) plus markdown + * resolution (childless library renderers). Span scanning lives in + * link-spans, wrapped-URL fusion in link-wrap, the open gate in link-open. */ import { CodeRenderable, @@ -28,328 +19,27 @@ import { type TextChunk, } from "@opentui/core"; import { stringWidth } from "./view/height.js"; +import { isOpenableUrl, isUrlOpenClick, openUrl } from "./link-open.js"; +import { + findLinks, + trimUrlEnd, + type LinkHit, + type LinkSpan, +} from "./link-spans.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() === "▍"; -} +/** Moved siblings, re-exported so existing url-links importers keep working. */ +export { + isOpenableUrl, + isUrlOpenClick, + openUrl, + platformUrlCommand, + resetUrlOpener, + setUrlOpener, + type UrlOpener, +} from "./link-open.js"; +export { findLinks, splitLinkSpans, type LinkSpan } from "./link-spans.js"; +export { splitWrappedLinkSpans } from "./link-wrap.js"; /** Native chunks for one span: link spans carry OSC-8 metadata. */ export function linkSpanChunks( @@ -364,68 +54,6 @@ export function linkSpanChunks( 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 — diff --git a/tests/unit/tui/url-links.test.ts b/tests/unit/tui/url-links.test.ts index 9f1deb7ee..ed1c5265e 100644 --- a/tests/unit/tui/url-links.test.ts +++ b/tests/unit/tui/url-links.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test, afterEach } from "bun:test"; import { findLinks, + hitUrlAt, isOpenableUrl, isUrlOpenClick, + linkColumnHits, openUrl, platformUrlCommand, setUrlOpener, @@ -296,3 +298,53 @@ describe("openUrl", () => { expect(calls).toEqual(["HTTP://EXAMPLE.COM/x"]); }); }); + +describe("linkColumnHits/hitUrlAt edges", () => { + const one = "https://example.com/x"; + const hits = linkColumnHits( + splitLinkSpans([{ text: `see ${one} ok`, fg: "#fff" }]), + ); + + test("one line maps to one inclusive-start, exclusive-end range", () => { + expect(hits).toEqual([{ url: one, start: 4, end: 25 }]); + }); + + test.each([ + ["before the line", -1, null], + ["prose", 0, null], + ["last prose column", 3, null], + ["inclusive start", 4, one], + ["mid-link", 14, one], + ["inclusive end", 24, one], + ["exclusive end", 25, null], + ["trailing prose", 26, null], + ["past the line end", 100, null], + ])("column %s resolves", (_label, column, expected) => { + expect(hitUrlAt(hits, column)).toBe(expected); + }); + + test.each([ + ["first link tail", 20, "https://one.example"], + ["gap between links", 21, null], + ["gap prose", 23, null], + ["second link head", 24, "http://two.example/c"], + ["second link tail", 43, "http://two.example/c"], + ["past the second link", 44, null], + ])("adjacent links: column %s resolves", (_label, column, expected) => { + const adjacent = linkColumnHits( + splitLinkSpans([ + { text: "a https://one.example b http://two.example/c", fg: "#fff" }, + ]), + ); + expect(adjacent).toHaveLength(2); + expect(hitUrlAt(adjacent, column)).toBe(expected); + }); + + test("a non-openable span url never becomes a hit", () => { + expect( + linkColumnHits([{ text: "x", fg: "#fff", url: "javascript:alert(1)" }]), + ).toEqual([]); + expect(linkColumnHits([])).toEqual([]); + expect(hitUrlAt([], 0)).toBeNull(); + }); +});