Skip to content

fix(ui-tui): render agent URLs as visible, clickable text#694

Merged
agentforce314 merged 4 commits into
mainfrom
fix/tui-agent-urls-clickable
Jul 12, 2026
Merged

fix(ui-tui): render agent URLs as visible, clickable text#694
agentforce314 merged 4 commits into
mainfrom
fix/tui-agent-urls-clickable

Conversation

@agentforce314

@agentforce314 agentforce314 commented Jul 12, 2026

Copy link
Copy Markdown
Owner

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:

  • Bare URLs and autolinks render verbatim (accent + underline, still OSC 8-wrapped).
  • [label](url) renders as label (url) with the target visible; no duplication when the label already is the URL/address.
  • The remote page-title fetch is gone from the renderer — no more network request per rendered URL, and labels no longer mutate into page titles mid-conversation.

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.ts pin 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:

  • [AUTO-FIXED] (performance + maintainability, multi-specialist, 9/10) 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; normalizeExternalUrl retained. This also makes the CHANGELOG claim "the stealth title fetch is gone" literally true.
  • [AUTO-FIXED] (design, 7/10) labeled mailto links leaked the scheme into the visible suffix (Email me (mailto:…) instead of the bare address); the suffix now renders through defaultLinkLabel, matching how autolinked addresses display.
  • [AUTO-FIXED] (testing, 5/10) trailing-punctuation trimming truncated wikipedia-style …/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.
  • [AUTO-FIXED] (testing, 7/10) long-URL wrap test added — lines.join('') must reconstruct the URL exactly (no characters lost across hard wraps).
  • [AUTO-FIXED] (testing, 6/10) OSC 8 regression test added — raw render output must carry the exact click target (incl. balanced paren) as hyperlink metadata.
  • [AUTO-FIXED] (testing, 5/10) scheme-less-label dedup test added ([example.com/docs](https://example.com/docs) renders once).
  • [AUTO-FIXED] (maintainability, 4/10) rationale comment deduplicated between test file and component.
  • [DEFERRED] (design, 6/10) links inside markdown table cells still render label-only through 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 in markdown.tsx.
  • [ACCEPTED] (design, 4/10) very long URLs hard-wrap and can visually dominate link-dense paragraphs — the explicit tradeoff for copyability/clickability; revisit only if real transcripts prove noisy.

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:

  • [FIXED] trimBareUrl was 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.
  • [HARDENED] Unicode bidi-control codepoints (U+202A–202E, U+2066–2069) survive the C0 filter and could visually reorder a displayed domain on software-bidi terminals (Trojan-Source-style spoofing; click target stays true). Now stripped from the visible link text only — OSC 8 target and opener input untouched — with a regression test. The prior behavior (rendering remote-fetched page titles) had equal-or-greater exposure.
  • [HARDENED] the display sanitizer also drops C0/DEL controls locally. The vendored renderer already strips them at the cell-clustering layer, but the reviewer's point stands: the guarantee shouldn't depend on renderer internals or on paths that serialize text outside the cell buffer.
  • [PRE-EXISTING, unchanged] OSC 8 forwards whatever scheme the model wrote (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>.
  • [NOTED] the SSRF-guard tests were deleted along with the title-fetch machinery they guarded; if link-title fetching is ever reintroduced, its guards + regression corpus must come back with it.
  • [ACCEPTED] no length bound on verbatim URLs — deliberate (the wrap-integrity test pins full-URL survival).

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/main with 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-tui vitest: 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 clean origin/main checkout — pre-existing, unrelated to this diff
  • pytest tests/ (the CI merge gate): 8441 passed, 7 skipped, 0 failed (8m09s)
  • tsc --noEmit clean; eslint clean on all changed files
  • Live PTY verification of the built TUI (URL visible on screen + OSC 8-wrapped, trailing punctuation outside the link)

🤖 Generated with Claude Code

agentforce314 and others added 4 commits July 11, 2026 20:51
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>
@agentforce314 agentforce314 merged commit 3dc36c8 into main Jul 12, 2026
1 of 2 checks passed
@agentforce314 agentforce314 deleted the fix/tui-agent-urls-clickable branch July 12, 2026 03:58
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