Skip to content

Commit b3185dd

Browse files
committed
Resolve transcript link clicks without the removed renderer hit-test
The renderer no longer exposes a link map, so the transcript-root handler resolves clicks from each painted code block's own content and line info instead.
1 parent b509ed3 commit b3185dd

3 files changed

Lines changed: 190 additions & 15 deletions

File tree

docs/TUI.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -857,9 +857,11 @@ url.dll,FileProtocolHandler` — argv spawns, never through a shell).
857857
- Markdown prose (assistant messages) is click-to-open only: the renderer
858858
paints it through childless code renderers with no node to arm, so there
859859
is no hover underline. A bubbling handler on the transcript root resolves
860-
the click through the renderer's `getLinkAt` link map (OpenTUI 0.5.11+)
861-
and opens on press-and-release over the same URL — armed rows keep their
862-
hover underline and open through their own node handlers.
860+
the click through the source-text markdown resolver (each painted code
861+
block pairs its content with its own line info, so wrapped and concealed
862+
links still map) and opens on press-and-release over the same URL —
863+
armed rows keep their hover underline and open through their own node
864+
handlers.
863865

864866
Arrow keys never scroll anything — inside the prompt they are caret motion
865867
or, at the buffer's edges, prompt-history recall; inside an open overlay's

src/tui/url-click.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ describe("Ctrl+clicking a transcript URL", () => {
497497
try {
498498
// The armed row's own release handler opens and stops propagation;
499499
// the transcript-root markdown handler must not see the same
500-
// gesture and open the (getLinkAt-resolved) target a second time.
500+
// gesture and open the (resolver-resolved) target a second time.
501501
appendStreamRow(shell, {
502502
role: "user",
503503
text: "see https://example.com/x ok",

src/tui/url-links.ts

Lines changed: 184 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
* holding Ctrl over a link highlights it, press-and-release on the same URL
55
* opens it. Assistant markdown paints through childless library renderers
66
* with no node to arm, so it is covered by a bubbling handler on the
7-
* transcript root (armMarkdownLinks) that resolves clicks through the
8-
* renderer's getLinkAt link map — click-to-open only, no hover highlight.
7+
* transcript root (armMarkdownLinks) that resolves clicks through
8+
* markdownLinkAt below — click-to-open only, no hover highlight.
99
*
1010
* The gesture is modifier-gated end to end. Without the modifier nothing here
1111
* runs: rows keep today's expand and selection behavior, and with mouse
1212
* capture off (Alt+M) the terminal owns every click because OpenTUI never
1313
* sees one. Only http(s) targets ever open; every other scheme is ignored.
1414
*/
1515
import {
16+
CodeRenderable,
17+
Renderable,
1618
StyledText,
1719
TextAttributes,
1820
TextRenderable,
@@ -21,8 +23,8 @@ import {
2123
link as linkChunk,
2224
underline as underlineChunk,
2325
type CliRenderer,
26+
type LineInfo,
2427
type MouseEvent,
25-
type Renderable,
2628
type TextChunk,
2729
} from "@opentui/core";
2830
import { stringWidth } from "./view/height.js";
@@ -559,9 +561,8 @@ export function armLinkLine(
559561
press = null;
560562
if (start !== null && isUrlOpenClick(event) && at(event) === start) {
561563
openUrl(start);
562-
// Armed rows register in the same link map getLinkAt reads, so the
563-
// leaf's open would otherwise be repeated by the transcript-root
564-
// armMarkdownLinks handler this event bubbles to. This handler runs
564+
// The bubbled release would otherwise be resolved again by the
565+
// transcript-root armMarkdownLinks handler. This handler runs
565566
// first in the bubble; stopping propagation starves the root of the
566567
// release and keeps exactly one open per gesture. The press
567568
// deliberately keeps bubbling so drag-select still works.
@@ -596,31 +597,203 @@ export function isUnderlined(attributes: number): boolean {
596597
return (attributes & TextAttributes.UNDERLINE) !== 0;
597598
}
598599

600+
/**
601+
* Link-markup characters whose painted width the resolver cannot know. The
602+
* renderer conceals markdown link markup — observed: `[guide](…)` paints as
603+
* `guide (…)` — and highlight state is not readable from here, so each of
604+
* these characters is modeled as taking painted width 0 or 1. Every other
605+
* character paints at its measured width.
606+
*/
607+
const CONCEALABLE = new Set(["[", "]", "(", ")"]);
608+
609+
/**
610+
* Inline `[label](target)` links in one source line. The label and the
611+
* target both open the target. Images (`![alt](target)`) are skipped:
612+
* nothing specifies their click behavior, and a missed click is safer
613+
* than a wrong open.
614+
*/
615+
function findMarkdownLinks(line: string): LinkHit[] {
616+
const spans: LinkHit[] = [];
617+
const pattern = /\[([^\]]*)\]\(([^)\s]+)\)/g;
618+
for (const match of line.matchAll(pattern)) {
619+
const index = match.index ?? 0;
620+
if (index > 0 && line[index - 1] === "!") continue;
621+
const url = match[2] ?? "";
622+
const rawStart = index + match[0].lastIndexOf(url);
623+
const end = trimUrlEnd(line, rawStart, rawStart + url.length);
624+
if (end <= rawStart) continue;
625+
const target = line.slice(rawStart, end);
626+
const label = match[1] ?? "";
627+
if (label.length > 0)
628+
spans.push({
629+
url: target,
630+
start: index + 1,
631+
end: index + 1 + label.length,
632+
});
633+
spans.push({ url: target, start: rawStart, end });
634+
}
635+
return spans;
636+
}
637+
638+
/**
639+
* The link target under one source offset: bare URLs first (fidelity for
640+
* URL-shaped link labels), then inline `[label](target)` spans.
641+
*/
642+
function markdownUrlAt(line: string, offset: number): string | null {
643+
for (const hit of findLinks(line)) {
644+
if (offset >= hit.start && offset < hit.end) return hit.url;
645+
}
646+
for (const span of findMarkdownLinks(line)) {
647+
if (offset >= span.start && offset < span.end) return span.url;
648+
}
649+
return null;
650+
}
651+
652+
/**
653+
* Source offsets a painted column can mean within one rendered row. Each
654+
* offset starts painting somewhere in [min, max] (the spread comes from
655+
* concealable markup before it) and paints up to wMax wide; the column hits
656+
* the offset when it falls in that range. Columns before any markup map
657+
* exactly; around markup the set holds neighbors too — the caller opens
658+
* only when every plausible offset agrees on one URL.
659+
*/
660+
function paintedColumnToSource(
661+
line: string,
662+
base: number,
663+
length: number,
664+
column: number,
665+
): number[] {
666+
if (column < 0) return [];
667+
const plausible: number[] = [];
668+
let min = 0;
669+
let max = 0;
670+
let offset = base;
671+
const end = Math.min(line.length, base + length);
672+
while (offset < end) {
673+
const char = line[offset] ?? "";
674+
const codePoint = line.codePointAt(offset) ?? 0;
675+
const wMax = CONCEALABLE.has(char)
676+
? 1
677+
: stringWidth(String.fromCodePoint(codePoint));
678+
if (min <= column && column < max + wMax) plausible.push(offset);
679+
min += CONCEALABLE.has(char) ? 0 : wMax;
680+
max += wMax;
681+
if (min > column) break;
682+
offset += codePoint > 0xffff ? 2 : 1;
683+
}
684+
return plausible;
685+
}
686+
687+
/**
688+
* The link target under terminal-absolute (x, y) inside one painted code
689+
* block: the row maps through the block's own line info to a source line,
690+
* the column maps to plausible source offsets, and the click opens only
691+
* when every plausible offset agrees on one URL — concealment ambiguity
692+
* misses rather than opening wrong. Stale layout or an unexpected library
693+
* shape resolves to null, never throws.
694+
*/
695+
function codeBlockLinkAt(
696+
block: CodeRenderable,
697+
x: number,
698+
y: number,
699+
): string | null {
700+
let content: string;
701+
let info: LineInfo;
702+
try {
703+
content = block.content;
704+
info = block.lineInfo;
705+
} catch {
706+
return null;
707+
}
708+
const row = y - block.screenY;
709+
const column = x - block.screenX;
710+
if (row < 0 || column < 0) return null;
711+
const source = info.lineSources[row];
712+
const base = info.lineStartCols[row];
713+
const length = info.lineWidthCols[row];
714+
if (
715+
source === undefined ||
716+
base === undefined ||
717+
length === undefined ||
718+
!Number.isInteger(source) ||
719+
!Number.isInteger(base) ||
720+
!Number.isInteger(length)
721+
)
722+
return null;
723+
const line = content.split("\n")[source];
724+
if (typeof line !== "string") return null;
725+
let found: string | null = null;
726+
for (const offset of paintedColumnToSource(line, base, length, column)) {
727+
const url = markdownUrlAt(line, offset);
728+
if (url === null) continue;
729+
if (found === null) found = url;
730+
else if (found !== url) return null;
731+
}
732+
return found;
733+
}
734+
735+
/**
736+
* The markdown click target: the raw link target under terminal-absolute
737+
* (x, y), or null when the cell paints no link. Walks from the hit leaf up
738+
* to the nearest painted code block (assistant markdown paints through
739+
* library CodeRenderables, one per block); clicks landing between blocks
740+
* still resolve through the parent markdown node, which pairs the same full
741+
* source with its own line info. TextRenderable rows never resolve here —
742+
* their own armed node handlers own those clicks. Never throws: anything
743+
* unexpected resolves to null so a missed click stays a missed click.
744+
*/
745+
export function markdownLinkAt(
746+
renderer: CliRenderer,
747+
x: number,
748+
y: number,
749+
): string | null {
750+
try {
751+
let current: Renderable | null | undefined;
752+
try {
753+
current = Renderable.renderablesByNumber.get(renderer.hitTest(x, y));
754+
} catch {
755+
return null;
756+
}
757+
while (current) {
758+
if (current instanceof CodeRenderable) {
759+
const url = codeBlockLinkAt(current, x, y);
760+
if (url !== null) return url;
761+
}
762+
current = current.parent;
763+
}
764+
return null;
765+
} catch {
766+
return null;
767+
}
768+
}
769+
599770
/**
600771
* Arm a transcript ancestor as the markdown click target: mouse events bubble
601772
* up from the hit leaf, and markdown blocks paint through childless library
602773
* renderers with no node of ours to arm, so this ancestor handler is the only
603774
* hook that sees their clicks. Ctrl+press stores the link under the pointer
604-
* (getLinkAt reads the same terminal-absolute coordinates events carry); the
605-
* open fires on release only over the same URL, so a press on a link that
775+
* (markdownLinkAt reads the same terminal-absolute coordinates events carry);
776+
* the open fires on release only over the same URL, so a press on a link that
606777
* drags away never opens. Armed rows stop propagation after opening
607778
* themselves, so a click there still opens exactly once; everything goes
608779
* through openUrl, which gates to http(s) — markdown links can carry any
609-
* scheme and getLinkAt hands the raw target back.
780+
* scheme and markdownLinkAt hands the raw target back.
610781
*/
611782
export function armMarkdownLinks(
612783
target: Renderable,
613784
renderer: CliRenderer,
614785
): void {
615786
let press: string | null = null;
616787
target.onMouseDown = (event) => {
617-
press = isUrlOpenClick(event) ? renderer.getLinkAt(event.x, event.y) : null;
788+
press = isUrlOpenClick(event)
789+
? markdownLinkAt(renderer, event.x, event.y)
790+
: null;
618791
};
619792
target.onMouseUp = (event) => {
620793
const start = press;
621794
press = null;
622795
if (start === null || !isUrlOpenClick(event)) return;
623-
if (renderer.getLinkAt(event.x, event.y) === start) openUrl(start);
796+
if (markdownLinkAt(renderer, event.x, event.y) === start) openUrl(start);
624797
};
625798
target.onMouseOut = () => {
626799
press = null;

0 commit comments

Comments
 (0)