fix(ui-tui): render agent URLs as visible, clickable text#694
Merged
Conversation
The markdown renderer replaced every URL's visible text with a remote-fetched page <title> (silently HTTP-GETting each URL the agent printed) or a slug-derived label, leaving the real URL only in OSC 8 metadata. Terminals without OSC 8 support strip that metadata, so the URL was invisible, unclickable, and uncopyable — and inline mode (the default) never captures the mouse, so terminal-native detection over the visible text is the only affordance that works everywhere. - Bare URLs and autolinks render verbatim (still OSC 8-wrapped). - [label](url) renders as `label (url)`; no duplication when the label already is the URL/address; mailto suffixes drop the scheme. - Bare-URL punctuation trimming is balanced-paren-aware so wikipedia-style /Foo_(bar) targets keep their closing paren. - Bidi control codepoints are stripped from the visible link text (Trojan-Source-style domain spoofing); the OSC 8 target and click opener receive the untouched URL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the renderer no longer replacing URLs by fetched page titles, the entire title-fetch subsystem (browser-UA HTTP fetch, HTML title parsing, SSRF guards, module-level caches — ~430 lines) was dead code kept green only by its own tests. Keep normalizeExternalUrl. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review caught trimBareUrl rescanning the whole string per trimmed character — O(n²) on model-controlled input, so a URL followed by tens of thousands of ')' would freeze the TUI synchronously in the unbounded non-streaming render path. Count the paren balance once and track it while trimming. Also widen the display-text sanitizer to drop C0/DEL controls locally (previously only bidi controls): the vendored renderer strips them downstream at the cell layer, but the guarantee shouldn't depend on renderer internals. OSC 8 targets and the click opener still receive the untouched URL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
URLs the agent prints are clickable again. The TUI markdown renderer replaced every visible URL with a remote-fetched page
<title>(silently HTTP-GETting each URL the agent printed) or a slug-derived label, leaving the real URL only in OSC 8 metadata. Terminals without OSC 8 support (e.g. Apple Terminal) strip that metadata, so the URL was invisible, unclickable, and uncopyable — and in the default inline mode the TUI never captures the mouse (the in-process click opener is alt-screen-gated), so terminal-native URL detection over the visible text is the only affordance that works everywhere. There was no visible URL for it to detect.Reproduced by driving the real TUI under a PTY: the agent's reply rendered as
You can find the documentation at Guide.— no URL anywhere on screen.Changes in
ui-tui/src/components/markdown.tsx:[label](url)renders aslabel (url)with the target visible; no duplication when the label already is the URL/address.After the fix, the same PTY drive renders
visit https://example.com/docs/guide.with the URL as plain visible text, still OSC 8-wrapped (Cmd+click in VS Code/iTerm2, plain click in fullscreen mode), trailing punctuation outside the link.Test Coverage
All changed code paths have direct unit coverage — three tests in
ui-tui/src/__tests__/markdown.test.tspin the new contract (bare URL verbatim;label (url); no URL duplication for self-labeled links and mailto autolinks), replacing the two tests that pinned the old label-hiding behavior. End-to-end verification via a live PTY drive of the built TUI (before/after captures of raw terminal bytes incl. OSC 8 sequences).Pre-Landing Review
Specialist dispatch (testing, maintainability, performance, design — 68-line diff) + checklist pass + Claude adversarial subagent (Codex CLI not installed). 10 findings, 0 critical:
externalLink.ts— the title-fetch machinery orphaned by this fix (~430 lines: browser-UA HTTP fetch, HTML title parsing, SSRF guards, module-level caches) was production-dead, kept green only by its own tests. Deleted;normalizeExternalUrlretained. This also makes the CHANGELOG claim "the stealth title fetch is gone" literally true.Email me (mailto:…)instead of the bare address); the suffix now renders throughdefaultLinkLabel, matching how autolinked addresses display.…/Foo_(bar)bare URLs' click targets (stripped the balancing paren → 404). Replaced with balanced-paren-aware trimming; prose punctuation (,.;:!?and unbalanced)) still stays outside the link.lines.join('')must reconstruct the URL exactly (no characters lost across hard wraps).[example.com/docs](https://example.com/docs)renders once).stripInlineMarkup(no visible URL, no OSC 8) — the same invisibility this PR fixes for paragraphs persists in the table block context. Pre-existing; fixing it changes table widths, so it needs its own decision. Sits next to the existing cell-markdown TODO inmarkdown.tsx.Adversarial review (Claude subagent; Codex CLI not installed): the load-bearing worry — terminal-escape injection through now-verbatim model-controlled URL text — is structurally impossible: the renderer strips every C0 control (incl. ESC/BEL) from text-node values at the cell-clustering pass on both inline and fullscreen paths, and ANSI-bearing assistant messages divert to the
<Ansi>sanitizer before reaching the markdown renderer at all. The click opener keeps its http(s)-only allowlist + no-shell spawn. Findings disposition:trimBareUrlwas O(n²) on model-controlled input: it rescanned the whole string per trimmed character, so a printed URL followed by tens of thousands of)would freeze the TUI synchronously in the unbounded non-streaming render path (reachable via prompt-injected tool output echoed into the transcript). Rewritten as a single O(n) pass (paren balance counted once, tracked while trimming), with a 50k-paren-flood regression test that blows the test timeout pre-fix.javascript:,file:) into the escape's URI field; terminals ignore non-standard schemes there and the in-process opener rejects non-http(s). This diff does not change what reaches<Link>.Verdict: merge — the only production-breaking risk it identified was accidental inclusion of unrelated concurrent edits from the shared working tree, which this PR avoids by being built in an isolated worktree from
origin/mainwith explicit-path staging.Design Review
Design specialist (TUI-scoped) ran as part of the specialist dispatch — see Pre-Landing Review.
Eval Results
No prompt-related files changed — evals skipped.
Plan Completion
No plan file detected.
TODOS
No TODO items completed in this PR.
Documentation
Documentation is current — no docs reference the removed title-fetch behavior.
Test plan
ui-tuivitest: 1411 passed / 4 skipped (after rebase onto feat(memory): port openclaude's /memory — TUI picker overlay, $EDITOR spawn, backend memory controls #693); the 7 failures (createGatewayEventHandler ×2, statusRule ×2, useConfigSync ×2, virtualHeights ×1) are byte-identical on a cleanorigin/maincheckout — pre-existing, unrelated to this diffpytest tests/(the CI merge gate): 8441 passed, 7 skipped, 0 failed (8m09s)tsc --noEmitclean;eslintclean on all changed files🤖 Generated with Claude Code