Skip to content

fix: OSC 8 hyperlink bare URLs instead of letting them wrap-split - #21

Merged
than merged 11 commits into
mainfrom
fix-15-url-wrap
Aug 7, 2026
Merged

fix: OSC 8 hyperlink bare URLs instead of letting them wrap-split#21
than merged 11 commits into
mainfrom
fix-15-url-wrap

Conversation

@than

@than than commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Overview

A bare URL is the board's own convention for something a person should click straight from the pane. That broke the moment a URL outgrew the render width — glamour's word-wrap force-split it mid-URL, leaving the terminal's link matcher with two dead fragments.

Approach

Two commits, because the first one wasn't the fix:

  1. Stash/restore — keep glamour from ever seeing a bare-URL line as a wrappable token, by swapping it for a short placeholder before rendering and restoring the real URL after. Fixed the split. Verified against renderMarkdown's return value only.
  2. OSC 8 hyperlink — turns out (1) wasn't the fix in the real app: bubbles' viewport.Model applies lipgloss MaxWidth to every frame, which silently truncated any line wider than the pane. Same bug, different failure mode, still broken — confirmed live with a screenshot.

Before building further on (2), checked the one thing a prior attempt at this (#18, closed unmerged after 4 review rounds) flagged as unverified and couldn't check from its environment: does bubbletea's renderer re-truncate through a non-OSC-aware truncator? Read x/ansi's truncate.go — escape bytes always pass through regardless of the visible-cell cutoff. Gate passes. OSC 8 survives the real pipeline.

Reimplemented as #18 was heading, using its four review rounds as the requirements list:

  • Each URL keeps its own index-keyed placeholder end to end — no global text search, so two URLs that elide to identical visible text can't cross-link (Keep wrapped URLs clickable (#15) #18's confirmed collision bug).
  • Fenced code blocks are skipped during stashing (line-by-line fence tracking) — a URL typed verbatim in a code block is never truncated or hyperlinked.
  • The elision budget is measured from the actual rendered line prefix at restore time, not guessed — fixes Keep wrapped URLs clickable (#15) #18's nested-bullet-indent bug structurally instead of special-casing it.
  • The OSC 8 target is percent-encoded for control/non-ASCII bytes instead of rejected — a pathological or non-ASCII URL still gets a complete, safe hyperlink.
  • visibleWidth switched from muesli/reflow (CSI-only) to charmbracelet/x/ansi.StringWidth (OSC-aware) — width/padding math and "never wider than the pane" hold for hyperlinked lines too, no exemption needed.
  • changedLines needed no change — its existing SGR-only strip never touched OSC 8, so the diff key already retains the link target.

Testing

  • New tests in render_test.go: intact single-line hyperlinks, collision-safety, fenced-block untouched, control-byte/non-ASCII encoding, nested-indent budget, diff detection on target-only changes, and TestHyperlinkSurvivesDownstreamTruncation — the exact gate Keep wrapped URLs clickable (#15) #18 couldn't check, pinned directly against x/ansi.Truncate.
  • Full suite + go vet clean.
  • Manually verified in Ghostty at ~40 columns: link is clickable and opens the right place.

Closes #15

🤖 Generated with Claude Code

than and others added 2 commits August 6, 2026 23:47
Glamour hard-splits a bare-URL line mid-word once the URL is longer
than the wrap width — the rendered Link/autolink ANSI run interacts
with reflow's word-wrap differently from plain text and forces a
break instead of pushing the whole word to the next line. In a normal
pane width, that's most URLs, every time.

renderMarkdown now stashes any line that's nothing but a bare URL
(the board convention) behind a short placeholder before handing raw
markdown to glamour, then restores the real URL afterward, styled to
match glamour's own Link style. The line is free to overflow the pane
width now — intentional, and the one exception TestNeverWiderThanWidth
allows — but the URL itself is never split, so terminal link detection
still picks up the whole thing.

TestBareURLIntact now renders at width 40 instead of 78, which is what
let this ship unnoticed — none of the fixture URLs exceeded 78 chars,
so the split path never fired.

Fixes #15.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit on this branch (stash/restore to keep glamour from
force-splitting a bare URL mid-word) fixed the split but not the real
problem: bubbles viewport applies lipgloss's MaxWidth to every frame,
which silently truncated any over-width line — losing the tail of the
URL outright, worse than the split it replaced.

Checked the actual gate before building further: bubbletea's flush()
and lipgloss's MaxWidth both truncate via charmbracelet/x/ansi, which
parses OSC sequences and always preserves escape bytes regardless of
where the visible-cell cutoff falls (confirmed by reading truncate.go).
So OSC 8 hyperlinks survive the real render pipeline intact — the one
question PR #18's last review round flagged as unverified and could
not check from that environment.

This reimplements #15 as PR #18 was heading, using its four rounds of
review as the spec:

- Each bare URL keeps its own index-keyed placeholder end to end, so
  two URLs that elide to identical visible text can never search-and
  cross-link (PR #18's confirmed collision bug) — there's nothing to
  search for.
- Fenced code blocks are skipped during stashing (line-by-line fence
  tracking), so a URL a user typed verbatim in a code block is never
  truncated or hyperlinked.
- The elision budget is measured from the actual rendered line prefix
  at restore time, not guessed — fixes PR #18's nested-bullet-indent
  bug structurally rather than special-casing it.
- The OSC 8 target is percent-encoded for control/non-ASCII bytes
  instead of rejected, so a pathological or non-ASCII URL still gets a
  complete, safe hyperlink instead of silently losing it.
- visibleWidth switched from muesli/reflow (CSI-only, no OSC 8
  awareness) to charmbracelet/x/ansi.StringWidth, so width/padding math
  and the 'never wider than the pane' invariant hold for hyperlinked
  lines too — no exemption needed.
- changedLines needed no change: its existing SGR-only stripANSI never
  touched OSC 8 to begin with, so the diff key already retains the link
  target — a target-only edit still flashes/marks correctly.

New tests in render_test.go cover each of the above, plus the one gate
PR #18 could never check: TestHyperlinkSurvivesDownstreamTruncation
pins x/ansi.Truncate against a real hyperlinked line.

Still needs a manual Ghostty eyeball at ~40 columns to confirm the
link is actually clickable — that's the one check no unit test here
can make and the one both this fix and PR #18 skipped before.

Refs #15, informed by #18.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

The core design is right. Index-keyed placeholders kill the collision class structurally, oscTarget encoding is correct and non-lossy (0x1b, BEL, DEL, non-ASCII all covered), the visibleWidth swap to xansi.StringWidth is the right call and pays off across tidy, applyLineBg, and the status bar for free, and TestHyperlinkSurvivesDownstreamTruncation pins exactly the assumption the whole approach rests on. Watcher, reload, and scroll-clamp paths are untouched — no regression there. Truecolor is preserved (termenv.TrueColor.Color(colorLink)), matching glamour's own Link style.

Four things, one of them real.

a. restoreBareURLs discards the rest of the line

style.go:263:

prefix := line[:idx]
...
lines[li] = prefix + hyperlink(url, styled)

Everything at line[idx+len(ph):] is dropped. That is safe for the board convention (- <url> as its own item — nothing follows the placeholder), which is why the suite is green. It is not safe when the URL is a soft-break line inside a multi-line paragraph:

See the docs at
https://example.test/very/long/url
for more information.

That is one paragraph. Glamour reflows soft breaks and word-wraps — the same reflow that force-split the URL in the first place — so the rendered line can be See the docs at ␟U0␟ for more, and for more is silently deleted. The elision budget has the same blind spot: it is width - visibleWidth(prefix) with nothing subtracted for what follows.

bareURLLine deliberately matches the un-bulleted indented-continuation form, so this input class is in scope by design. Fix:

rest := line[idx+len(ph):]
budget := width - visibleWidth(prefix) - visibleWidth(rest)
...
lines[li] = prefix + hyperlink(url, styled) + rest

I could not run a probe render in this environment — worth a 30-second check with a three-line paragraph before merging.

b. budget <= 0 breaks the never-wider-than-the-pane invariant

elideURL returns "…" (one cell) when budget <= 0, so the line comes out at visibleWidth(prefix) + 1 > width. Reachable at the width < 10 clamp floor with a nested bullet whose indent already fills the pane. Cheapest fix is to bail in restoreBareURLs when budget < 1 and leave prefix as-is; the URL disappearing beats the pane wrapping. TestNeverWiderThanWidth only exercises 40/60/78 against the fixture, so nothing catches this today.

c. CRLF input silently bypasses the fix

bareURLLine ends in ([ \t]*)$, and \r is in neither class — so on a CRLF board no bare URL is ever stashed and the original wrap-split bug is fully intact. tidy normalizes \r\n explicitly, so the codebase already treats CRLF as a supported input. One-character fix: ([ \t\r]*)$, or normalize at the top of stashBareURLs.

d. Indented code blocks aren't excluded, unlike fenced ones

The fence tracking is careful, but a 4-space indented code block matches bareURLLine (leading [ \t]*), gets stashed, and comes back as a styled, clickable hyperlink inside what is supposed to be verbatim text. Same decision as the fence case, opposite outcome. Minor, but it makes TestBareURLInFenceUntouched narrower than it reads.

Smaller notes

  • No fallback if a placeholder fails to survive glamour — the raw \x1f-delimited token lands on screen as-is. A residual-placeholder sweep in tidy would turn a garbled failure into an invisible one.
  • README drift: "Bare URLs stay on their own line, so Ghostty can detect them and make them clickable" no longer describes what happens. Worth stating the tradeoff explicitly, because it is a real one — on a terminal or multiplexer without OSC 8 support, a URL longer than the pane is now unrecoverable from the visible output, where before it was ugly but complete and selectable. Same applies to --static, whose doc comment advertises piping and CI.
  • Test gaps matching the above: URL with trailing text in the same paragraph, CRLF, indented code block, budget <= 0.

go.mod movement (x/ansi to direct, reflow to indirect) is correct.

…ted code

Four review findings on PR #21, one of them real:

a. restoreBareURLs discarded everything after the placeholder on its
   line. Safe for the board's own convention (a URL as its own list
   item), not safe for a bare-URL line that's a soft-wrapped
   continuation inside a multi-line paragraph — real content after the
   URL was silently deleted, and the elision budget didn't reserve
   room for it either. Now keeps and measures both the prefix and the
   rest of the line.

b. elideURL's budget<=0 fallback (a bare ellipsis) could still push a
   deeply-nested, tiny-pane line past the width — the one invariant
   every other line in this renderer holds. restoreBareURLs now drops
   the URL entirely rather than draw anything when there's no room.

c. bareURLLine's trailing-whitespace class didn't include \r, so a
   CRLF board silently kept the pre-fix wrap-split bug on every bare
   URL. tidy() already treats CRLF as supported input; the regex now
   does too.

d. A 4-space indented code block (CommonMark verbatim content, same
   as a fence) matched bareURLLine and got truncated/hyperlinked.
   Skipped now on the same signal as the indented-code heuristic
   allows: no list marker plus a 4+ space or tab indent. Trade-off
   noted in the comment — this can also false-skip a deeply-nested
   bullet continuation, which is the safer failure.

Plus the two smaller notes: a residual-placeholder sweep in
renderMarkdown so an unanticipated reflow edge case fails invisibly
rather than leaving raw \x1f bytes on screen, and README/--static doc
updates that describe what actually happens now (OSC 8 hyperlink,
possibly-elided display, full target) instead of the pre-OSC8 claim.

Five new tests, one per finding plus the doc-adjacent behavior. Full
suite + go vet clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid PR. The approach is right, the reasoning in the description matches what the code actually does, and the hard invariants survive. A few notes, none blocking.

Verified intact

  • Watcher — untouched. Still parent-directory watch plus 100ms debounce, still survives rename-swaps, deletes, recreates.
  • Scroll preservation — untouched. reload still captures YOffset before SetContent and restores after; nothing in the render path changed line counts in a way that bypasses that.
  • Never wider than the pane — holds structurally, not by luck. restoreBareURLs derives budget from visibleWidth(prefix) and visibleWidth(rest) measured on the real rendered line, and drops the URL rather than overflowing when budget < 1. Measuring the suffix too is what makes the soft-wrapped-paragraph case safe.
  • Compact spacing, no trailing padding, hex colorstidy and styleConfig unchanged.
  • Concurrency — no new surface. Render stays synchronous inside Update.
  • oscTarget percent-encoding everything below 0x20, DEL, and 0x80+ is the right call and easy to leave out. It also means the OSC target can never contain a literal ESC[0m, so applyLineBg's reset-reapply pass cannot corrupt a hyperlinked line. Worth noting that coupling somewhere, since it is load-bearing and non-obvious.
  • Index-keyed placeholders instead of a global text search is the correct structural fix for the collision bug, and pinning x/ansi.Truncate behavior in a test is a good way to hold the downstream assumption.
  • Switching visibleWidth to xansi.StringWidth is right for its own reason: it is the same measurement the downstream truncator uses, so the invariant and the thing enforcing it now agree.

Worth changing

a. Width-metric drift in elideURL

The pane-width invariant is now enforced with xansi.StringWidth, but elideURL builds its result with runewidth.StringWidth and per-rune runewidth.RuneWidth. Those are different measurements — x/ansi measures grapheme clusters, runewidth sums runes — and they disagree on VS16 presentation sequences (U+2764 U+FE0F: one cell by rune sum, two by cluster). A URL containing one lets elideURL return a display string that visibleWidth then measures as wider than the budget it was built against, which is exactly the over-width line the PR exists to prevent. Per-rune iteration can also cut inside a cluster.

One line fixes both, using the same package the invariant is measured with:

display := xansi.Truncate(url, budget, "…")

That is grapheme-aware, counts the tail toward the width, returns the URL unchanged when it fits — and it drops the mattn/go-runewidth direct dependency this PR promoted.

b. The 4-space skip reopens the original bug for list continuations

The len(indent) >= 4 guard treats a 4-space line as an indented code block. Under CommonMark that is only true outside a list; inside one it is a lazy continuation of the item paragraph. So this shape:

- Parent
  - Child item
    https://example.test/some-long-url

falls through to glamour and gets the pre-fix wrap-split. The comment acknowledges the tradeoff and "fail safe rather than hyperlink real code" is the right default, so this is fine to ship — but it is a real board shape, not a corner case, and the indented-code-block test only covers the non-list form. A cheap tightening: only apply the guard when the previous non-blank line is blank, which is the actual CommonMark precondition for an indented code block.

Minor

  • fenceLine toggling on any 3+ run of the same char means a ~~~ line inside a ~~~~ block closes the fence early, and URLs after it in the same block get hyperlinked. Obscure, but the length check is a one-liner if you want it.
  • README recommends tmux, and tmux only gained OSC 8 support in 3.4. On older tmux a long URL now renders as elided plain text with no way to recover the full address — previously it was split across two lines but fully copyable. The README wording covers this honestly; flagging it only so the tradeoff is a decision rather than a surprise. Same for copy-paste out of the pane in any terminal.
  • --static piped to a file or grep now loses the full URL. Documented in the runStatic comment, and consistent with the existing decision to force truecolor when piped, so I would leave it — just noting it is now two behaviors that assume a terminal on the other end.
  • residualPlaceholder.ReplaceAllString runs on every render even when no URL was stashed. Guarding on len(urls) > 0 costs nothing and makes the defensive intent clearer.
  • TestNeverWiderThanWidth still only checks widths 40, 60, 78. Since the new code path is width-sensitive in a way the old one was not, a sweep over 10 through 100 on the fixture would be cheap insurance.

Test coverage for the new logic is genuinely good — the collision, nested-indent, zero-budget, CRLF, and downstream-truncation cases are the ones that matter, and testing restoreBareURLs directly for the zero-budget boundary rather than trying to coax glamour into producing it is the right instinct.

Second review round on PR #21 passed with no blocking findings, but
took three of them anyway — one real correctness gap, one real board
shape, one free.

a. elideURL measured with go-runewidth (rune-sum) but visibleWidth
   enforces the pane-width invariant with xansi.StringWidth
   (grapheme-cluster-aware). They disagree on presentation-sequence
   emoji (VS16), so a URL containing one could produce a display
   string that measures over budget by the metric that actually
   matters — reopening the exact overflow this PR exists to prevent.
   Replaced with xansi.Truncate, the same package/metric the invariant
   already uses; drops the go-runewidth dependency entirely.

b. The indented-code-block guard (4-space indent, no list marker) is
   only correct under CommonMark's actual precondition: a blank line
   immediately before it. Without that check it also caught a real
   board shape — a URL as its own continuation line two list levels
   deep, which reaches the same 4-space indent but is a lazy list
   continuation, not code. stashBareURLs now tracks the previous
   line's blankness and only applies the guard when it holds.

c. fenceLine's close-toggle matched on character alone, so a ~~~
   line inside a ~~~~-opened block closed the fence early per
   CommonMark's own rule (closing run must be >= opening run length).
   Tracks the opening run length now.

Plus: residualPlaceholder's regexp scan now only runs when a URL was
actually stashed, and TestNeverWiderThanWidth's width sweep is scoped
to hyperlinked lines specifically — an unscoped 10-100 sweep surfaced
a genuine but unrelated pre-existing glamour off-by-one on plain,
non-URL text at certain odd widths (confirmed pre-existing: both
xansi and reflow agree on the over-width measurement, so it isn't a
metric disagreement introduced here) — out of scope for #15/#21.

Three new tests: nested-list-continuation-not-mistaken-for-code (the
regression b fixes), plus the hyperlink-scoped width sweep across
10-100. Full suite + go vet clean; go.mod no longer carries
go-runewidth as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Verdict: solid. Right approach, the hard invariants hold, and the test suite is unusually good for a change of this shape — TestHyperlinkSurvivesDownstreamTruncation and TestHyperlinkedLineNeverWiderThanWidth pin exactly the two things that sank the previous attempt. go build ./..., go vet ./..., go test ./... all clean here.

What I verified

  • Width invariant holds, including the hard case. The budget is measured from the actual rendered prefix and suffix, and re-measured per URL. So when two stashed URLs reflow onto one physical line, the second one's prefix already contains the first one's expanded hyperlink, and the total still sums to ≤ width. budget < 1 drops the URL instead of overflowing. That is the correct call — an over-width line is the one thing this renderer cannot do.
  • No cross-linking. Index-keyed placeholders delimited by \x1f on both ends means \x1fU1\x1f cannot prefix-match inside \x1fU11\x1f. The collision bug from the earlier attempt is structurally gone, not patched.
  • The visibleWidth swap is load-bearing, not incidental. applyLineBg's pad math and tidy's blank-line test both call it; with the reflow counter they would have counted the OSC 8 target as visible text and mispadded every hyperlinked line. Good catch to do this rather than exempt the URL path.
  • No regressions in the areas I was asked to guard: watcher, reload, and scroll-preservation code is untouched; colors stay truecolor (termenv.TrueColor.Color(colorLink)); compact-spacing path unchanged; oscTarget percent-encodes control bytes without touching %, so an already-encoded URL is not double-encoded.

Worth addressing

a. Elide from the middle, not the tail. xansi.Truncate(url, budget, "…") keeps the head, so at a narrow pane the visible remnant is https://gi… — no information at all. Middle-elision (https://github.com/…/pull/21) stays identifiable and hand-typeable at every width. This matters more than it looks, because the visible text is now the only thing a terminal without OSC 8 gives the user, and the README recommends tmux for the split-pane setup — tmux's OSC 8 passthrough is version-dependent. Before this PR those users got a mangled-but-complete URL; after it they get a truncated one with no way to recover the tail. The change is contained entirely in restoreBareURLs and improves the OSC 8 case too.

b. Ordered-list and blockquoted URLs still hit the original bug. bareURLLine's marker group is (?:[-*+]\s+)?, so 1. https://… never matches, is never stashed, and glamour still hard-splits it mid-URL. Same for > https://…. An ordered list is plausible on a board. Widening group 2 to (?:(?:[-*+]|\d+[.)])\s+)? is a one-line fix and everything downstream already handles it.

c. runStatic already has the signal to fix the caveat you documented. term.GetSize returning an error is the non-TTY check. Rather than documenting that grep/CI readers lose the URL, thread a bool into renderMarkdown and skip the stash in the non-TTY branch — piped output then keeps the full URL as plain text. Two lines, and it removes the caveat instead of explaining it.

Notes, not blockers

  • visibleWidth's swap changes width semantics for every caller, not just hyperlinked lines: go-runewidth per-rune vs. grapheme-cluster width. They disagree on ZWJ sequences and VS16 (❤️), and glamour's internal wrapper still uses reflow — so two metrics now coexist in one pipeline. The board's own markers (🧠🚧🚘✅📦) are single-codepoint and agree, and matching x/ansi is what bubbletea and lipgloss do downstream, so I think this is the right direction. Just flagging that the blast radius is wider than the URL path, and the non-hyperlink width coverage is still only 40/60/78 while the new sweep deliberately skips those lines.
  • The indented-code-block guard classifies a loose list item's second paragraph (blank line, then a 4-space-indented URL under a bullet) as code, so that case keeps the old split. Narrow enough not to hold the PR for; worth a line at the guard if that is the intended tradeoff.
  • If glamour ever splits a placeholder across a wrap, residualPlaceholder will not match the halves and raw \x1f bytes reach the screen. The placeholder is short enough that this should not happen at the width-10 floor, so the current defensive sweep is proportionate — just not airtight, contrary to what the comment implies.

Third review round: 'solid' verdict, no blocking findings, three
'worth addressing' items. Took two, deliberately skipped one.

b. bareURLLine's marker group only matched '-'/'*'/'+', so an
   ordered-list URL ('1. https://…') was never stashed and still hit
   the original wrap-split bug. Widened to accept '\d+[.)]' too — a
   real board shape the fix was otherwise silently missing.

c. runStatic's non-TTY fallback (piped, grep, CI) documented the
   caveat that a long URL's full address only lives in an OSC 8 target
   the reader can't see. Fixed it instead: renderMarkdown takes a
   linkify bool now, false for the non-TTY path, which skips
   stashBareURLs entirely — a bare URL renders as plain, complete text
   there. (This doesn't rescue a URL longer than the fallback width
   from the pre-#15 wrap-split; it only stops hiding an
   already-short one behind an escape sequence grep can't read. That's
   the actual common case at width 80, and documented as such.)

Skipped: middle-elision instead of tail-elision. Real UX improvement
for terminals without OSC 8 support, but no invariant violation
(target is always complete either way) and meaningfully more surface
for a fourth review round on a diminishing-returns question. Left as
a follow-up rather than chased further.

Two new tests: ordered-list stashing, and linkify=false actually
skipping the OSC 8 path (at the width where it matters — a narrower
first attempt at the test caught the pre-existing wrap-split instead,
which is expected and documented as out of scope for linkify=false).

Full suite + go vet clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

The core approach is right, and the parts that mattered most are handled well.

What holds up:

  • Switching visibleWidth to x/ansi.StringWidth was necessary, not cosmetic. applyLineBg pads to width - visibleWidth(ln) and tidy uses it for blank detection — reflow's CSI-only counter would have measured the OSC 8 target as visible text and blown the padding math on exactly the lines this PR adds. Good that it was fixed at the helper rather than exempted.
  • Measuring the elision budget from the actual rendered prefix and suffix (width - visibleWidth(prefix) - visibleWidth(rest)) fixes PR 18's nested-indent bug structurally instead of special-casing it. Index-keyed placeholders kill the cross-link collision by construction.
  • budget < 1 dropping the URL rather than drawing it is the correct call — the never-wider-than-the-pane invariant wins over showing something.
  • changedLines genuinely needed no change: ansiRE is ESC [ ... m, so the OSC 8 target stays in the diff key. Nice that it is pinned by a test rather than asserted in prose.
  • Watcher, reload, scroll clamp, and the p.Send path are untouched — no new concurrency surface, nothing to regress there. go.mod direct/indirect reclassification is correct.
  • TestHyperlinkSurvivesDownstreamTruncation is the right gate to pin, and pinning it against x/ansi.Truncate directly is better than asserting it in a comment.

The display text is not sanitized the way the target is.

style.go:275 builds the visible text straight from the raw URL:

display := xansi.Truncate(url, budget, "…")
styled := termenv.String(display)...
lines[li] = prefix + hyperlink(url, styled) + rest

oscTarget carefully percent-encodes control bytes for the target — but display gets no such treatment, and neither xansi.Truncate nor termenv.String strips escapes (Truncate passing escapes through unconditionally is the very premise the PR verified). Critically, the URL is stashed before glamour runs, so glamour never sees these bytes and never neutralizes them; they go from the file to the terminal verbatim.

So a board line whose URL contains a literal ESC byte — say - https://x/<ESC>]8;;https://evil<BEL>pwned, no spaces so \S+ swallows it whole — emits a nested OSC 8 inside the outer one, and the visible text points somewhere the board never named. A <ESC>[2J does worse. Note the pane-width invariant does not catch this: visibleWidth scores injected escapes as zero cells, so the line measures fine while the screen does not.

TestBareURLUnsafeBytesEncoded only asserts on m[1]; m[2] is unchecked, which is why this slipped through.

Fix is small — sanitize before truncating, and assert on the display group too:

display := xansi.Truncate(stripControlBytes(url), budget, "…")

Given board files are written by an agent that routinely pastes tool output, this is worth closing.


Smaller things:

  • Residual sweep cannot catch a split placeholder. residualPlaceholder is \x1fU\d+\x1f. If glamour ever force-splits the placeholder across two lines — reachable only at the width-10 floor with a deep indent, where limit - indent drops under the placeholder's ~2 cells — then neither strings.Index(line, ph) nor the sweep matches, and raw \x1f bytes reach the pane. That is precisely the failure the sweep exists to prevent. Adding out = strings.ReplaceAll(out, "\x1f", "") after the token sweep closes it for free.

  • Mid-paragraph URLs elide much harder than own-paragraph ones. Because the placeholder is ~2 cells and the URL is not, glamour packs neighbouring words onto the same physical line, and the restore budget collapses. TestBareURLKeepsTrailingContent's own shape at width 40 leaves roughly 15 cells for a 42-char URL. Clickable in Ghostty, fine; but in a terminal without OSC 8 that URL is now unreadable and uncopyable, where pre-PR it was at least fully present across two lines. The README covers the general tradeoff, so this is a judgment call, not a blocker — just flagging that it bites the reflowed case far harder than the on-its-own-paragraph case the convention actually describes.

  • Loose-list continuation reads as indented code. A blank line before a 4-space-indented continuation inside a nested list (- Parent / - Child / blank / https://…) satisfies marker == "" && len(indent) >= 4 && wasPrevBlank, so it is skipped and keeps the old wrap-split. Conservative failure, and the sibling case is tested — worth a line in the comment so the next reader knows it is a known boundary rather than an oversight.

  • bareURLLine's doc comment cites CLAUDE.md for the bare-URL convention; there is no CLAUDE.md in the repo. The README rendering-style section is the live reference.

Fix the display-text sanitization; the rest is your call.

Fourth review round found a real vulnerability, not a style nit.

The stashed URL is never seen by glamour — it's swapped for a
placeholder before rendering, so glamour never gets a chance to
neutralize anything embedded in it. oscTarget percent-encodes control
bytes for the OSC 8 target, but the display text was built straight
from the raw URL: xansi.Truncate passes escape bytes through
unconditionally (the same property this PR relies on for the
hyperlink to survive downstream truncation), and termenv only styles
text, it doesn't sanitize it.

So a board line with a URL containing a literal ESC byte — plausible,
since these files are agent-written from pasted tool output, no
special crafting needed — reached the terminal verbatim. A second,
attacker-chosen OSC 8 open nested inside the display text points the
visible link somewhere the board never named; an arbitrary control
sequence does worse. The pane-width invariant didn't catch it either:
visibleWidth scores injected escapes as zero cells, so the line
measures fine while the screen doesn't reflect what's in the file.

Added stripControlBytes and applied it to the display text before
truncating. TestBareURLUnsafeBytesEncoded now asserts on the display
group, not just the target (that gap is exactly how this slipped
through the first pass); TestBareURLDisplayTextNoEscapeInjection pins
the nested-OSC-8 case directly.

Two more from the same round, both cheap:

- residualPlaceholder's paired-delimiter regex can't match a
  placeholder that got split across a wrap (reachable only at the
  width-10 floor with a deep indent). Added a bare \x1f sweep after it
  — a lone \x1f is unambiguously our own byte, safe to drop outright.
- Fixed a doc-comment citation: CLAUDE.md is excluded via
  .git/info/exclude, not part of the repo from a fresh clone's
  perspective. Points at README.md's rendering-style section instead,
  plus one line on the loose-list-continuation boundary so it reads
  as a known tradeoff rather than an oversight.

Full suite + go vet clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Reviewed the full diff against style.go, diff.go, ui.go, and main.go. Built, vetted, and ran the suite locally — all clean.

The core approach is right, and the hard invariants hold. Stashing to an index-keyed placeholder is the correct shape: it structurally rules out the cross-linking collision, and measuring the elision budget from the actual rendered prefix/suffix is a real fix rather than a special-case patch. Two calls in particular are worth naming as correct:

  • Swapping visibleWidth to x/ansi.StringWidth is load-bearing, not cosmetic. applyLineBg (diff.go:183) pads to width - visibleWidth(ln); with the old CSI-only counter an OSC 8 line would have measured the link target as visible cells and under-padded, and composeMarked would have mismeasured too. Getting the metric OSC-aware before emitting OSC is the right order of operations.
  • Leaving changedLines' SGR-only strip alone so the diff key retains the target. Two URLs that elide to identical visible text must still diff — good that it's pinned by a test.

Watcher, reload, and scroll paths are untouched, and reload/rerenderCollapse still bracket SetContent with the YOffset save/restore. No concurrency surface changed. Nothing to flag there.

Three things:

a. The link styling is the only color path that depends on termenv's auto-detected profile

style.go:306:

styled := termenv.String(display).Foreground(termenv.TrueColor.Color(colorLink)).Underline().String()

termenv.String binds to termenv's package-global default Output, whose profile is auto-detected. Everywhere else this codebase deliberately refuses that: glamour.WithColorProfile(termenv.TrueColor) at style.go:340 ("Force truecolor so hex colors survive; piped/degraded profiles were how glow washed out"), and diff.go writes raw 38;2;r;g;b by hand. When termenv resolves the default output to Ascii, Style.Styled short-circuits and returns the bare string — so under a degraded profile the link text renders unstyled while every other color on screen stays truecolor.

It also means the styled path is never exercised by the tests: they run with stdout not a terminal, so "teal, underlined" is asserted nowhere. Matching diff.go's existing idiom fixes both:

r, g, b := hexToRGB(colorLink)
styled := fmt.Sprintf("\x1b[4;38;2;%d;%d;%dm%s\x1b[0m", r, g, b, display)

b. The residual-placeholder fallback is garbled, not invisible

style.go:351-361 says "better an invisible failure than a garbled one," but the second sweep doesn't deliver that. If glamour ever hard-breaks a placeholder across a wrap, residualPlaceholder can't match the halves, and strings.ReplaceAll(out, "\x1f", "") then strips only the delimiters — leaving a literal U and 0 on screen as visible text.

Narrow to reach (the placeholder is a single 2-cell word, so it needs a deep indent at a near-floor width), so this is a nit rather than a blocker. But the fallback should strip the token's letters and digits too — a pattern like \x1fU?[0-9]* / U?[0-9]*\x1f in place of the bare \x1f pass — otherwise the comment promises something the code doesn't do.

c. A URL reflowed into a paragraph can be elided down to nothing legible

budget = width - visibleWidth(prefix) - visibleWidth(rest) is correct for the board convention (URL as its own bullet: budget is width - 2, and testdata/REVIEW.md confirms that's the real shape). But when the bare-URL source line is a soft-wrap continuation inside a paragraph, the substitution changes the reflow itself: glamour now wraps around a 2-cell placeholder instead of the full URL, so it packs more prose onto that physical line, and rest eats most of the budget. The URL can land at a couple of cells — h… — where pre-fix it was fully readable, just split.

That's the exact fixture in TestBareURLKeepsTrailingContent, and the test only asserts the trailing prose survives and the width invariant holds — not that the URL is still legible. I'd either assert a floor on the visible display width there, or skip the stash when the URL isn't alone on its rendered line, so a paragraph-embedded URL keeps its old full-text behavior. (I derived the budget arithmetic by reading restoreBareURLs; I couldn't add a probe test in this environment to print the rendered line, so treat the exact cell count as illustrative.)

Noted, not asking for changes

  • Blockquoted bare URLs (> https://…) don't match bareURLLine, so they keep the original wrap-split. Outside the board convention, but the PR says "Closes Viewer: wrapped URLs break clickable links #15" — worth knowing the gap exists.
  • On a terminal without OSC 8, a long URL's full text is no longer recoverable from the screen (elided plain text, not a two-line split). A real tradeoff, but a deliberate one and the README states it plainly, so it's the author's call.
  • TestHyperlinkSurvivesDownstreamTruncation pinning the x/ansi.Truncate assumption directly is the right instinct — that's the load-bearing premise of the whole approach, and it now fails loudly if it ever regresses.

Solid work overall. Item (a) is the only one I'd want fixed before merge; (b) and (c) are small.

Fifth review round: one real thing (explicitly called out as the one
they'd want fixed before merge), one nit, one noted-not-asked-for.

a. The hyperlink's display text was styled via termenv.String(...),
   which binds to termenv's auto-detected package-global Output
   profile — every other color path in this codebase deliberately
   overrides that (glamour.WithColorProfile(termenv.TrueColor) here,
   diff.go's hand-written 38;2;r;g;b). Under a degraded profile,
   termenv.String silently drops styling entirely, so the link text
   would render unstyled while everything else on screen stayed
   truecolor — and no test caught it, because every test here runs
   with stdout not a terminal, the exact condition that triggers it.
   Switched to the same raw-ANSI idiom diff.go already uses via
   hexToRGB. New test pins the exact SGR sequence directly.

b. Tried widening the residual-placeholder fallback regex to catch a
   placeholder split across a wrap — caught it myself before it went
   out:  has every anchor optional, so it would have
   matched a bare capital U anywhere in real board text (URL,
   Update) and silently eaten it. Reverted to the original
   well-formed-token-only regex; the comment now says plainly that the
   split case leaves a cosmetic U0 rather than overclaiming an
   invisible failure the code doesn't deliver — narrowing further
   isn't worth the risk of corrupting real content over an edge this
   narrow (needs a placeholder split at the width-10 floor).

c. Reflowed-paragraph URLs can elide down to just a couple of cells
   when 'rest' (prose after the URL on the same physical line) eats
   most of the budget — already the accepted, README-documented
   tradeoff of this approach, and the reviewer flagged genuine
   uncertainty about the exact severity ('I couldn't add a probe test
   in this environment... treat the exact cell count as illustrative').
   Not changing behavior on an unverified claim during an
   already-thorough fifth round; logging as a board follow-up instead
   of a fix.

Full suite + go vet clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid PR overall. The core design is right: index-keyed placeholders (no global text search) structurally kills the collision bug from the earlier attempt, measuring the elision budget from the actual rendered prefix/suffix beats guessing, and stripControlBytes + oscTarget is a genuinely careful pair — percent-encode where the bytes must stay meaningful, delete where they only need to be inert. Swapping visibleWidth from muesli/reflow to x/ansi.StringWidth is the correct call, not a drive-by: reflow's CSI-only scanner would have counted the OSC 8 target as visible text and quietly broken the pane-width invariant. Raw 38;2;r;g;b instead of termenv.String matches what diff.go already does and for the same reason. changedLines retaining the OSC 8 target in the diff key is the right answer, and it's pinned by a test.

Watcher, reload, and scroll-restore paths are untouched — renderMarkdown's new third arg is the only edit in ui.go, and the YOffset save / SetContent / SetYOffset sequence is unchanged in all three call sites. No new goroutines, no new shared state. Nothing to flag there.

Two things worth fixing before merge, then some nits.

a) Multi-line indented code blocks are only protected on their first line

stashBareURLs guards indented code with a per-line wasPrevBlank check (style.go:203), but an indented code block is a block — once it opens, every subsequent indented line belongs to it, blank-line-preceded or not. So:

Run these:

    https://example.test/first
    https://example.test/second

first has wasPrevBlank == true and is correctly skipped. second has wasPrevBlank == false, falls straight through the guard, gets stashed, and comes back as an elided OSC 8 hyperlink — inside verbatim content, truncated with an ellipsis. Two URLs in the same code block render differently from each other. TestBareURLInIndentedCodeBlockUntouched only covers the single-line case, so it does not catch this.

The fix is symmetric with what the fence tracker already does — carry the state instead of re-deriving it per line:

inIndentedCode := false
// ...
blank := strings.TrimSpace(line) == ""
if inIndentedCode {
    if !blank && !hasIndent4OrTab(line) {
        inIndentedCode = false
    }
} else if wasPrevBlank && !blank && hasIndent4OrTab(line) && noListMarker {
    inIndentedCode = true
}
if inIndentedCode { continue }

(Blank lines do not close an indented block — only a non-blank line indented under 4.) That also makes the existing wasPrevBlank guard redundant, so it collapses rather than adds.

b) Two bare URLs on the same reflowed line starve the second one

restoreBareURLs iterates URLs in order and computes each budget from the line's current state — correct per-URL, but order-dependent when two placeholders share a physical line. Glamour joins soft breaks into one paragraph (which is exactly what TestBareURLKeepsTrailingContent relies on), so:

Docs:
https://a.example.test/some/long/path
https://b.example.test/some/long/path

collapses to one rendered line holding both U0 and U1 placeholders — each about 2 visible cells, so glamour has no reason to wrap. URL 0 then expands to fill nearly the whole line, and by the time URL 1 is processed its budget is whatever is left: a cell or three, i.e. h… or a bare ellipsis.

The width invariant holds and both links are still clickable, so this is not a wrap-split regression. But a 1-cell link is effectively invisible, and the failure is silent. The budget < 1 rule already establishes "drop rather than render something broken" as the policy here — extending it to a small floor (drop, or do not elide below roughly 8 cells) would be consistent, or split the line's remaining width evenly across the placeholders on it. Either way this needs a test; nothing currently pins the multi-placeholder-per-line case at all.

c) Please confirm the width-15 overflow predates this PR

TestHyperlinkedLineNeverWiderThanWidth is scoped to hyperlink-bearing lines, and the comment justifies that by citing a plain blockquote line measuring one cell over at width 15. That may well be pre-existing — but this PR is also what changed the measuring function, from reflow's per-rune PrintableRuneWidth to x/ansi.StringWidth's grapheme-cluster width. Those disagree on exactly the content this fixture is full of (emoji, VS16, combining marks). Worth running the same sweep against main's reflow-based visibleWidth to establish which it is, and filing an issue either way — "never wider than the pane" is the invariant the tool exists for, and a scoped test is the right call for this PR but should not be where the finding stops.

Nits

  • main.go:123 infers TTY-ness from term.GetSize returning no error. term.IsTerminal(int(os.Stdout.Fd())) is in the same already-imported package and says what it means; the width fallback can stay keyed off GetSize independently. The comment currently has to explain the conflation, which is a sign the code should just not conflate them.
  • hexToRGB(colorLink) is called inside the per-URL loop in restoreBareURLs (style.go:315). Hoist it — loop-invariant.
  • restoreBareURLs silently does nothing for a URL whose placeholder glamour dropped entirely (the strings.Index miss just falls off the end of the inner loop). The residual sweep cannot help there — there is nothing left to sweep. Not worth guarding against, but the doc comment implies the placeholder is always found, and it is the one path where a URL vanishes without a trace.

Comment density is heavy in places, and a couple of the longest comments are load-bearing for logic that should not need them — the wasPrevBlank guard in (a) and the TTY inference above are both cases where restructuring the code removes the need to explain it. Not blocking, just worth watching.

Sixth review round found one real correctness bug.

a. stashBareURLs guarded indented code blocks with a per-line
   wasPrevBlank check, but an indented code block is a block: once a
   blank line plus a 4-space indented, unmarked line opens one, every
   subsequent indented line belongs to it whether or not that later
   line's own predecessor was blank. Two URLs in the same code block
   (second one immediately following the first, no blank line between
   them) rendered differently — the first correctly left alone, the
   second incorrectly stashed and hyperlinked. Fixed by tracking
   inIndentedCode as running state across every line, symmetric with
   the fence tracking already next to it. New test covers the
   multi-URL-same-block case the old single-line test couldn't catch.

Two free nits from the same round:

- main.go: swapped the term.GetSize-error-implies-non-TTY inference
  for term.IsTerminal, which says what it means directly instead of
  needing a comment to explain the conflation. The width fallback
  stays keyed off GetSize independently, since that's a separate
  question (what width to use) from TTY-ness (whether to linkify).
- hexToRGB(colorLink) hoisted out of restoreBareURLs' per-URL loop —
  loop-invariant.

Deliberately not changed, per explicit steer to stop an already
six-round loop on non-invariant findings:

- Two URLs sharing one reflowed line can starve the second one's
  elision budget. Width invariant holds, both links stay clickable
  (reviewer's own words) — a UX rough edge, not a correctness bug.
  Logging as a board follow-up.
- The width-15 plain-text overflow flagged again this round was
  already confirmed pre-existing and unrelated to this PR's metric
  swap in an earlier round of this same review cycle: both x/ansi and
  the old reflow counter agree exactly on the over-width measurement
  for the failing line, so it isn't a disagreement this PR introduced.

Full suite plus go vet clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid PR, and the right call on the approach. The stash/restore + OSC 8 combination is the only way to keep a long URL clickable and keep the never-wider-than-the-pane invariant, and the reasoning about x/ansi passing escape bytes through truncation is correct — pinning that assumption in a test is exactly right, since the whole design rests on it.

Verified locally on the branch: go build, go vet, and the full suite pass. Specifically checked and happy with:

  • Width invariant holds. The elision budget is measured from the real rendered prefix/suffix, and visibleWidth moving to x/ansi.StringWidth means the budget and the enforcement use the same OSC-aware metric. The 10..100 sweep over hyperlinked lines is the right shape of test. Bonus: applyLineBg's pad math in diff.go gets more correct from the same swap — muesli/reflow was counting the link target as visible cells.
  • Colors. Hand-written 38;2;r;g;b rather than termenv.String is correct here, for the reason the comment gives — the codebase forces TrueColor everywhere else and termenv.String would silently degrade under a non-TTY profile.
  • Diff marking. changedLines strips SGR only, so the OSC 8 target stays in the LCS key and a target-only change is still detected. Good catch that this needed no change, and better that there is a test pinning it.
  • Watcher, scroll, concurrency. Untouched — watcher.go unchanged, and the ui.go diff is only the three call-site updates. Reload still does the YOffset save/restore around SetContent. No new shared state, nothing crossing the p.Send boundary.
  • Compact spacing / trailing padding. tidy still runs last, after restore.
  • Escape injection. Stripping control bytes from the display text is a real hazard closed, not a theoretical one, since glamour never sees the stashed URL. Right to handle both halves differently — percent-encode the target, strip the display.

One real bug: the budget math is wrong when two placeholders share a rendered line

restoreBareURLs computes budget as width - visibleWidth(prefix) - visibleWidth(rest), but when it processes URL i, rest still contains the unexpanded placeholders for URLs i+1..n. A placeholder measures ~2-3 cells; the hyperlink it becomes measures up to the full pane. So the first URL on the line is handed a budget that assumes the later ones need almost no room, and it takes nearly the whole width. The later ones then compute their own (correct) budget against an already-full line and come out at 1-2 cells — h… — or hit budget < 1 and get dropped outright, link and all.

This is reachable with a shape the board convention actually produces: two bare URLs as consecutive continuation lines of the same list item or paragraph, e.g.

- Working on it
  https://github.com/example/app/pull/411
  https://github.com/example/app/pull/412

Those are one paragraph, so glamour reflows both placeholders onto a single rendered line — the same reflow that TestBareURLKeepsTrailingContent already demonstrates for trailing prose. The existing collision test does not cover this because it uses two separate list items, which land on separate lines.

The pane-width invariant itself survives (each later URL measures against the real expanded prefix, and budget < 1 drops rather than overflows), so this is not the wrap-split bug coming back — but the second URL is effectively unusable or invisible, which is worse than the pre-PR wrap-split for that line.

Fix: make the unit of work the rendered line rather than the individual URL. Find every placeholder on the line, compute the available width once as width minus the visible width of the line with all placeholders removed, then divide that across the placeholders on the line before truncating each. That also removes the current O(len(urls) x len(lines)) rescan-from-line-zero, since one pass over the lines handles everything.

Worth a test with two bare URLs in one paragraph asserting both targets are intact and both display texts are non-trivial.

Minor

  • Fence state can be opened from inside an indented code block. The fenceLine check runs before the indented-code tracking and ignores inIndentedCode, so a stray ``` or ~~~ line inside a 4-space-indented block (or a line that merely starts with ~~~) flips fenceChar on. If it never finds a matching close, every bare URL for the rest of the document silently reverts to the old wrap-split. Cheap guard: skip the fence check while inIndentedCode is set, which means hoisting the indented-code tracking above it.
  • Trailing whitespace is double-counted. The stash preserves the source line's trailing whitespace into rest, so the budget is reduced by spaces that tidy strips a moment later. Harmless direction, just a cell or two of unused budget — dropping group 4 rather than reinserting it would be simpler.
  • Comments citing prior PR numbers and review rounds ("the collision bug from the closed PR", "Round-7 review's confirmed bug") read as review history rather than code documentation and will age badly once that context is gone. The behavioural half of each comment is genuinely valuable — keep that, drop the provenance. The commit message and this PR body are the right home for it. Related: style.go is now ~65% comment by line in the new section; a few of these explain the same decision twice.

Nothing blocking except the shared-line budget. Fix that and I think this ships.

…tent

Round 8 review flagged a real bug: two bare URLs sharing one reflowed
line starve the second one, because restoreBareURLs processed URLs
independently and the first one's expanded hyperlink became part of
the "rest" the second one budgeted against. Fixing that properly
required rewriting restoreBareURLs to treat the rendered LINE as the
unit of work: find every placeholder on a line at once, compute one
shared budget, split it evenly.

Debugging that fix surfaced something bigger. glamour pads every
short block line with trailing spaces out to the full block width
(styleConfig's own doc comment says this). The per-line budget
calculation was measuring that padding as if it were real content —
so a single bare URL alone on its own line, with the entire pane
width free, was getting a budget of about 2 cells and rendering as
"h..." This has been true since the "keep trailing content" fix
several rounds back added visibleWidth(rest) to the budget formula:
rest includes the padding, not just genuine trailing prose. It passed
every review round and every existing test because nothing checked
display-text legibility — only that the hyperlink target was intact
and the line never exceeded the pane width, both of which stayed true
for a two-cell display just as much as a full one.

Fixed by stripping the padding before measuring: trim the line's
plain-text trailing spaces, then cut the ANSI-styled line to that same
visible width with x/ansi.Truncate (escape-aware, so this only drops
the filler and keeps every real byte). Two board follow-ups I logged
in earlier rounds turn out to have been this same bug in disguise —
"reflowed-paragraph URLs elide down to very few cells" and "two URLs
sharing a line starve the second" were both the padding-as-content
measurement, not separate deliberate tradeoffs. Neither needs a board
entry now; both are fixed here.

Two new tests: the shared-line case review round 8 asked for
(asserting both targets intact and both displays above a usable width
floor), and a strengthened TestBareURLIntact that checks display width
directly rather than only target-correctness — the check that should
have caught this several rounds ago.

Full suite plus go vet clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid overall. The width math holds, the OSC 8 handling is careful where it needs to be (percent-encoded target, control bytes stripped from the display text, index-keyed placeholders so two URLs cannot cross-link), and the visibleWidth switch to x/ansi is the right call — applyLineBg’s pad-to-width and the status bar both need an OSC-aware measure now. Watcher, reload, and scroll preserve/clamp are untouched. Full suite passes locally.

One real bug.

Fence tracking breaks when a fence follows an indented code block

In stashBareURLs, the fence-open check lives in the else branch of the inIndentedCode state machine. The line that closes an indented code block takes the if branch, clears the flag, and falls straight through to the bare-URL match — it never gets fence-checked.

Repro:

intro

    indented code

```
https://example.test/long-url-inside-a-fence
```

Trace: indented code sets inIndentedCode; the blank line hits continue; the opening clears `inIndentedCode` and skips the fence check, so **no fence opens**. The URL line is then stashed, hyperlinked, and elided — exactly what the PR states fenced blocks are protected from. Second-order effect: the *closing* does reach the else branch and opens a fence, so from there to the end of the document (or the next matching run) every real bare URL is silently skipped and the fix stops applying.

Fix is to hoist the fence check out of else. After the inIndentedCode block clears the flag, indented is false for that line by construction, so the indented-code-open check cannot misfire:

if inIndentedCode {
	if blank || indented {
		continue
	}
	inIndentedCode = false
}
if m := fenceLine.FindStringSubmatch(line); m != nil {
	fenceChar, fenceLen = m[1][0], len(m[1])
	continue
}
if wasPrevBlank && !blank && indented && !listMarkerPrefix.MatchString(line) {
	inIndentedCode = true
	continue
}

TestBareURLInFenceUntouched does not catch this because its fence starts at document position 0. Worth a case with the full sequence: indented block, blank, fence, URL, fence, then a real bare URL that must still be hyperlinked.

Smaller notes

a. The non-TTY path still splits URLs mid-token. linkify=false skips stashing entirely, so sidecar --static | grep gets glamour’s original hard break — the URL is un-elided but still broken across two lines, so it is neither greppable nor copy-pasteable. renderMarkdown’s doc comment ("plain, complete, un-elided text") and runStatic’s ("a full, plain, un-elided URL serves it better") oversell what that reader actually gets. Either stash-and-restore the plain full URL on that path too, or soften the comments to describe what happens. Reasonable to leave the behavior as out of scope for issue #15 — but the comment should not claim the stronger property.

b. Even split of the shared line budget. Two placeholders on one physical line each get available/2, even when the first needs 8 cells and the second needs 60. The comment correctly explains why the shared budget beats the sequential version; a "give each what it needs, split the surplus" pass would elide less. Fine to leave.

c. visibleWidth is now grapheme-cluster-aware everywhere, not just on hyperlinked lines — x/ansi.StringWidth clusters where reflow.PrintableRuneWidth counted runes. Right direction, and it is what makes the OSC-aware padding correct, but it also quietly changes status-bar and applyLineBg padding for the board’s emoji headings. Related: truncateTo (ui.go:497) still measures per-rune, which now disagrees with the cluster-aware metric for any multi-rune cluster. It only sees the ASCII "updated Ns ago" string today, so it is harmless — flagging it because the metric moved underneath it.

Nothing else of substance: colors stay truecolor hex (raw 38;2;r;g;b, and the reasoning for avoiding termenv.String is right), the compact-spacing/tidy path is unchanged, and neither residual-placeholder sweep can corrupt a link target since oscTarget percent-encodes \x1f.

Round 9 review found one real bug, traced and confirmed exactly as
reported.

The fence-open check lived in the else branch of the inIndentedCode
if/else. The line that CLOSES an indented code block (non-blank,
non-indented) takes the if branch, clears the flag, and — because it's
if/else, not sequential — never reaches the else branch's fence check
on that same line. So a fence immediately following an indented code
block never opens: the URL inside it gets hyperlinked, exactly what
fence protection exists to prevent. Second-order effect confirmed by
tracing it through: the line meant to CLOSE that fence then hits the
(still-untouched) else branch fresh, and since fenceChar is still 0 it
reads as an OPEN instead of a close — a phantom fence starts there and
never finds a match, silently disabling the whole fix for every real
bare URL for the rest of the document.

Fixed by hoisting the fence check out of the else and running it
unconditionally after the inIndentedCode block, rather than as an
alternative to it. indented is false by construction on the line that
just closed a code block (that's what let it fall through in the
first place), so the indented-code-open check right after can't
misfire on that same line.

Also softened two doc comments (renderMarkdown's linkify parameter,
runStatic) that oversold what the non-TTY path actually guarantees —
a short URL comes through plain and intact, but one long enough to
still need wrapping at the fallback width hits glamour's original
hard break, same as before this fix. Round 9's other two notes
(uneven shared-line budget split, x/ansi width metric now used
everywhere not just hyperlinked lines) were both explicitly "fine to
leave" / "harmless" from the reviewer — no code change.

New test reproduces the exact sequence from the review: intro text,
indented code, fence, URL, fence, blank, then a real bare URL that
must still be hyperlinked — asserting both that the fenced URL stays
untouched and that the later real URL doesn't fall victim to the
phantom fence.

Full suite plus go vet clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

This is solid work, and the approach is right. Verified locally: go vet clean, go test -race ./... passes, and watcher.go / the reload+scroll path in ui.go are untouched apart from the three renderMarkdown call sites, so the rename-swap/delete/recreate behaviour and SetYOffset preservation carry over unchanged.

On the hard requirements:

  • Never wider than the pane — holds by construction, not just by test. In restoreBareURLs, available is measured against the line with placeholders removed, and each display text is cut to available / len(matches), so the reassembled line is bounded by width. available < len(matches) falls into the share < 1 drop, so it can't go negative-then-overflow. The padding-strip (TrimRight + xansi.Truncate to realWidth) is the right fix for the starved-budget problem — measuring glamour's MarginWriter filler as content would have been silent forever otherwise.
  • Colors — good call using a hand-written \x1b[4;38;2;r;g;bm instead of termenv.String; that's consistent with diff.go and survives the degraded-profile case the tests actually run under.
  • Compact spacingtidy still runs last and is unaffected; the OSC 8 terminator is BEL, so TrimRight(line, " ") can't eat into it.
  • Concurrency — no change, and none needed.
  • The visibleWidth swap to x/ansi.StringWidth is correct and arguably overdue: it's the same metric lipgloss/bubbletea use downstream, so the width the invariant asserts is now the width the truncator applies.

Things worth changing:

a. \x1f in the source isn't neutralized before stashing. restoreBareURLs searches the rendered output for \x1fU<n>\x1f, but nothing strips \x1f from raw on the way in. A board line that already contains those bytes — from an agent pasting raw tool output, which is the exact threat model stripControlBytes cites — reaches the output intact and gets substituted with urls[0]'s hyperlink. The comment claiming "nothing else in this pipeline emits it" is true of the pipeline but not of the input. One line at the top of stashBareURLs closes it:

raw = strings.ReplaceAll(raw, "\x1f", "")

b. A dropped URL takes the whole line with it. When share < 1 the placeholder is replaced with nothing, leaving only glamour's indent — which tidy then trims to empty and collapses into the neighbouring blank run. So the line doesn't render as "URL elided", it vanishes. The comment frames this as "dropped rather than drawn", which undersells it. It needs a genuinely pathological indent to trigger, so this is a note rather than a blocker, but emitting a single at share >= 1-equivalent cost would keep the line visible for the same width guarantee.

c. Leftover budget isn't reclaimed. share := available / len(matches) splits evenly, so a short first URL on a shared line leaves its unused cells on the floor and the integer remainder is discarded. Cosmetic, and only on multi-URL lines — mentioning it so the even split reads as a deliberate simplification rather than an oversight.

d. Trim the review archaeology out of the comments. Several of the new comments narrate the PR's own review history rather than the code: "Round-7 review's confirmed bug", "Round-9 review's confirmed bug", "PR #18's confirmed bug", "the 'keep trailing content' fix this comment replaces". A few of these run 15+ lines against 3 lines of code. That history belongs in the commit messages and this thread — six months from now a reader needs to know what the code does and why, not which review round found what. The invariant statements inside them are worth keeping; the round numbers and the references to superseded implementations aren't. Same for the test comments. This codebase's existing comments (diff.go, ui.go) are explanatory without being historical — match that.

Nothing here blocks merge except your own call on (a), which I'd take.

…ology

Round 10 review found one real gap, one worth taking.

The one real gap: restoreBareURLs searches rendered output for the
\x1f-delimited placeholder, but nothing stripped \x1f from the raw
board text on the way in. A line already containing that byte (pasted
tool output, the same threat model stripControlBytes exists for)
could prefix-match a real placeholder and get substituted with the
wrong URL's hyperlink. \x1f has no legitimate use in board text, so
stashBareURLs now drops it from the input unconditionally, same
directness as the display-text sanitization from an earlier round.

Also trimmed review-round citations out of several comments
("Round-7 review's confirmed bug", "PR #18's confirmed bug", and
similar) per the same round's note that they read as review history
rather than code documentation, and will mean less to a reader once
that context is gone. Kept every behavioral explanation; only dropped
the provenance. Two smaller notes from this round — uneven budget
split on a shared line, a dropped URL collapsing its line to nothing
rather than showing a single character — were explicitly framed as
notes rather than blockers, and are left as-is.

New test: a board line containing a raw \x1f byte must not corrupt an
adjacent real URL's hyperlink.

Full suite plus go vet clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@than
than merged commit 1078176 into main Aug 7, 2026
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.

Viewer: wrapped URLs break clickable links

1 participant