Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 37 additions & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,37 @@ running its own selection. Two chords cover remaining copy needs:
52 escape sequence when no helper is available, e.g. over SSH). On a
coalesced tool lane the copy resolves to the most recent call's full
output; a single-call row copies its own output, exactly as before.
- **URL click-through (CL-7346).** Holding the platform modifier over an
`http(s)` URL in a plain or structured-text transcript row underlines it;
pressing and releasing on the same URL opens it in the default browser,
while a press that releases anywhere else stays a selection gesture:
- **macOS: Cmd+click.** The terminal itself owns this chord: link spans
carry OSC-8 metadata, so an emulator with OSC-8 support opens the URL
and the app never sees the press. Per-terminal: Terminal.app does not
support OSC-8, so Cmd+click does nothing there; iTerm2 3.5+, Ghostty,
WezTerm, Kitty, and VS Code support it.
- **Linux/Windows: Ctrl+click.** The app opens the URL through the
platform opener (`open` on macOS as fallback, `xdg-open`, `rundll32
url.dll,FileProtocolHandler` — argv spawns, never through a shell).
- **Right-click safety.** The open gesture requires a left (button-0)
press with the modifier held, so Ctrl+right-click never opens a URL —
context menus stay safe.
- Without the modifier, nothing changes: clicks still expand rows and
drags still select-and-copy. With mouse capture off (Alt+M), the
terminal owns every click and the app sees none, so there is nothing to
fight over. Non-`http(s)` targets (`file:`, `mailto:`, `javascript:`,
…) never open anywhere — the gate parses the scheme, it does not
prefix-match. Hover highlighting needs pointer-motion reports, so the
main shell enables them (`enableMouseMovement`, DEC ?1003) alongside
the existing capture; the pickers and setup screens stay opted out.
- Wrapped URLs resolve whole: a URL broken across continuation lines
highlights and opens as the one target from any of its fragments.
- Markdown prose (assistant messages) is click-to-open only: the renderer
paints it through childless code renderers with no node to arm, so there
is no hover underline. A bubbling handler on the transcript root resolves
the click through the renderer's `getLinkAt` link map (OpenTUI 0.5.11+)
and opens on press-and-release over the same URL — armed rows keep their
hover underline and open through their own node handlers.

Arrow keys never scroll anything — inside the prompt they are caret motion
or, at the buffer's edges, prompt-history recall; inside an open overlay's
Expand Down Expand Up @@ -889,7 +920,12 @@ terminal. It cannot observe:
Alt+letter, or similar modifier combinations depends on the terminal
negotiating the kitty keyboard protocol (or an equivalent) with the actual
host terminal emulator — the headless harness has no such negotiation to
fail or succeed at.
fail or succeed at. URL click-through (CL-7346) inherits this: the unit
and headless suites pin the gating (Ctrl+press opens, plain click and
Ctrl+drag do not, non-`http(s)` never opens) with a mocked opener, but
only a real terminal can show whether it delivers the held Ctrl on motion
and press events, whether it honors OSC-8 for Cmd+click, or resolves the
`open`/`xdg-open`/`rundll32` spawn into a browser.
- **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and
OSC 52 fallback are exercised with mocked spawn functions in tests; no
test round-trips through a real `pbcopy`/`xclip`/terminal clipboard.
Expand Down
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
"@intx/types": "workspace:*",
"@intx/workflow-host": "0.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentui/core": "0.5.10",
"@opentui/core": "0.5.11",
"arktype": "catalog:",
"highlight.js": "^11.11.1",
"isomorphic-git": "catalog:"
Expand All @@ -115,13 +115,13 @@
"ws": "^8.21.0"
},
"optionalDependencies": {
"@opentui/core-darwin-arm64": "0.5.10",
"@opentui/core-darwin-x64": "0.5.10",
"@opentui/core-linux-arm64": "0.5.10",
"@opentui/core-linux-arm64-musl": "0.5.10",
"@opentui/core-linux-x64": "0.5.10",
"@opentui/core-linux-x64-musl": "0.5.10",
"@opentui/core-win32-arm64": "0.5.10",
"@opentui/core-win32-x64": "0.5.10"
"@opentui/core-darwin-arm64": "0.5.11",
"@opentui/core-darwin-x64": "0.5.11",
"@opentui/core-linux-arm64": "0.5.11",
"@opentui/core-linux-arm64-musl": "0.5.11",
"@opentui/core-linux-x64": "0.5.11",
"@opentui/core-linux-x64-musl": "0.5.11",
"@opentui/core-win32-arm64": "0.5.11",
"@opentui/core-win32-x64": "0.5.11"
}
}
10 changes: 7 additions & 3 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,14 @@ export async function mountProductHost(
// Cost accepted: this suppresses the terminal's *native* drag-select
// in the main shell. OpenTUI selection still works and auto-copies
// on mouse-up; Alt+M hands the mouse back when native select is wanted.
// enableMouseMovement stays off (no ?1003): only clicks and wheel
// are needed.
// enableMouseMovement stays on (?1003): URL hover highlighting
// (CL-7346) needs pointer motion with the modifier held — clicks and
// wheel alone never report where an unpressed pointer is. Cost
// accepted alongside the native-drag-select one above: a motion event
// per pointer move while capture is on; Alt+M still hands the mouse
// back when native select is wanted.
useMouse: config.useMouse ?? true,
enableMouseMovement: false,
enableMouseMovement: true,
// A plain terminal sends a bare CR for both Enter and Shift+Enter, so
// the modifier only arrives once the kitty keyboard protocol is
// negotiated. Empty object, not explicit flags: this matches what
Expand Down
5 changes: 5 additions & 0 deletions src/tui/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
submitPrompt,
syncPromptHighlights,
} from "./prompt.js";
import { armMarkdownLinks } from "../url-links.js";
import {
createShellKeyHandlers,
routePromptWheelToTranscript,
Expand Down Expand Up @@ -211,6 +212,10 @@ export function createAppShell(
// every row to say so. Position is legible from the content itself.
transcript.verticalScrollBar.visible = false;
transcript.horizontalScrollBar.visible = false;
// Markdown blocks have no node of ours to arm; this bubbling handler is
// what makes their links Ctrl+click-to-open (armed rows stop propagation
// after opening, so a click opens exactly once either way).
armMarkdownLinks(transcript, ctx);

// Leading filler that bottom-anchors a short transcript; see
// `syncTranscriptSpacer`. Zero height until the first sync call.
Expand Down
8 changes: 5 additions & 3 deletions src/tui/shell/row-retext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/
import {
BoxRenderable,
StyledText,
TextRenderable,
TextTableRenderable,
bold as boldChunk,
Expand All @@ -17,6 +16,7 @@ import {
} from "@opentui/core";
import { stringWidth } from "../view/height.js";
import { viewToTableContent, type McpStructuredView } from "../mcp-view.js";
import { splitLinkSpans, paintLinkLine } from "../url-links.js";
import {
splitTrailingArrow,
expandedRowLines,
Expand Down Expand Up @@ -125,13 +125,15 @@ function retextBodyLine(
const split = splitTrailingArrow(line);
if (node instanceof TextRenderable) {
if (split !== null) return false;
node.content = new StyledText(diffLineChunks(line));
// A URL appearing or disappearing repaints on the same node; re-arming
// refreshes the hit ranges, so hover never resolves against stale text.
paintLinkLine(node, [splitLinkSpans(line)]);
return true;
}
if (!(node instanceof BoxRenderable) || split === null) return false;
const [bodyNode] = node.getChildren();
if (!(bodyNode instanceof TextRenderable)) return false;
bodyNode.content = new StyledText(diffLineChunks(split.body));
paintLinkLine(bodyNode, [splitLinkSpans(split.body)]);
return true;
}

Expand Down
105 changes: 87 additions & 18 deletions src/tui/shell/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import {
} from "@opentui/core";
import { stringWidth } from "../view/height.js";
import { viewToTableContent, type McpStructuredView } from "../mcp-view.js";
import {
armLinkLine,
buildLinkLine,
findLinks,
paintLinkLine,
splitLinkSpans,
splitWrappedLinkSpans,
} from "../url-links.js";
import {
splitAtSettledHeading,
withholdIncompleteHeading,
Expand All @@ -26,6 +34,7 @@ import {
isSentenceRow,
MAIN_AGENT,
paintStreamRow,
plainRowWrapWidth,
rowGroupGap,
streamRowGutter,
toolRowLines,
Expand Down Expand Up @@ -198,7 +207,12 @@ function retextStreamRowBody(
return false;
if (node instanceof TextRenderable) {
if (isMarkdownRow(row)) return false;
node.content = paintStreamRow(row, layout).content;
paintPlainRowNode(
node,
row,
paintStreamRow(row, layout),
plainRowWrapWidth(row, layout),
);
return true;
}

Expand Down Expand Up @@ -347,11 +361,12 @@ export function buildRowNode(
}

if (!isMarkdownRow(row)) {
const painted = paintStreamRow(row, layout);
return new TextRenderable(ctx, {
content: painted.content,
fg: painted.fg,
});
return buildPlainRowNode(
ctx,
row,
paintStreamRow(row, layout),
plainRowWrapWidth(row, layout),
);
}

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

/**
* A literal-text row's paint node: always a single text node, as before. Rows
* holding URLs paint styled text (URL spans carry OSC-8 metadata) and arm as
* Ctrl+click targets; URL-free rows paint the plain string they always have.
*/
function buildPlainRowNode(
ctx: CliRenderer,
row: StreamRow,
painted: PaintedStreamLine,
wrapWidth: number,
): TextRenderable {
const node = new TextRenderable(ctx, {
content: painted.content,
fg: painted.fg,
});
paintPlainRowNode(node, row, painted, wrapWidth);
return node;
}

/**
* Links a plain row's pre-wrap text holds: wrapped fragments reassemble to
* one of these, which is what tells a real wrap across a short fragment line
* apart from a natural line break after the fact.
*/
function plainRowSourceUrls(row: StreamRow): string[] {
return findLinks(`${row.text}\n${row.summary ?? ""}`).map((hit) => hit.url);
}

/**
* Rewrite a plain row's text on its existing node. The node never changes
* shape, so a URL appearing or disappearing repaints in place instead of
* forcing a rebuild.
*/
function paintPlainRowNode(
node: TextRenderable,
row: StreamRow,
painted: PaintedStreamLine,
wrapWidth: number,
): void {
const lines = painted.content.split("\n");
if (!lines.some((line) => findLinks(line).length > 0)) {
node.content = painted.content;
node.fg = painted.fg;
// Route through the armer so a retext that drops the last URL disarms
// the handlers a previous arming installed (armLinkLine clears them).
armLinkLine(node, []);
return;
}
paintLinkLine(
node,
splitWrappedLinkSpans(
lines.map((text) => ({ text: text.trimEnd(), fg: painted.fg })),
wrapWidth,
plainRowSourceUrls(row),
),
);
}

/**
* A markdown row's body. Most rows have no settled heading yet (no heading at
* all, or the only one is still the open tail), and paint through a single
Expand All @@ -396,10 +469,10 @@ function createMarkdownBody(
const content = markdownContent(row);
const split = splitAtSettledHeading(content);
if (split === null) {
// Native incremental block stability: only the trailing block is unstable.
return new MarkdownRenderable(ctx, {
...markdownBodyOptions(gutter, width),
content,
// Native incremental block stability: only the trailing block is unstable.
streaming: row.streaming === true,
});
}
Expand Down Expand Up @@ -454,8 +527,9 @@ function createStyledLinesRowRenderable(

/**
* One painted body line. A line ending in an expand arrow is split so the
* arrow is its own renderable and can answer a click; every other line is a
* single text node, as before.
* arrow is its own renderable and can answer a click; a line holding URLs
* paints styled text and arms as a Ctrl+click target (see url-links.ts);
* every other line is a single text node, as before.
*/
function bodyLineNode(
ctx: CliRenderer,
Expand All @@ -464,17 +538,12 @@ function bodyLineNode(
): TextRenderable | BoxRenderable {
const split = onToggle === undefined ? null : splitTrailingArrow(line);
if (split === null || onToggle === undefined) {
return new TextRenderable(ctx, {
content: new StyledText(diffLineChunks(line)),
});
return buildLinkLine(ctx, splitLinkSpans(line));
}
const wrapper = new BoxRenderable(ctx, { flexDirection: "row", flexGrow: 1 });
wrapper.add(
new TextRenderable(ctx, {
content: new StyledText(diffLineChunks(split.body)),
flexShrink: 0,
}),
);
const body = buildLinkLine(ctx, splitLinkSpans(split.body));
body.flexShrink = 0;
wrapper.add(body);
wrapper.add(
new TextRenderable(ctx, {
content: new StyledText(diffLineChunks([split.arrow])),
Expand Down
Loading
Loading