Skip to content

Open transcript URLs on modifier click - #964

Merged
TheGreatAxios merged 8 commits into
mainfrom
cl-7346-url-click-through
Sep 14, 2026
Merged

Open transcript URLs on modifier click#964
TheGreatAxios merged 8 commits into
mainfrom
cl-7346-url-click-through

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Holding the platform modifier over an http(s) URL in a transcript row underlines it, and releasing a press on the same URL opens it in the default browser
  • Ctrl+click now also opens http(s) links in assistant markdown rows through the renderer's link hit-testing; the press keeps bubbling so drag-select still works, and an armed-row open stops propagation so exactly one open lands per gesture
  • Markdown links open on click only — underline highlight stays limited to rows the app paints itself
  • Plain clicks, drag selection, mouse-capture-off behavior, and non-http(s) targets behave exactly as before

Verification

  • bun run typecheck, bun run build, and bun run check pass
  • Transcript tests cover scheme gating, hit-testing, hover highlight, open-on-release, markdown click-to-open (bare URLs and named labels), drag-away no-open, and the armed-row single-open guarantee
  • Modifier reporting depends on the terminal sending key-modifier mouse events and needs a real-terminal check

Related to CL-7346

Markdown prose stays terminal-owned: its renderers expose no text-leaf API to highlight or hit-test.
@linear-code

linear-code Bot commented Sep 13, 2026

Copy link
Copy Markdown

CL-7346

A retext that dropped the last URL left the old hit ranges armed, so Ctrl+clicking the old columns still opened. The armer now clears its handlers when no line holds a URL.
@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Audit: NEEDS-WORK (security — Windows opener command injection)

Finding (blocking): defaultUrlOpener (src/tui/url-links.ts) routes Windows opens through ["cmd", "/c", "start", "", url]. Bun.spawn with an argv array does not invoke a shell itself, but cmd.exe /c re-parses the assembled command line, so &, | and && inside a transcript URL act as command separators. Transcript URLs are attacker-influenceable (tool output, fetched pages, pasted logs), so Ctrl+clicking a crafted URL on Windows would execute arbitrary commands. Fix: never route through cmd /c — spawn rundll32 url.dll,FileProtocolHandler <url> as argv with no shell. I will push this fix to this branch.

Checked and kept (no change requested):

  • Scheme gate parses (new URL(url).protocol) rather than prefix-matching, so javascript: / file: / mailto: never open.
  • argv-spawn with no shell on the darwin/linux paths.
  • Press/release URL equality: a press that releases anywhere else stays a selection gesture.
  • Stale-handler disarm on retext, uppercase-scheme matching, button-0-only (right-click safe) gating.

Suggestion (non-blocking): a hover URL preview (status line or tooltip showing the target) would mitigate display-text-vs-target phishing; consider as a follow-up.

Verification note: the unit/headless suites pin the gating with a mocked opener, but a real-terminal pass is still needed to confirm the terminal delivers the held modifier on motion/press events and that the spawn resolves into a browser. Not run in this pass (no real-terminal mouse tests here).

cmd.exe re-parses the assembled command line, so &, | and && in an attacker-influenceable transcript URL would execute as command separators. Route win32 through rundll32 url.dll,FileProtocolHandler with the URL as a plain argv element instead; no shell is involved on any platform.
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.
@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Critic review — merge-ready

Holds the platform modifier over an http(s) URL in a transcript row to underline it; press-and-release on the same URL opens it in the default browser. All 9 CI checks pass (build, typecheck, eslint, prettier, static-analysis, both test suites).

Blocking

None.

Nits (non-blocking, file for later)

  • src/tui/url-links.ts (armMarkdownLinks): overwrites transcript.onMouseDown/Up/Out wholesale. No existing root handlers today (only leaf arrow toggle at src/tui/shell/transcript.ts:483), so nothing clobbers — but a future root handler will silently lose. Consider chaining instead of assigning.
  • isUrlOpenClick fires on Ctrl+button-0 on every platform including macOS, while docs/TUI.md frames Ctrl+click as Linux/Windows-only. Harmless (a useful fallback where OSC-8 is unsupported, e.g. Terminal.app), but docs and code tell slightly different stories.
  • Wrapped-URL reassembly (splitWrappedLinkSpans) is a documented approximation needing the URL to end exactly at the wrap edge; a hard split inside scheme/host without a known source URL stays two fragments. Acceptable, pinned by tests — just noting the residual gap.

Verified against the diff

  • Correctness: scheme gate parses via URL.protocol (isOpenableUrl), not prefix-match; right-click safe (button === 0); press/release same-URL equality keeps Ctrl+drag as selection; retext disarms stale handlers (armLinkLine clears on URL-free repaint); armed-row open stops propagation so the bubbling markdown handler cannot double-open (tested).
  • Tests are real: src/tui/url-click.test.ts (open/plain-click/drag-away, hover underline on/off, disarm on retext via both unit and row paths, wrapped URLs in thinking and bubble rows, markdown bare/named links, non-http markdown target, drag-away, single-open) plus tests/unit/tui/url-links.test.ts (gating, offsets, punctuation/paren trimming, wrap-chain geometry, opener argv incl. no cmd /c on Windows, throw-swallowing opener).
  • Hygiene: focused diff — new url-links.ts module, paint/arm swaps in transcript.ts/row-retext.ts, plainRowWrapWidth in stream.ts, one mouse flag in product-host.ts, docs in TUI.md, OpenTUI 0.5.10 to 0.5.11 for getLinkAt. No drive-bys. Motion-repaint storm guarded by url !== hover.
  • Known blind spot (owned in docs): real-terminal modifier reporting and OSC-8 behavior cannot be covered headless.

Verdict

Approve — no blocking findings; nits may land as follow-ups.

@TheGreatAxios
TheGreatAxios merged commit 653e4e9 into main Sep 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant