Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 60 additions & 51 deletions src/tui/url-links.ts → src/tui/code-links.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
203 changes: 196 additions & 7 deletions src/tui/link-spans.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,7 +20,6 @@ export interface LinkHit {
readonly end: number;
}

const URL_PATTERN = /https?:\/\/[^\s<>"'`\]]+/gi;
const TRAILING_PUNCTUATION = new Set([
".",
",",
Expand All @@ -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;
}
Expand Down Expand Up @@ -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() === "▍";
}
Loading
Loading