Skip to content

Commit 3e89d22

Browse files
committed
Open transcript URLs on modifier click
Markdown prose stays terminal-owned: its renderers expose no text-leaf API to highlight or hit-test.
1 parent bf79cc0 commit 3e89d22

7 files changed

Lines changed: 728 additions & 25 deletions

File tree

‎docs/TUI.md‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,28 @@ running its own selection. Two chords cover remaining copy needs:
819819
52 escape sequence when no helper is available, e.g. over SSH). On a
820820
coalesced tool lane the copy resolves to the most recent call's full
821821
output; a single-call row copies its own output, exactly as before.
822+
- **URL click-through (CL-7346).** Holding the platform modifier over an
823+
`http(s)` URL in a plain or structured-text transcript row underlines it;
824+
pressing and releasing on the same URL opens it in the default browser,
825+
while a press that releases anywhere else stays a selection gesture:
826+
- **macOS: Cmd+click.** The terminal itself owns this chord: every link
827+
span carries OSC-8 metadata, so the emulator opens the URL and the app
828+
never sees the press.
829+
- **Linux/Windows: Ctrl+click.** The app opens the URL through the
830+
platform opener (`open` on macOS as fallback, `xdg-open`, `cmd /c
831+
start`).
832+
- Without the modifier, nothing changes: clicks still expand rows and
833+
drags still select-and-copy. With mouse capture off (Alt+M), the
834+
terminal owns every click and the app sees none, so there is nothing to
835+
fight over. Non-`http(s)` targets (`file:`, `mailto:`, `javascript:`,
836+
…) never open anywhere — the gate parses the scheme, it does not
837+
prefix-match. Hover highlighting needs pointer-motion reports, so the
838+
main shell enables them (`enableMouseMovement`, DEC ?1003) alongside
839+
the existing capture; the pickers and setup screens stay opted out.
840+
- Markdown prose (assistant messages) is not covered: the renderer paints
841+
it through childless code renderers with no stable text-leaf API to
842+
highlight or hit-test, so those links stay terminal business until the
843+
library exposes one.
822844

823845
Arrow keys never scroll anything — inside the prompt they are caret motion
824846
or, at the buffer's edges, prompt-history recall; inside an open overlay's
@@ -889,7 +911,11 @@ terminal. It cannot observe:
889911
Alt+letter, or similar modifier combinations depends on the terminal
890912
negotiating the kitty keyboard protocol (or an equivalent) with the actual
891913
host terminal emulator — the headless harness has no such negotiation to
892-
fail or succeed at.
914+
fail or succeed at. URL click-through (CL-7346) inherits this: the unit
915+
and headless suites pin the gating (Ctrl+press opens, plain click and
916+
Ctrl+drag do not, non-`http(s)` never opens) with a mocked opener, but
917+
only a real terminal can show whether it delivers the held Ctrl on motion
918+
and press events, or resolves the `open`/`xdg-open` spawn into a browser.
893919
- **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and
894920
OSC 52 fallback are exercised with mocked spawn functions in tests; no
895921
test round-trips through a real `pbcopy`/`xclip`/terminal clipboard.

‎src/tui/product-host.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,14 @@ export async function mountProductHost(
280280
// Cost accepted: this suppresses the terminal's *native* drag-select
281281
// in the main shell. OpenTUI selection still works and auto-copies
282282
// on mouse-up; Alt+M hands the mouse back when native select is wanted.
283-
// enableMouseMovement stays off (no ?1003): only clicks and wheel
284-
// are needed.
283+
// enableMouseMovement stays on (?1003): URL hover highlighting
284+
// (CL-7346) needs pointer motion with the modifier held — clicks and
285+
// wheel alone never report where an unpressed pointer is. Cost
286+
// accepted alongside the native-drag-select one above: a motion event
287+
// per pointer move while capture is on; Alt+M still hands the mouse
288+
// back when native select is wanted.
285289
useMouse: config.useMouse ?? true,
286-
enableMouseMovement: false,
290+
enableMouseMovement: true,
287291
// A plain terminal sends a bare CR for both Enter and Shift+Enter, so
288292
// the modifier only arrives once the kitty keyboard protocol is
289293
// negotiated. Empty object, not explicit flags: this matches what

‎src/tui/shell/row-retext.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88
import {
99
BoxRenderable,
10-
StyledText,
1110
TextRenderable,
1211
TextTableRenderable,
1312
bold as boldChunk,
@@ -17,6 +16,7 @@ import {
1716
} from "@opentui/core";
1817
import { stringWidth } from "../view/height.js";
1918
import { viewToTableContent, type McpStructuredView } from "../mcp-view.js";
19+
import { splitLinkSpans, paintLinkLine } from "../url-links.js";
2020
import {
2121
splitTrailingArrow,
2222
expandedRowLines,
@@ -125,13 +125,15 @@ function retextBodyLine(
125125
const split = splitTrailingArrow(line);
126126
if (node instanceof TextRenderable) {
127127
if (split !== null) return false;
128-
node.content = new StyledText(diffLineChunks(line));
128+
// A URL appearing or disappearing repaints on the same node; re-arming
129+
// refreshes the hit ranges, so hover never resolves against stale text.
130+
paintLinkLine(node, [splitLinkSpans(line)]);
129131
return true;
130132
}
131133
if (!(node instanceof BoxRenderable) || split === null) return false;
132134
const [bodyNode] = node.getChildren();
133135
if (!(bodyNode instanceof TextRenderable)) return false;
134-
bodyNode.content = new StyledText(diffLineChunks(split.body));
136+
paintLinkLine(bodyNode, [splitLinkSpans(split.body)]);
135137
return true;
136138
}
137139

‎src/tui/shell/transcript.ts‎

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import {
1212
} from "@opentui/core";
1313
import { stringWidth } from "../view/height.js";
1414
import { viewToTableContent, type McpStructuredView } from "../mcp-view.js";
15+
import {
16+
buildLinkLine,
17+
findLinks,
18+
paintLinkLine,
19+
splitLinkSpans,
20+
} from "../url-links.js";
1521
import {
1622
splitAtSettledHeading,
1723
withholdIncompleteHeading,
@@ -198,7 +204,7 @@ function retextStreamRowBody(
198204
return false;
199205
if (node instanceof TextRenderable) {
200206
if (isMarkdownRow(row)) return false;
201-
node.content = paintStreamRow(row, layout).content;
207+
paintPlainRowNode(node, paintStreamRow(row, layout));
202208
return true;
203209
}
204210

@@ -347,11 +353,7 @@ export function buildRowNode(
347353
}
348354

349355
if (!isMarkdownRow(row)) {
350-
const painted = paintStreamRow(row, layout);
351-
return new TextRenderable(ctx, {
352-
content: painted.content,
353-
fg: painted.fg,
354-
});
356+
return buildPlainRowNode(ctx, paintStreamRow(row, layout));
355357
}
356358

357359
const gutter = streamRowGutter(row, layout);
@@ -375,6 +377,44 @@ function markdownBodyOptions(gutter: PaintedStreamLine, width: number) {
375377
} as const;
376378
}
377379

380+
/**
381+
* A literal-text row's paint node: always a single text node, as before. Rows
382+
* holding URLs paint styled text (URL spans carry OSC-8 metadata) and arm as
383+
* Ctrl+click targets; URL-free rows paint the plain string they always have.
384+
*/
385+
function buildPlainRowNode(
386+
ctx: CliRenderer,
387+
painted: PaintedStreamLine,
388+
): TextRenderable {
389+
const node = new TextRenderable(ctx, {
390+
content: painted.content,
391+
fg: painted.fg,
392+
});
393+
paintPlainRowNode(node, painted);
394+
return node;
395+
}
396+
397+
/**
398+
* Rewrite a plain row's text on its existing node. The node never changes
399+
* shape, so a URL appearing or disappearing repaints in place instead of
400+
* forcing a rebuild.
401+
*/
402+
function paintPlainRowNode(
403+
node: TextRenderable,
404+
painted: PaintedStreamLine,
405+
): void {
406+
const lines = painted.content.split("\n");
407+
if (!lines.some((line) => findLinks(line).length > 0)) {
408+
node.content = painted.content;
409+
node.fg = painted.fg;
410+
return;
411+
}
412+
paintLinkLine(
413+
node,
414+
lines.map((line) => splitLinkSpans([{ text: line, fg: painted.fg }])),
415+
);
416+
}
417+
378418
/**
379419
* A markdown row's body. Most rows have no settled heading yet (no heading at
380420
* all, or the only one is still the open tail), and paint through a single
@@ -396,10 +436,10 @@ function createMarkdownBody(
396436
const content = markdownContent(row);
397437
const split = splitAtSettledHeading(content);
398438
if (split === null) {
439+
// Native incremental block stability: only the trailing block is unstable.
399440
return new MarkdownRenderable(ctx, {
400441
...markdownBodyOptions(gutter, width),
401442
content,
402-
// Native incremental block stability: only the trailing block is unstable.
403443
streaming: row.streaming === true,
404444
});
405445
}
@@ -454,8 +494,9 @@ function createStyledLinesRowRenderable(
454494

455495
/**
456496
* One painted body line. A line ending in an expand arrow is split so the
457-
* arrow is its own renderable and can answer a click; every other line is a
458-
* single text node, as before.
497+
* arrow is its own renderable and can answer a click; a line holding URLs
498+
* paints styled text and arms as a Ctrl+click target (see url-links.ts);
499+
* every other line is a single text node, as before.
459500
*/
460501
function bodyLineNode(
461502
ctx: CliRenderer,
@@ -464,17 +505,12 @@ function bodyLineNode(
464505
): TextRenderable | BoxRenderable {
465506
const split = onToggle === undefined ? null : splitTrailingArrow(line);
466507
if (split === null || onToggle === undefined) {
467-
return new TextRenderable(ctx, {
468-
content: new StyledText(diffLineChunks(line)),
469-
});
508+
return buildLinkLine(ctx, splitLinkSpans(line));
470509
}
471510
const wrapper = new BoxRenderable(ctx, { flexDirection: "row", flexGrow: 1 });
472-
wrapper.add(
473-
new TextRenderable(ctx, {
474-
content: new StyledText(diffLineChunks(split.body)),
475-
flexShrink: 0,
476-
}),
477-
);
511+
const body = buildLinkLine(ctx, splitLinkSpans(split.body));
512+
body.flexShrink = 0;
513+
wrapper.add(body);
478514
wrapper.add(
479515
new TextRenderable(ctx, {
480516
content: new StyledText(diffLineChunks([split.arrow])),

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* 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.
4+
*
5+
* The opener is mocked (setUrlOpener) — no test spawns a real browser.
6+
* Whether a real terminal reports the Ctrl modifier is a harness blind
7+
* spot (docs/TUI.md); headless, the mock delivers it like any click.
8+
*/
9+
import { describe, expect, test } from "bun:test";
10+
11+
import { defined } from "../../tests/helpers/defined.js";
12+
import { withTestRenderer } from "./harness";
13+
import { appendStreamRow } from "./shell/chrome";
14+
import { createAppShell } from "./shell/index";
15+
import { isUnderlined, resetUrlOpener, setUrlOpener } from "./url-links";
16+
import { type StreamRow } from "./stream";
17+
18+
const CALL: StreamRow = {
19+
role: "tool",
20+
text: "",
21+
meta: "web_fetch",
22+
verb: "Web Fetch",
23+
summary: "see https://www.example.com/docs for details",
24+
detail: [[{ text: "url: https://www.example.com/docs", fg: "#f7ead5" }]],
25+
};
26+
27+
/** Screen position of the first cell of `needle`, or null when not painted. */
28+
function findCell(
29+
frame: string,
30+
needle: string,
31+
): { readonly x: number; readonly y: number } | null {
32+
const lines = frame.split("\n");
33+
for (const [y, line] of lines.entries()) {
34+
const x = line.indexOf(needle);
35+
if (x !== -1) return { x, y };
36+
}
37+
return null;
38+
}
39+
40+
describe("Ctrl+clicking a transcript URL", () => {
41+
test("opens it, while plain click and Ctrl+drag do not", async () => {
42+
await withTestRenderer(
43+
async (h) => {
44+
const shell = createAppShell(h.renderer, {
45+
terminal: { columns: 80, rows: 24 },
46+
wireKeys: false,
47+
run: "idle",
48+
});
49+
const opened: string[] = [];
50+
setUrlOpener((url) => {
51+
opened.push(url);
52+
});
53+
try {
54+
appendStreamRow(shell, CALL);
55+
await h.renderOnce();
56+
57+
const link = findCell(h.captureCharFrame(), "example.com");
58+
expect(link).not.toBeNull();
59+
const at = defined(link);
60+
61+
await h.mockMouse.click(at.x, at.y, 0, {
62+
modifiers: { ctrl: true },
63+
});
64+
await h.renderOnce();
65+
expect(opened).toEqual(["https://www.example.com/docs"]);
66+
67+
opened.length = 0;
68+
await h.mockMouse.click(at.x, at.y);
69+
await h.renderOnce();
70+
expect(opened).toEqual([]);
71+
expect(shell.streamLog[0]?.expanded).not.toBe(true);
72+
73+
await h.mockMouse.drag(at.x, at.y, at.x + 12, at.y, 0, {
74+
modifiers: { ctrl: true },
75+
});
76+
await h.renderOnce();
77+
expect(opened).toEqual([]);
78+
} finally {
79+
resetUrlOpener();
80+
shell.dispose();
81+
}
82+
},
83+
{ width: 80, height: 24 },
84+
);
85+
});
86+
87+
test("Ctrl+hover underlines the link until the pointer leaves it", async () => {
88+
await withTestRenderer(
89+
async (h) => {
90+
const shell = createAppShell(h.renderer, {
91+
terminal: { columns: 80, rows: 24 },
92+
wireKeys: false,
93+
run: "idle",
94+
});
95+
try {
96+
appendStreamRow(shell, CALL);
97+
await h.renderOnce();
98+
99+
const link = findCell(h.captureCharFrame(), "example.com");
100+
expect(link).not.toBeNull();
101+
const at = defined(link);
102+
103+
const linkSpanUnderlined = (): boolean =>
104+
defined(h.captureSpans().lines[at.y]).spans.some(
105+
(span) =>
106+
span.text.includes("example.com") &&
107+
isUnderlined(span.attributes),
108+
);
109+
110+
await h.mockMouse.moveTo(at.x, at.y, {
111+
modifiers: { ctrl: true },
112+
});
113+
await h.renderOnce();
114+
expect(linkSpanUnderlined()).toBe(true);
115+
116+
await h.mockMouse.moveTo(at.x, at.y);
117+
await h.renderOnce();
118+
expect(linkSpanUnderlined()).toBe(false);
119+
} finally {
120+
shell.dispose();
121+
}
122+
},
123+
{ width: 80, height: 24 },
124+
);
125+
});
126+
});

0 commit comments

Comments
 (0)