Skip to content

Commit bb436d0

Browse files
committed
Open markdown transcript URLs with Ctrl+click
Assistant markdown paints through library renderables the app cannot arm, so those links stayed terminal-owned. The renderer now exposes getLinkAt link hit-testing (OpenTUI 0.5.11), so a bubbling transcript-root handler can resolve markdown clicks and open them the same way armed plain rows already do.
1 parent 9ed5509 commit bb436d0

6 files changed

Lines changed: 246 additions & 53 deletions

File tree

bun.lock

Lines changed: 18 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/TUI.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -844,10 +844,12 @@ url.dll,FileProtocolHandler` — argv spawns, never through a shell).
844844
the existing capture; the pickers and setup screens stay opted out.
845845
- Wrapped URLs resolve whole: a URL broken across continuation lines
846846
highlights and opens as the one target from any of its fragments.
847-
- Markdown prose (assistant messages) is not covered: the renderer paints
848-
it through childless code renderers with no stable text-leaf API to
849-
highlight or hit-test, so those links stay terminal business until the
850-
library exposes one.
847+
- Markdown prose (assistant messages) is click-to-open only: the renderer
848+
paints it through childless code renderers with no node to arm, so there
849+
is no hover underline. A bubbling handler on the transcript root resolves
850+
the click through the renderer's `getLinkAt` link map (OpenTUI 0.5.11+)
851+
and opens on press-and-release over the same URL — armed rows keep their
852+
hover underline and open through their own node handlers.
851853

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

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
"@intx/types": "workspace:*",
101101
"@intx/workflow-host": "0.3.0",
102102
"@modelcontextprotocol/sdk": "^1.29.0",
103-
"@opentui/core": "0.5.10",
103+
"@opentui/core": "0.5.11",
104104
"arktype": "catalog:",
105105
"highlight.js": "^11.11.1",
106106
"isomorphic-git": "catalog:"
@@ -115,13 +115,13 @@
115115
"ws": "^8.21.0"
116116
},
117117
"optionalDependencies": {
118-
"@opentui/core-darwin-arm64": "0.5.10",
119-
"@opentui/core-darwin-x64": "0.5.10",
120-
"@opentui/core-linux-arm64": "0.5.10",
121-
"@opentui/core-linux-arm64-musl": "0.5.10",
122-
"@opentui/core-linux-x64": "0.5.10",
123-
"@opentui/core-linux-x64-musl": "0.5.10",
124-
"@opentui/core-win32-arm64": "0.5.10",
125-
"@opentui/core-win32-x64": "0.5.10"
118+
"@opentui/core-darwin-arm64": "0.5.11",
119+
"@opentui/core-darwin-x64": "0.5.11",
120+
"@opentui/core-linux-arm64": "0.5.11",
121+
"@opentui/core-linux-arm64-musl": "0.5.11",
122+
"@opentui/core-linux-x64": "0.5.11",
123+
"@opentui/core-linux-x64-musl": "0.5.11",
124+
"@opentui/core-win32-arm64": "0.5.11",
125+
"@opentui/core-win32-x64": "0.5.11"
126126
}
127127
}

src/tui/shell/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
submitPrompt,
7373
syncPromptHighlights,
7474
} from "./prompt.js";
75+
import { armMarkdownLinks } from "../url-links.js";
7576
import {
7677
createShellKeyHandlers,
7778
routePromptWheelToTranscript,
@@ -211,6 +212,10 @@ export function createAppShell(
211212
// every row to say so. Position is legible from the content itself.
212213
transcript.verticalScrollBar.visible = false;
213214
transcript.horizontalScrollBar.visible = false;
215+
// Markdown blocks have no node of ours to arm; this bubbling handler is
216+
// what makes their links Ctrl+click-to-open (armed rows stop propagation
217+
// after opening, so a click opens exactly once either way).
218+
armMarkdownLinks(transcript, ctx);
214219

215220
// Leading filler that bottom-anchors a short transcript; see
216221
// `syncTranscriptSpacer`. Zero height until the first sync call.

src/tui/url-click.test.ts

Lines changed: 161 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/**
22
* URL click-through (CL-7346): Ctrl+click opens an http(s) URL in the
3-
* default browser; a plain click keeps today's row behavior.
3+
* default browser; a plain click keeps today's row behavior. Armed
4+
* plain/structured rows open through their own node handlers (with hover
5+
* highlight); assistant markdown opens through the bubbling transcript
6+
* handler — click only, no hover highlight.
47
*
58
* The opener is mocked (setUrlOpener) — no test spawns a real browser.
69
* Whether a real terminal reports the Ctrl modifier is a harness blind
@@ -339,7 +342,7 @@ describe("Ctrl+clicking a transcript URL", () => {
339342
);
340343
});
341344

342-
test("assistant markdown links stay terminal business (no opener call)", async () => {
345+
test("assistant markdown bare URL and link label open on Ctrl+click", async () => {
343346
await withTestRenderer(
344347
async (h) => {
345348
const shell = createAppShell(h.renderer, {
@@ -352,31 +355,120 @@ describe("Ctrl+clicking a transcript URL", () => {
352355
opened.push(url);
353356
});
354357
try {
355-
// Markdown prose paints through childless library renderers with
356-
// no text-leaf API to arm or hit-test (docs/TUI.md), so neither
357-
// the bare URL nor the explicit link label opens through us.
358-
// The real terminal owns those cells; this pins that remainder.
358+
// Markdown blocks paint through childless library renderers, so
359+
// their clicks are only visible through the bubbling transcript
360+
// handler armed by createAppShell.
359361
appendStreamRow(shell, {
360362
role: "assistant",
361363
text: "see https://example.com/docs and [guide](https://example.com/guide) ok",
362364
});
363365
// Assistant rows are markdown; their blocks highlight
364-
// asynchronously (see shell.test.ts), so the frame only carries
365-
// the prose after a settle.
366-
await new Promise((resolve) => setTimeout(resolve, 250));
366+
// asynchronously (see shell.test.ts), so wait for the paint
367+
// instead of sleeping a fixed settle.
368+
const bare = await waitForPaintedCell(h, "example.com/docs");
369+
await h.mockMouse.click(bare.x, bare.y, 0, {
370+
modifiers: { ctrl: true },
371+
});
372+
await h.renderOnce();
373+
expect(opened).toEqual(["https://example.com/docs"]);
374+
375+
opened.length = 0;
376+
const label = await waitForPaintedCell(h, "guide");
377+
await h.mockMouse.click(label.x, label.y, 0, {
378+
modifiers: { ctrl: true },
379+
});
380+
await h.renderOnce();
381+
expect(opened).toEqual(["https://example.com/guide"]);
382+
} finally {
383+
resetUrlOpener();
384+
shell.dispose();
385+
}
386+
},
387+
{ width: 80, height: 24 },
388+
);
389+
});
390+
391+
test("plain click on a markdown link does not open", async () => {
392+
await withTestRenderer(
393+
async (h) => {
394+
const shell = createAppShell(h.renderer, {
395+
terminal: { columns: 80, rows: 24 },
396+
wireKeys: false,
397+
run: "idle",
398+
});
399+
const opened: string[] = [];
400+
setUrlOpener((url) => {
401+
opened.push(url);
402+
});
403+
try {
404+
appendStreamRow(shell, {
405+
role: "assistant",
406+
text: "see https://example.com/docs ok",
407+
});
408+
const bare = await waitForPaintedCell(h, "example.com/docs");
409+
await h.mockMouse.click(bare.x, bare.y);
367410
await h.renderOnce();
411+
expect(opened).toEqual([]);
412+
} finally {
413+
resetUrlOpener();
414+
shell.dispose();
415+
}
416+
},
417+
{ width: 80, height: 24 },
418+
);
419+
});
368420

369-
const bare = findCell(h.captureCharFrame(), "example.com/docs");
370-
expect(bare).not.toBeNull();
371-
await h.mockMouse.click(defined(bare).x, defined(bare).y, 0, {
421+
test("a markdown link to a non-http(s) target never opens", async () => {
422+
await withTestRenderer(
423+
async (h) => {
424+
const shell = createAppShell(h.renderer, {
425+
terminal: { columns: 80, rows: 24 },
426+
wireKeys: false,
427+
run: "idle",
428+
});
429+
const opened: string[] = [];
430+
setUrlOpener((url) => {
431+
opened.push(url);
432+
});
433+
try {
434+
appendStreamRow(shell, {
435+
role: "assistant",
436+
text: "see [target](custom://thing/pull/1) ok",
437+
});
438+
const label = await waitForPaintedCell(h, "target");
439+
await h.mockMouse.click(label.x, label.y, 0, {
372440
modifiers: { ctrl: true },
373441
});
374442
await h.renderOnce();
375443
expect(opened).toEqual([]);
444+
} finally {
445+
resetUrlOpener();
446+
shell.dispose();
447+
}
448+
},
449+
{ width: 80, height: 24 },
450+
);
451+
});
376452

377-
const label = findCell(h.captureCharFrame(), "guide");
378-
expect(label).not.toBeNull();
379-
await h.mockMouse.click(defined(label).x, defined(label).y, 0, {
453+
test("Ctrl+press on a markdown link, release off it, does not open", async () => {
454+
await withTestRenderer(
455+
async (h) => {
456+
const shell = createAppShell(h.renderer, {
457+
terminal: { columns: 80, rows: 24 },
458+
wireKeys: false,
459+
run: "idle",
460+
});
461+
const opened: string[] = [];
462+
setUrlOpener((url) => {
463+
opened.push(url);
464+
});
465+
try {
466+
appendStreamRow(shell, {
467+
role: "assistant",
468+
text: "see https://example.com/docs and more prose here ok",
469+
});
470+
const bare = await waitForPaintedCell(h, "example.com/docs");
471+
await h.mockMouse.drag(bare.x, bare.y, bare.x + 30, bare.y, 0, {
380472
modifiers: { ctrl: true },
381473
});
382474
await h.renderOnce();
@@ -389,4 +481,58 @@ describe("Ctrl+clicking a transcript URL", () => {
389481
{ width: 80, height: 24 },
390482
);
391483
});
484+
485+
test("Ctrl+click on an armed plain-row link opens exactly once", async () => {
486+
await withTestRenderer(
487+
async (h) => {
488+
const shell = createAppShell(h.renderer, {
489+
terminal: { columns: 80, rows: 24 },
490+
wireKeys: false,
491+
run: "idle",
492+
});
493+
const opened: string[] = [];
494+
setUrlOpener((url) => {
495+
opened.push(url);
496+
});
497+
try {
498+
// The armed row's own release handler opens and stops propagation;
499+
// the transcript-root markdown handler must not see the same
500+
// gesture and open the (getLinkAt-resolved) target a second time.
501+
appendStreamRow(shell, {
502+
role: "user",
503+
text: "see https://example.com/x ok",
504+
});
505+
const link = await waitForPaintedCell(h, "example.com");
506+
await h.mockMouse.click(link.x, link.y, 0, {
507+
modifiers: { ctrl: true },
508+
});
509+
await h.renderOnce();
510+
expect(opened).toEqual(["https://example.com/x"]);
511+
} finally {
512+
resetUrlOpener();
513+
shell.dispose();
514+
}
515+
},
516+
{ width: 80, height: 24 },
517+
);
518+
});
392519
});
520+
521+
/** Poll until `needle` paints, rendering between tries. */
522+
async function waitForPaintedCell(
523+
h: {
524+
renderOnce: () => Promise<void>;
525+
captureCharFrame: () => string;
526+
},
527+
needle: string,
528+
timeoutMs = 2000,
529+
): Promise<{ readonly x: number; readonly y: number }> {
530+
const deadline = Date.now() + timeoutMs;
531+
for (;;) {
532+
await h.renderOnce();
533+
const cell = findCell(h.captureCharFrame(), needle);
534+
if (cell !== null) return cell;
535+
if (Date.now() > deadline) throw new Error(`never painted: ${needle}`);
536+
await new Promise((resolve) => setTimeout(resolve, 25));
537+
}
538+
}

0 commit comments

Comments
 (0)