Skip to content

Commit 174d7e3

Browse files
Merge pull request #1043 from corbitsdev/cl-7955-link-resolver-edges
Harden transcript link resolution at markup edges
2 parents 06aba65 + 109f616 commit 174d7e3

2 files changed

Lines changed: 293 additions & 12 deletions

File tree

‎src/tui/url-click.test.ts‎

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import { withTestRenderer } from "./harness";
1717
import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome";
1818
import { createAppShell } from "./shell/index";
1919
import {
20+
isOpenableUrl,
2021
isUnderlined,
22+
markdownLinkAt,
2123
paintLinkLine,
2224
resetUrlOpener,
2325
setUrlOpener,
@@ -518,6 +520,254 @@ describe("Ctrl+clicking a transcript URL", () => {
518520
});
519521
});
520522

523+
describe("transcript markdown resolver edges (CL-7955)", () => {
524+
test("adjacent-link boundary cells miss and never resolve garbage", async () => {
525+
await withTestRenderer(
526+
async (h) => {
527+
const shell = createAppShell(h.renderer, {
528+
terminal: { columns: 80, rows: 24 },
529+
wireKeys: false,
530+
run: "idle",
531+
});
532+
const opened: string[] = [];
533+
setUrlOpener((url) => {
534+
opened.push(url);
535+
});
536+
try {
537+
appendStreamRow(shell, {
538+
role: "assistant",
539+
text: "[a](https://a.com)[b](https://b.com)",
540+
});
541+
const painted = await waitForPaintedCell(h, "a.com");
542+
const line = h.captureCharFrame().split("\n")[painted.y] ?? "";
543+
544+
// Every resolved cell is a real openable target: the junction
545+
// between two adjacent links must miss rather than fuse their
546+
// sources into a garbage URL.
547+
for (let x = 0; x < line.length; x += 1) {
548+
const hit = markdownLinkAt(h.renderer, x, painted.y);
549+
if (hit === null) continue;
550+
expect(isOpenableUrl(hit)).toBe(true);
551+
expect([`https://a.com`, `https://b.com`]).toContain(hit);
552+
}
553+
554+
// The junction cell itself (the ")" before "b (") misses, and
555+
// Ctrl+clicking it opens nothing.
556+
const junction = line.indexOf(")b (");
557+
expect(junction).toBeGreaterThan(-1);
558+
expect(markdownLinkAt(h.renderer, junction, painted.y)).toBeNull();
559+
await h.mockMouse.click(junction, painted.y, 0, {
560+
modifiers: { ctrl: true },
561+
});
562+
await h.renderOnce();
563+
expect(opened).toEqual([]);
564+
565+
// Either side still opens its own target: the labels are
566+
// unambiguous, so the miss stays pinned to the boundary.
567+
const labelA = findCell(h.captureCharFrame(), " a (");
568+
expect(labelA).not.toBeNull();
569+
await h.mockMouse.click(defined(labelA).x + 1, defined(labelA).y, 0, {
570+
modifiers: { ctrl: true },
571+
});
572+
await h.renderOnce();
573+
expect(opened).toEqual(["https://a.com"]);
574+
575+
opened.length = 0;
576+
const targetB = findCell(h.captureCharFrame(), "https://b.com");
577+
expect(targetB).not.toBeNull();
578+
await h.mockMouse.click(defined(targetB).x, defined(targetB).y, 0, {
579+
modifiers: { ctrl: true },
580+
});
581+
await h.renderOnce();
582+
expect(opened).toEqual(["https://b.com"]);
583+
} finally {
584+
resetUrlOpener();
585+
shell.dispose();
586+
}
587+
},
588+
{ width: 80, height: 24 },
589+
);
590+
});
591+
592+
test("non-link prose in a markdown row misses", async () => {
593+
await withTestRenderer(
594+
async (h) => {
595+
const shell = createAppShell(h.renderer, {
596+
terminal: { columns: 80, rows: 24 },
597+
wireKeys: false,
598+
run: "idle",
599+
});
600+
const opened: string[] = [];
601+
setUrlOpener((url) => {
602+
opened.push(url);
603+
});
604+
try {
605+
appendStreamRow(shell, {
606+
role: "assistant",
607+
text: "see https://example.com/docs ok",
608+
});
609+
const bare = await waitForPaintedCell(h, "example.com/docs");
610+
const prose = findCell(h.captureCharFrame(), "see ");
611+
expect(prose).not.toBeNull();
612+
const at = defined(prose);
613+
expect(markdownLinkAt(h.renderer, at.x, at.y)).toBeNull();
614+
await h.mockMouse.click(at.x, at.y, 0, {
615+
modifiers: { ctrl: true },
616+
});
617+
await h.renderOnce();
618+
expect(opened).toEqual([]);
619+
620+
await h.mockMouse.click(bare.x, bare.y, 0, {
621+
modifiers: { ctrl: true },
622+
});
623+
await h.renderOnce();
624+
expect(opened).toEqual(["https://example.com/docs"]);
625+
} finally {
626+
resetUrlOpener();
627+
shell.dispose();
628+
}
629+
},
630+
{ width: 80, height: 24 },
631+
);
632+
});
633+
634+
test("image markup never opens, even with a URL-shaped label", async () => {
635+
await withTestRenderer(
636+
async (h) => {
637+
const shell = createAppShell(h.renderer, {
638+
terminal: { columns: 80, rows: 24 },
639+
wireKeys: false,
640+
run: "idle",
641+
});
642+
const opened: string[] = [];
643+
setUrlOpener((url) => {
644+
opened.push(url);
645+
});
646+
try {
647+
appendStreamRow(shell, {
648+
role: "assistant",
649+
text: "see ![logo](https://example.com/logo.png) ok",
650+
});
651+
const logo = await waitForPaintedCell(h, "logo");
652+
expect(markdownLinkAt(h.renderer, logo.x, logo.y)).toBeNull();
653+
await h.mockMouse.click(logo.x, logo.y, 0, {
654+
modifiers: { ctrl: true },
655+
});
656+
await h.renderOnce();
657+
expect(opened).toEqual([]);
658+
659+
// A URL-shaped image label paints as URL text but stays an
660+
// image: Ctrl+clicking it must not open the label.
661+
appendStreamRow(shell, {
662+
role: "assistant",
663+
text: "see ![https://evil.example/x](https://img.example/y.png) ok",
664+
});
665+
const evil = await waitForPaintedCell(h, "evil.example");
666+
await h.mockMouse.click(evil.x + 1, evil.y, 0, {
667+
modifiers: { ctrl: true },
668+
});
669+
await h.renderOnce();
670+
expect(opened).toEqual([]);
671+
} finally {
672+
resetUrlOpener();
673+
shell.dispose();
674+
}
675+
},
676+
{ width: 80, height: 24 },
677+
);
678+
});
679+
680+
test("a markdown bare URL wrapped across rows opens the full target", async () => {
681+
await withTestRenderer(
682+
async (h) => {
683+
const shell = createAppShell(h.renderer, {
684+
terminal: { columns: 40, rows: 24 },
685+
wireKeys: false,
686+
run: "idle",
687+
});
688+
const opened: string[] = [];
689+
setUrlOpener((url) => {
690+
opened.push(url);
691+
});
692+
try {
693+
const full =
694+
"https://example.com/abcdefghijklmnopqrstuvwxyz0123456789";
695+
appendStreamRow(shell, {
696+
role: "assistant",
697+
text: `checking ${full} today`,
698+
});
699+
const first = await waitForPaintedCell(h, "example.com");
700+
await h.mockMouse.click(first.x, first.y, 0, {
701+
modifiers: { ctrl: true },
702+
});
703+
await h.renderOnce();
704+
expect(opened).toEqual([full]);
705+
} finally {
706+
resetUrlOpener();
707+
shell.dispose();
708+
}
709+
},
710+
{ width: 40, height: 24 },
711+
);
712+
});
713+
714+
test("a markdown link still opens at its post-scroll position", async () => {
715+
await withTestRenderer(
716+
async (h) => {
717+
const shell = createAppShell(h.renderer, {
718+
terminal: { columns: 80, rows: 24 },
719+
wireKeys: false,
720+
run: "idle",
721+
});
722+
const opened: string[] = [];
723+
setUrlOpener((url) => {
724+
opened.push(url);
725+
});
726+
try {
727+
for (let i = 0; i < 25; i += 1) {
728+
appendStreamRow(shell, {
729+
role: "assistant",
730+
text: `filler line ${i}`,
731+
});
732+
}
733+
appendStreamRow(shell, {
734+
role: "assistant",
735+
text: "see https://example.com/docs ok",
736+
});
737+
for (let i = 0; i < 3; i += 1) {
738+
appendStreamRow(shell, {
739+
role: "assistant",
740+
text: `trailing filler ${i}`,
741+
});
742+
}
743+
const before = await waitForPaintedCell(h, "example.com/docs");
744+
for (let i = 0; i < 2; i += 1) {
745+
await h.mockMouse.scroll(before.x, before.y, "up");
746+
}
747+
// Let in-flight scroll work land before clicking: a Ctrl+click
748+
// whose down/up straddles a scroll re-render never arms, so the
749+
// keeper settles first and tests the post-scroll position itself.
750+
await new Promise((r) => setTimeout(r, 100));
751+
await h.renderOnce();
752+
await h.renderOnce();
753+
const after = findCell(h.captureCharFrame(), "example.com/docs");
754+
expect(after).not.toBeNull();
755+
expect(defined(after).y).not.toBe(before.y);
756+
await h.mockMouse.click(defined(after).x, defined(after).y, 0, {
757+
modifiers: { ctrl: true },
758+
});
759+
await h.renderOnce();
760+
expect(opened).toEqual(["https://example.com/docs"]);
761+
} finally {
762+
resetUrlOpener();
763+
shell.dispose();
764+
}
765+
},
766+
{ width: 80, height: 24 },
767+
);
768+
});
769+
});
770+
521771
/** Poll until `needle` paints, rendering between tries. */
522772
async function waitForPaintedCell(
523773
h: {

‎src/tui/url-links.ts‎

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -635,15 +635,45 @@ function findMarkdownLinks(line: string): LinkHit[] {
635635
return spans;
636636
}
637637

638+
/**
639+
* Whole `![label](target)` ranges: bare-URL scanning cannot tell image markup
640+
* from links, so the resolver discards bare matches touching these ranges and
641+
* the spans above already skip them. Images stay non-openable by policy.
642+
*/
643+
function findImageRanges(line: string): { start: number; end: number }[] {
644+
const ranges: { start: number; end: number }[] = [];
645+
for (const match of line.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)) {
646+
const start = match.index ?? 0;
647+
ranges.push({ start, end: start + match[0].length });
648+
}
649+
return ranges;
650+
}
651+
638652
/**
639653
* The link target under one source offset: bare URLs first (fidelity for
640-
* URL-shaped link labels), then inline `[label](target)` spans.
654+
* URL-shaped link labels), then inline `[label](target)` spans. A bare match
655+
* fused across a link span's boundary (`[a](u1)[b](u2)` scans as one run) or
656+
* inside image markup is the matcher's artifact, not a link the line holds,
657+
* so only a bare match one span fully contains — or no span touches — counts.
641658
*/
642659
function markdownUrlAt(line: string, offset: number): string | null {
660+
const spans = findMarkdownLinks(line);
661+
const images = findImageRanges(line);
643662
for (const hit of findLinks(line)) {
644-
if (offset >= hit.start && offset < hit.end) return hit.url;
663+
if (offset >= hit.start && offset < hit.end) {
664+
const fused = spans.some(
665+
(span) =>
666+
hit.start < span.end &&
667+
hit.end > span.start &&
668+
(hit.start < span.start || hit.end > span.end),
669+
);
670+
const imaged = images.some(
671+
(image) => hit.start < image.end && hit.end > image.start,
672+
);
673+
if (!fused && !imaged) return hit.url;
674+
}
645675
}
646-
for (const span of findMarkdownLinks(line)) {
676+
for (const span of spans) {
647677
if (offset >= span.start && offset < span.end) return span.url;
648678
}
649679
return null;
@@ -734,13 +764,15 @@ function codeBlockLinkAt(
734764

735765
/**
736766
* 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.
767+
* (x, y), or null when the cell paints no link. Walks from the hit leaf up to
768+
* the nearest painted code block (assistant markdown paints through library
769+
* CodeRenderables, one per block), and that first block decides: its answer
770+
* stands, with no retry at an ancestor, so a miss inside one block never
771+
* falls through to a wider ancestor that pairs the same column with a link
772+
* the narrower block already rejected. Clicks landing outside any block miss.
773+
* TextRenderable rows never resolve here — their own armed node handlers own
774+
* those clicks. Never throws: anything unexpected resolves to null so a
775+
* missed click stays a missed click.
744776
*/
745777
export function markdownLinkAt(
746778
renderer: CliRenderer,
@@ -756,8 +788,7 @@ export function markdownLinkAt(
756788
}
757789
while (current) {
758790
if (current instanceof CodeRenderable) {
759-
const url = codeBlockLinkAt(current, x, y);
760-
if (url !== null) return url;
791+
return codeBlockLinkAt(current, x, y);
761792
}
762793
current = current.parent;
763794
}

0 commit comments

Comments
 (0)