Skip to content

Commit 653e4e9

Browse files
Merge pull request #964 from corbitsdev/cl-7346-url-click-through
Open transcript URLs on modifier click
2 parents 40b96b0 + bb436d0 commit 653e4e9

11 files changed

Lines changed: 1651 additions & 52 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: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,37 @@ running its own selection. Two chords cover remaining copy needs:
829829
52 escape sequence when no helper is available, e.g. over SSH). On a
830830
coalesced tool lane the copy resolves to the most recent call's full
831831
output; a single-call row copies its own output, exactly as before.
832+
- **URL click-through (CL-7346).** Holding the platform modifier over an
833+
`http(s)` URL in a plain or structured-text transcript row underlines it;
834+
pressing and releasing on the same URL opens it in the default browser,
835+
while a press that releases anywhere else stays a selection gesture:
836+
- **macOS: Cmd+click.** The terminal itself owns this chord: link spans
837+
carry OSC-8 metadata, so an emulator with OSC-8 support opens the URL
838+
and the app never sees the press. Per-terminal: Terminal.app does not
839+
support OSC-8, so Cmd+click does nothing there; iTerm2 3.5+, Ghostty,
840+
WezTerm, Kitty, and VS Code support it.
841+
- **Linux/Windows: Ctrl+click.** The app opens the URL through the
842+
platform opener (`open` on macOS as fallback, `xdg-open`, `rundll32
843+
url.dll,FileProtocolHandler` — argv spawns, never through a shell).
844+
- **Right-click safety.** The open gesture requires a left (button-0)
845+
press with the modifier held, so Ctrl+right-click never opens a URL —
846+
context menus stay safe.
847+
- Without the modifier, nothing changes: clicks still expand rows and
848+
drags still select-and-copy. With mouse capture off (Alt+M), the
849+
terminal owns every click and the app sees none, so there is nothing to
850+
fight over. Non-`http(s)` targets (`file:`, `mailto:`, `javascript:`,
851+
…) never open anywhere — the gate parses the scheme, it does not
852+
prefix-match. Hover highlighting needs pointer-motion reports, so the
853+
main shell enables them (`enableMouseMovement`, DEC ?1003) alongside
854+
the existing capture; the pickers and setup screens stay opted out.
855+
- Wrapped URLs resolve whole: a URL broken across continuation lines
856+
highlights and opens as the one target from any of its fragments.
857+
- Markdown prose (assistant messages) is click-to-open only: the renderer
858+
paints it through childless code renderers with no node to arm, so there
859+
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.
832863

833864
Arrow keys never scroll anything — inside the prompt they are caret motion
834865
or, at the buffer's edges, prompt-history recall; inside an open overlay's
@@ -899,7 +930,12 @@ terminal. It cannot observe:
899930
Alt+letter, or similar modifier combinations depends on the terminal
900931
negotiating the kitty keyboard protocol (or an equivalent) with the actual
901932
host terminal emulator — the headless harness has no such negotiation to
902-
fail or succeed at.
933+
fail or succeed at. URL click-through (CL-7346) inherits this: the unit
934+
and headless suites pin the gating (Ctrl+press opens, plain click and
935+
Ctrl+drag do not, non-`http(s)` never opens) with a mocked opener, but
936+
only a real terminal can show whether it delivers the held Ctrl on motion
937+
and press events, whether it honors OSC-8 for Cmd+click, or resolves the
938+
`open`/`xdg-open`/`rundll32` spawn into a browser.
903939
- **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and
904940
OSC 52 fallback are exercised with mocked spawn functions in tests; no
905941
test round-trips through a real `pbcopy`/`xclip`/terminal clipboard.

‎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/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/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/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: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ import {
1212
} from "@opentui/core";
1313
import { stringWidth } from "../view/height.js";
1414
import { viewToTableContent, type McpStructuredView } from "../mcp-view.js";
15+
import {
16+
armLinkLine,
17+
buildLinkLine,
18+
findLinks,
19+
paintLinkLine,
20+
splitLinkSpans,
21+
splitWrappedLinkSpans,
22+
} from "../url-links.js";
1523
import {
1624
splitAtSettledHeading,
1725
withholdIncompleteHeading,
@@ -26,6 +34,7 @@ import {
2634
isSentenceRow,
2735
MAIN_AGENT,
2836
paintStreamRow,
37+
plainRowWrapWidth,
2938
rowGroupGap,
3039
streamRowGutter,
3140
toolRowLines,
@@ -198,7 +207,12 @@ function retextStreamRowBody(
198207
return false;
199208
if (node instanceof TextRenderable) {
200209
if (isMarkdownRow(row)) return false;
201-
node.content = paintStreamRow(row, layout).content;
210+
paintPlainRowNode(
211+
node,
212+
row,
213+
paintStreamRow(row, layout),
214+
plainRowWrapWidth(row, layout),
215+
);
202216
return true;
203217
}
204218

@@ -347,11 +361,12 @@ export function buildRowNode(
347361
}
348362

349363
if (!isMarkdownRow(row)) {
350-
const painted = paintStreamRow(row, layout);
351-
return new TextRenderable(ctx, {
352-
content: painted.content,
353-
fg: painted.fg,
354-
});
364+
return buildPlainRowNode(
365+
ctx,
366+
row,
367+
paintStreamRow(row, layout),
368+
plainRowWrapWidth(row, layout),
369+
);
355370
}
356371

357372
const gutter = streamRowGutter(row, layout);
@@ -375,6 +390,64 @@ function markdownBodyOptions(gutter: PaintedStreamLine, width: number) {
375390
} as const;
376391
}
377392

393+
/**
394+
* A literal-text row's paint node: always a single text node, as before. Rows
395+
* holding URLs paint styled text (URL spans carry OSC-8 metadata) and arm as
396+
* Ctrl+click targets; URL-free rows paint the plain string they always have.
397+
*/
398+
function buildPlainRowNode(
399+
ctx: CliRenderer,
400+
row: StreamRow,
401+
painted: PaintedStreamLine,
402+
wrapWidth: number,
403+
): TextRenderable {
404+
const node = new TextRenderable(ctx, {
405+
content: painted.content,
406+
fg: painted.fg,
407+
});
408+
paintPlainRowNode(node, row, painted, wrapWidth);
409+
return node;
410+
}
411+
412+
/**
413+
* Links a plain row's pre-wrap text holds: wrapped fragments reassemble to
414+
* one of these, which is what tells a real wrap across a short fragment line
415+
* apart from a natural line break after the fact.
416+
*/
417+
function plainRowSourceUrls(row: StreamRow): string[] {
418+
return findLinks(`${row.text}\n${row.summary ?? ""}`).map((hit) => hit.url);
419+
}
420+
421+
/**
422+
* Rewrite a plain row's text on its existing node. The node never changes
423+
* shape, so a URL appearing or disappearing repaints in place instead of
424+
* forcing a rebuild.
425+
*/
426+
function paintPlainRowNode(
427+
node: TextRenderable,
428+
row: StreamRow,
429+
painted: PaintedStreamLine,
430+
wrapWidth: number,
431+
): void {
432+
const lines = painted.content.split("\n");
433+
if (!lines.some((line) => findLinks(line).length > 0)) {
434+
node.content = painted.content;
435+
node.fg = painted.fg;
436+
// Route through the armer so a retext that drops the last URL disarms
437+
// the handlers a previous arming installed (armLinkLine clears them).
438+
armLinkLine(node, []);
439+
return;
440+
}
441+
paintLinkLine(
442+
node,
443+
splitWrappedLinkSpans(
444+
lines.map((text) => ({ text: text.trimEnd(), fg: painted.fg })),
445+
wrapWidth,
446+
plainRowSourceUrls(row),
447+
),
448+
);
449+
}
450+
378451
/**
379452
* A markdown row's body. Most rows have no settled heading yet (no heading at
380453
* all, or the only one is still the open tail), and paint through a single
@@ -396,10 +469,10 @@ function createMarkdownBody(
396469
const content = markdownContent(row);
397470
const split = splitAtSettledHeading(content);
398471
if (split === null) {
472+
// Native incremental block stability: only the trailing block is unstable.
399473
return new MarkdownRenderable(ctx, {
400474
...markdownBodyOptions(gutter, width),
401475
content,
402-
// Native incremental block stability: only the trailing block is unstable.
403476
streaming: row.streaming === true,
404477
});
405478
}
@@ -454,8 +527,9 @@ function createStyledLinesRowRenderable(
454527

455528
/**
456529
* 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.
530+
* arrow is its own renderable and can answer a click; a line holding URLs
531+
* paints styled text and arms as a Ctrl+click target (see url-links.ts);
532+
* every other line is a single text node, as before.
459533
*/
460534
function bodyLineNode(
461535
ctx: CliRenderer,
@@ -464,17 +538,12 @@ function bodyLineNode(
464538
): TextRenderable | BoxRenderable {
465539
const split = onToggle === undefined ? null : splitTrailingArrow(line);
466540
if (split === null || onToggle === undefined) {
467-
return new TextRenderable(ctx, {
468-
content: new StyledText(diffLineChunks(line)),
469-
});
541+
return buildLinkLine(ctx, splitLinkSpans(line));
470542
}
471543
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-
);
544+
const body = buildLinkLine(ctx, splitLinkSpans(split.body));
545+
body.flexShrink = 0;
546+
wrapper.add(body);
478547
wrapper.add(
479548
new TextRenderable(ctx, {
480549
content: new StyledText(diffLineChunks([split.arrow])),

0 commit comments

Comments
 (0)