Skip to content

Commit 0641f44

Browse files
Merge pull request #1464 from corbitsdev/cl-10643-tui-adopt-code-onhighlight-detectlinks
feat(tui): adopt Code onHighlight and detectLinks
2 parents 0656ac3 + e8af046 commit 0641f44

8 files changed

Lines changed: 358 additions & 258 deletions

File tree

Lines changed: 60 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* Transcript link targets: painted-line geometry plus markdown resolution
3-
* through childless library renderers. Span scanning, wrapped-URL fusion,
4-
* and the open gate live in link-spans, link-wrap, link-open.
3+
* through childless library renderers. Span scanning and wrapped-URL fusion
4+
* live in link-spans; the open gate lives in link-open. Fenced code gets
5+
* bare URLs from Code `onHighlight` / `onChunks` (`detectLinks`).
56
*/
67
import {
78
CodeRenderable,
@@ -10,20 +11,22 @@ import {
1011
TextAttributes,
1112
TextRenderable,
1213
bold as boldChunk,
14+
detectLinks,
1315
fg as fgChunk,
1416
link as linkChunk,
1517
underline as underlineChunk,
1618
type CliRenderer,
1719
type LineInfo,
1820
type MouseEvent,
21+
type SimpleHighlight,
1922
type TextChunk,
2023
} from "@opentui/core";
2124
import { stringWidth } from "./view/height.js";
2225
import { isOpenableUrl, isUrlOpenClick, openUrl } from "./link-open.js";
2326
import {
2427
findLinks,
28+
linkHits,
2529
trimUrlEnd,
26-
type LinkHit,
2730
type LinkSpan,
2831
} from "./link-spans.js";
2932
import { UI } from "./theme.js";
@@ -198,69 +201,54 @@ export function isUnderlined(attributes: number): boolean {
198201
*/
199202
const CONCEALABLE = new Set(["[", "]", "(", ")"]);
200203

201-
/** Inline `[label](target)` links in one source line: label and target
202-
* both open the target. Images are skipped (no click behavior is
203-
* specified). */
204-
function findMarkdownLinks(line: string): LinkHit[] {
205-
const spans: LinkHit[] = [];
206-
const pattern = /\[([^\]]*)\]\(([^)\s]+)\)/g;
207-
for (const match of line.matchAll(pattern)) {
204+
/**
205+
* Inline `[label](target)` and bare http(s) ranges, resolved by
206+
* `detectLinks`. Images are `markup.raw` so neither the label nor the
207+
* destination opens. A label that is itself a URL stays a bare hit (it
208+
* opens the label text, not the markdown target).
209+
*/
210+
function markdownHighlights(line: string): SimpleHighlight[] {
211+
const highlights: SimpleHighlight[] = [];
212+
for (const match of line.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)) {
213+
const start = match.index ?? 0;
214+
highlights.push([start, start + match[0].length, "markup.raw"]);
215+
}
216+
for (const match of line.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)) {
208217
const index = match.index ?? 0;
209218
if (index > 0 && line[index - 1] === "!") continue;
210219
const url = match[2] ?? "";
211220
const rawStart = index + match[0].lastIndexOf(url);
212221
const end = trimUrlEnd(line, rawStart, rawStart + url.length);
213222
if (end <= rawStart) continue;
214-
const target = line.slice(rawStart, end);
215223
const label = match[1] ?? "";
216-
if (label.length > 0)
217-
spans.push({
218-
url: target,
219-
start: index + 1,
220-
end: index + 1 + label.length,
221-
});
222-
spans.push({ url: target, start: rawStart, end });
224+
if (label.length > 0 && !labelIsBareUrl(label)) {
225+
highlights.push([
226+
index + 1,
227+
index + 1 + label.length,
228+
"markup.link.label",
229+
]);
230+
}
231+
highlights.push([rawStart, end, "markup.link.url"]);
223232
}
224-
return spans;
233+
return highlights;
225234
}
226235

227-
/** Whole `![label](target)` ranges: bare-URL scanning cannot tell image
228-
* markup from links, so the resolver discards bare matches touching them;
229-
* images stay non-openable. */
230-
function findImageRanges(line: string): { start: number; end: number }[] {
231-
const ranges: { start: number; end: number }[] = [];
232-
for (const match of line.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)) {
233-
const start = match.index ?? 0;
234-
ranges.push({ start, end: start + match[0].length });
235-
}
236-
return ranges;
236+
/** True when the label text is exactly one bare http(s) URL. */
237+
function labelIsBareUrl(label: string): boolean {
238+
const hits = findLinks(label);
239+
return (
240+
hits.length === 1 && hits[0]?.start === 0 && hits[0].end === label.length
241+
);
237242
}
238243

239244
/**
240-
* Link target under one source offset: bare URLs first (URL-shaped labels
241-
* stay clickable), then inline `[label](target)` spans. A bare match fused
242-
* across a span boundary or inside image markup is a matcher artifact, so
243-
* only a bare match fully inside one span — or touching none — counts.
245+
* Link target under one source offset. `detectLinks` applies bare URLs and
246+
* the markdown label/destination highlights above; image raw ranges are
247+
* omitted.
244248
*/
245249
function markdownUrlAt(line: string, offset: number): string | null {
246-
const spans = findMarkdownLinks(line);
247-
const images = findImageRanges(line);
248-
for (const hit of findLinks(line)) {
249-
if (offset >= hit.start && offset < hit.end) {
250-
const fused = spans.some(
251-
(span) =>
252-
hit.start < span.end &&
253-
hit.end > span.start &&
254-
(hit.start < span.start || hit.end > span.end),
255-
);
256-
const imaged = images.some(
257-
(image) => hit.start < image.end && hit.end > image.start,
258-
);
259-
if (!fused && !imaged) return hit.url;
260-
}
261-
}
262-
for (const span of spans) {
263-
if (offset >= span.start && offset < span.end) return span.url;
250+
for (const hit of linkHits(line, markdownHighlights(line))) {
251+
if (offset >= hit.start && offset < hit.end) return hit.url;
264252
}
265253
return null;
266254
}
@@ -417,3 +405,24 @@ export function armMarkdownLinks(
417405
press = null;
418406
};
419407
}
408+
409+
/**
410+
* Fenced code is a Code renderable without markdown's link callbacks.
411+
* `onHighlight` keeps the tree-sitter groups; `onChunks` is `detectLinks`,
412+
* so a bare URL becomes an OSC-8 span and survives native wrap. Markdown
413+
* prose already sets both — do not replace those, or conceal regresses.
414+
*/
415+
export function armCodeLinks(node: Renderable): void {
416+
const walk = (current: Renderable): void => {
417+
if (current instanceof CodeRenderable) {
418+
if (current.onHighlight === undefined) {
419+
current.onHighlight = (highlights) => highlights;
420+
}
421+
if (current.onChunks === undefined) {
422+
current.onChunks = (chunks, context) => detectLinks(chunks, context);
423+
}
424+
}
425+
for (const child of current.getChildren()) walk(child);
426+
};
427+
walk(node);
428+
}

‎src/tui/link-spans.ts‎

Lines changed: 196 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
/** URL spans: bare http(s) scanning plus splitting styled runs on hits. */
2+
import {
3+
detectLinks,
4+
type SimpleHighlight,
5+
type TextChunk,
6+
} from "@opentui/core";
7+
import { stringWidth } from "./view/height.js";
8+
import { isOpenableUrl } from "./link-open.js";
9+
210
export interface LinkSpan {
311
readonly text: string;
412
readonly fg: string;
@@ -12,7 +20,6 @@ export interface LinkHit {
1220
readonly end: number;
1321
}
1422

15-
const URL_PATTERN = /https?:\/\/[^\s<>"'`\]]+/gi;
1623
const TRAILING_PUNCTUATION = new Set([
1724
".",
1825
",",
@@ -27,14 +34,34 @@ const TRAILING_PUNCTUATION = new Set([
2734
">",
2835
]);
2936

30-
/** http(s) runs inside plain text, without trailing prose punctuation. */
37+
/**
38+
* http(s) runs inside plain text. Bare detection is OpenTUI `detectLinks`
39+
* so a painted fragment and a Code chunk agree on the same target.
40+
*/
3141
export function findLinks(text: string): LinkHit[] {
42+
return linkHits(text, []);
43+
}
44+
45+
/** Linked ranges `detectLinks` reports for one string. */
46+
export function linkHits(
47+
text: string,
48+
highlights: readonly SimpleHighlight[],
49+
): LinkHit[] {
50+
if (text.length === 0) return [];
51+
const chunk: TextChunk = { __isChunk: true, text };
52+
const linked = detectLinks([chunk], {
53+
content: text,
54+
highlights: [...highlights],
55+
sourceRanges: [{ start: 0, end: text.length }],
56+
});
3257
const hits: LinkHit[] = [];
33-
URL_PATTERN.lastIndex = 0;
34-
for (const match of text.matchAll(URL_PATTERN)) {
35-
const end = trimUrlEnd(text, match.index, match.index + match[0].length);
36-
if (end > match.index)
37-
hits.push({ url: text.slice(match.index, end), start: match.index, end });
58+
let offset = 0;
59+
for (const part of linked) {
60+
const url = part.link?.url;
61+
if (url !== undefined && part.text.length > 0) {
62+
hits.push({ url, start: offset, end: offset + part.text.length });
63+
}
64+
offset += part.text.length;
3865
}
3966
return hits;
4067
}
@@ -124,3 +151,165 @@ export function sliceSpans(
124151
});
125152
return spans;
126153
}
154+
155+
/**
156+
* Rejoin URL fragments split across continuation lines so every fragment
157+
* highlights and opens the full URL.
158+
*
159+
* `wrapWidth` is the painted width the row was wrapped at. A chain starts
160+
* on a full line ending in a URL run and continues through full lines; a
161+
* short line ends it unless no text follows (then a natural break, not a
162+
* wrap). A chain is accepted when its fragments reassemble to one of
163+
* `sourceUrls`, the row's real links: wrap can orphan a short fragment
164+
* with text after it, indistinguishable from a natural break by geometry.
165+
* Without known source URLs the joined candidate must still scan as
166+
* exactly one clean http(s) URL, so a break cannot fuse two unrelated
167+
* runs. The scan is `detectLinks` via `findLinks`.
168+
*/
169+
export function splitWrappedLinkSpans(
170+
lines: readonly { text: string; fg: string }[],
171+
wrapWidth: number,
172+
sourceUrls: readonly string[] = [],
173+
): LinkSpan[][] {
174+
const hits = lines.map((line) =>
175+
findLinks(line.text).map((hit): SliceHit => ({ ...hit, text: hit.url })),
176+
);
177+
let index = 0;
178+
while (index < lines.length) {
179+
const line = lines[index];
180+
const seed = line === undefined ? null : wrapSeed(line.text, wrapWidth);
181+
if (line === undefined || seed === null) {
182+
index += 1;
183+
continue;
184+
}
185+
const chain = followWrapChain(
186+
lines,
187+
index + 1,
188+
seed,
189+
(hits[index] ?? []).some((hit) => hit.end >= seed.end),
190+
wrapWidth,
191+
sourceUrls,
192+
);
193+
if (chain === null) {
194+
index += 1;
195+
continue;
196+
}
197+
hits[index] = (hits[index] ?? []).filter((hit) => hit.start < seed.start);
198+
hits[index]?.push({
199+
url: chain.full,
200+
start: seed.start,
201+
end: seed.end,
202+
text: seed.text,
203+
});
204+
for (const run of chain.runs) {
205+
hits[run.line] = (hits[run.line] ?? []).filter(
206+
(hit) => hit.end <= run.start || hit.start >= run.end,
207+
);
208+
hits[run.line]?.push({
209+
url: chain.full,
210+
start: run.start,
211+
end: run.end,
212+
text: lines[run.line]?.text.slice(run.start, run.end) ?? "",
213+
});
214+
}
215+
index = chain.endLine + 1;
216+
}
217+
return lines.map((line, i) =>
218+
sliceSpans(
219+
line,
220+
[...(hits[i] ?? [])].sort((a, b) => a.start - b.start),
221+
),
222+
);
223+
}
224+
225+
/** A full line's trailing URL run seeds a wrapped chain, if URL-shaped. */
226+
function wrapSeed(
227+
text: string,
228+
wrapWidth: number,
229+
): {
230+
readonly start: number;
231+
readonly end: number;
232+
readonly text: string;
233+
} | null {
234+
if (stringWidth(text) !== wrapWidth) return null;
235+
const run = text.match(/[^\s]+$/)?.[0] ?? "";
236+
// The :// marks the run as URL-shaped even when a hard split inside the
237+
// scheme or host leaves no detectable hit; the joined candidate still
238+
// has to scan as one clean URL before anything merges. Prose punctuation
239+
// at the wrap edge is not part of the seed.
240+
if (!run.includes("://")) return null;
241+
const start = text.length - run.length;
242+
const end = trimUrlEnd(text, start, text.length);
243+
if (end <= start) return null;
244+
return { start, end, text: text.slice(start, end) };
245+
}
246+
247+
/** Fragments a chain picks up past its seed line, through its final line. */
248+
interface WrapChain {
249+
readonly full: string;
250+
readonly runs: readonly {
251+
readonly line: number;
252+
readonly start: number;
253+
readonly end: number;
254+
}[];
255+
readonly endLine: number;
256+
}
257+
258+
/**
259+
* Walk continuation lines past their indent, fusing leading runs onto the
260+
* seed under the module's chain rules. A hitless seed continues only
261+
* through a full first line — a short line behind a bare scheme reads as
262+
* prose.
263+
*/
264+
function followWrapChain(
265+
lines: readonly { text: string; fg: string }[],
266+
from: number,
267+
seed: { readonly start: number; readonly end: number; readonly text: string },
268+
seedAnchored: boolean,
269+
wrapWidth: number,
270+
sourceUrls: readonly string[],
271+
): WrapChain | null {
272+
let full = seed.text;
273+
const runs: { line: number; start: number; end: number }[] = [];
274+
let line = from;
275+
for (;;) {
276+
const text = lines[line]?.text;
277+
if (text === undefined) break;
278+
const start = text.match(/^[\s▍]*/)?.[0].length ?? 0;
279+
const raw = text.slice(start).match(/^[^\s]+/)?.[0] ?? "";
280+
if (raw.length === 0) {
281+
if (runs.length === 0 || !isWrapEndLine(text)) return null;
282+
break;
283+
}
284+
const end = trimUrlEnd(text, start, start + raw.length);
285+
if (end <= start) return null;
286+
full += text.slice(start, end);
287+
runs.push({ line, start, end });
288+
if (sourceUrls.includes(full)) return { full, runs, endLine: line };
289+
if (runs.length === 1 && !seedAnchored && stringWidth(text) !== wrapWidth)
290+
return null;
291+
if (end !== text.length) break;
292+
if (stringWidth(text) === wrapWidth) {
293+
line += 1;
294+
continue;
295+
}
296+
if (!isWrapEndLine(lines[line + 1]?.text)) return null;
297+
break;
298+
}
299+
if (runs.length === 0) return null;
300+
if (sourceUrls.length > 0) return null;
301+
if (!isOpenableUrl(full)) return null;
302+
const check = findLinks(full);
303+
if (check.length !== 1 || check[0]?.url !== full) return null;
304+
return { full, runs, endLine: runs[runs.length - 1]?.line ?? from };
305+
}
306+
307+
/**
308+
* A line nothing textual follows on: the end of the text, or a user-bubble
309+
* pad row (a bare `▍`). A blank source line is a natural break, not one.
310+
* Paint trims trailing space, so the pad compares exactly.
311+
*/
312+
function isWrapEndLine(text: string | undefined): boolean {
313+
if (text === undefined) return true;
314+
return text.trimEnd() === "▍";
315+
}

0 commit comments

Comments
 (0)