Skip to content

Keep wrapped URLs clickable (#15) - #18

Closed
than wants to merge 6 commits into
mainfrom
fix-15-link-wrap
Closed

Keep wrapped URLs clickable (#15)#18
than wants to merge 6 commits into
mainfrom
fix-15-link-wrap

Conversation

@than

@than than commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Overview

A bare URL is the board's own convention for something a person should be able to click straight from the pane. That convention broke down the moment a URL outgrew the render width: glamour's word‑wrap doesn't know a URL is a single, indivisible token, so it force‑breaks it mid‑character wherever the width boundary happens to fall — and the terminal's native link matcher, which this project deliberately relies on instead of capturing the mouse, sees two dead fragments instead of one link.

Approach

Emit OSC 8 hyperlinks around bare URLs, with the visible text pre‑shortened so it never gets a chance to wrap — a hybrid of the two candidates in the issue.

Pure OSC 8 (option a) doesn't survive this pipeline unmodified: muesli/reflow's ANSI‑aware word‑wrap and width counter only recognize a narrow CSI terminator range ([0-9;]*[A-Za-z]), with no notion of OSC sequences. Feed it an OSC 8 escape carrying a URL and the first ordinary lowercase letter inside that URL reads as a premature sequence terminator — wrapping and width math both corrupt.

So the fix does the shrink first: any bare‑URL‑only line (the existing board convention — a URL alone on its own list item or line) that's wider than the render width gets truncated to fit, ellipsis included, before it reaches glamour. That guarantees glamour's own word‑wrap never has to force‑break it — it's already short enough. Only after rendering and tidying is the OSC 8 hyperlink stitched back on, wrapping the truncated visible text and pointing at the untouched, full URL. In a terminal that honors OSC 8 (Ghostty included), the whole truncated line is clickable and opens the real target regardless of what's visually shown. In one that doesn't, the line simply reads as short, plain, ellipsis‑terminated text — no worse than a truncated bookmark title.

visibleWidth and stripANSI were both taught to strip OSC 8 sequences before measuring or diffing, so the hyperlink's URL payload can never leak into a width check or a change comparison. Short URLs that already fit are left completely alone — no new styling, no risk to the existing autolink path, and the pre‑existing tests for the fixture's short URLs pass unmodified.

Trade-offs

  • Truncation is visual, not just theoretical. A very long URL now shows as https://github.com/… rather than in full — some pre‑click context is lost, though the OSC 8 target underneath is always the untruncated URL, so nothing is ever unreachable.
  • Scoped to bare-URL-only lines, matching the board convention called out in the issue. A URL embedded mid‑sentence still wraps the old way; broadening the fix to inline links is a reasonable follow‑up if it turns out to matter in practice.
  • BEL-terminated OSC 8, not ST. ESC \ never gets recognized as a terminator by this codebase's ANSI helpers (they only match [A-Za-z]), so an ST‑terminated sequence would appear to run forever. BEL is a single byte and terminates cleanly under the same logic already used for CSI codes.

Testing

  • New regression tests in render_test.go: TestLongBareURLNeverWraps (a URL wider than the render width stays on one line across widths 24/40/78, and its OSC 8 target carries the full untruncated URL) and TestOSC8IsInvisible (the hyperlink escape never counts toward visibleWidth or survives stripANSI).
  • New unit tests in linkify_test.go covering truncateBareURLs (short URLs left alone, inline URLs left alone, long ones shortened to fit) and findPlainRange (ANSI-aware substring location, including the no-match case).
  • Full suite: go test ./... and go vet ./... both clean.

Closes #15

🤖 Generated with Claude Code

Bare URLs on their own line are the board's convention for a link a
human should be able to click. glamour's word-wrap has no concept of
an atomic URL — when one is wider than the render width it force-breaks
mid-character, so the terminal's native link detection sees two dead
fragments instead of one URL (sometimes with a stray space at the
wrap point).

The fix: shrink any bare-URL-only line that's wider than the render
width down to width before handing it to glamour (ellipsis-truncated,
so it never gets a chance to wrap), then re-attach the full URL after
rendering as an OSC 8 hyperlink target around the truncated text. A
terminal that understands OSC 8 (Ghostty included) makes the whole
truncated line clickable and opens the untruncated URL regardless of
what's visually shown.

OSC 8 escapes needed BEL termination, not ST — this codebase's ANSI
width/wrap helpers (muesli/reflow, used by both glamour's own
word-wrap and this project's visibleWidth) only recognize a narrow
CSI terminator range and would otherwise treat letters inside the
URL as premature sequence terminators. visibleWidth and stripANSI
now both strip OSC 8 sequences before measuring/comparing, so the
escape's URL payload never leaks into width checks or diffing.

Short bare URLs that already fit are left untouched — no new styling,
no behavior change, existing tests unaffected.
Closes #15

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

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review: keep wrapped URLs clickable

The approach is right — shrink before glamour sees it, stitch the OSC 8 back on after. The reasoning about reflow not understanding OSC sequences is correct, and teaching visibleWidth/stripANSI to skip OSC 8 is the right call: applyLineBg padding and changedLines both go through those, so the flash and the change-pointer stay correct. Nothing here touches the watcher, the reload/scroll path, or p.Send, and the link styling stays truecolor off colorLink. Three things to fix before merge.

a. Two truncations with the same display text mislink (real bug)

linkifyTruncations searches rendered from offset 0 on every iteration, and the first replacement leaves the display runes visible (the escapes go around them, not inside). So when two truncations share a display string, the second iteration re-finds the first occurrence and nests a second OSC 8 inside the first. Net effect: the first line links to the second URL, and the second line gets no hyperlink at all. Visible width is unchanged, so no existing test notices.

Sharing a display string is the common case, not a corner — two URLs collide whenever they agree on their first budget-1 runes. testdata/REVIEW.md already does it: .../pull/412 and .../pull/415 are both 39 runes, so at width 40 (a width TestNeverWiderThanWidth already exercises) budget is 38, keep is 37, and both truncate to the identical https://github.com/example/app/pull/4….

Truncations are produced in document order and render in document order, so a cursor fixes it:

cursor := 0
for _, t := range truncations {
    start, end, ok := findPlainRange(rendered[cursor:], t.display)
    if !ok {
        continue
    }
    start, end = start+cursor, end+cursor
    // ... build styled ...
    rendered = rendered[:start] + styled + rendered[end:]
    cursor = start + len(styled)
}

Worth a regression test: two bare URLs sharing a long prefix, asserting both full targets appear as OSC 8 opens, in order.

b. truncateBareURLs rewrites inside fenced code blocks

bareURLLineRE runs over every raw line with no fence tracking, so a lone URL inside a fenced block gets ellipsized and then hyperlinked. That silently alters content the user typed verbatim, and code blocks are not word-wrapped by glamour anyway, so there is nothing to fix there in the first place. Track fence state (a line of three backticks or three tildes toggling in/out) and skip lines inside. Indented code blocks are already safe, since the regex anchors the URL at column 0 when there is no bullet prefix.

c. reserve = 2 only holds for a top-level bullet

reserve is hard-coded to 2 for the prefix, but List.LevelIndent is 2 as well, so a nested item ( - https://...) carries that indent on top of the bullet. The budget is then too generous by the indent, the rendered line goes over width, and glamour force-breaks the truncated text — the exact bug this PR exists to fix. Worse, once it breaks, findPlainRange cannot match across the break, so it takes the silent continue and the link is gone too: truncated text pointing nowhere, strictly worse than before. Deriving reserve from the matched prefix (leading whitespace length + 2) plus a nested-bullet case in TestLongBareURLNeverWraps at ~30 columns would cover it.

More generally, that silent continue is the one swallowed path here. If the display text cannot be located, the text has already been shortened and the target is now unreachable — better to not truncate at all than to truncate without attaching a link.

Smaller notes

  • > https://... and - [ ] https://... do not match bareURLLineRE, so those still force-break. Fine as scope; just noting the fix is narrower than it reads.
  • The README rendering-style bullet still says bare URLs stay intact and clickable. Worth a clause that over-width ones are shortened, with the full URL carried as the OSC 8 target.
  • indexRunes, the empty-needle branch is unreachable — findPlainRange already rejects an empty plain. Collapse it.
  • budget < 8 cannot trigger: width is clamped to 10 just above and reserve maxes at 2.
  • findPlainRange builds three full-document slices per truncation. Fine at board sizes; the cursor fix above also shrinks each scan to the remainder.

Three correctness bugs plus cleanup, all reviewer-caught:

- linkifyTruncations searched from offset 0 on every iteration, so two
  truncations sharing identical display text (long shared prefix, same
  budget cut) nested the second's hyperlink inside the first's occurrence
  and left the second display line with no link at all. Fixed with a
  forward-only cursor: each search starts just past the previous
  replacement.

- truncateBareURLs truncated bare URLs inside fenced code blocks, silently
  altering verbatim content. Now tracks fence state (fence markers toggle
  on either ``` or ~~~) and skips fence-interior lines entirely.

- reserve was hard-coded to 2 (a top-level bullet's marker width), so a
  nested bullet's real indent (LevelIndent per level) was under-budgeted:
  the truncated text was still too wide, glamour force-wrapped it anyway,
  and findPlainRange couldn't relocate the (wrongly sized) display text
  post-render — silently dropping the truncation and leaving inert,
  unlinked, truncated-looking text. Worse than the pre-fix bug, which at
  least left recognizable URL fragments.

  Two changes: reserve is now derived from the matched line's actual
  leading whitespace (+2 for the bullet marker), so nested bullets budget
  correctly; and linkifyTruncations now reports truncations it couldn't
  relocate instead of swallowing them. renderMarkdown retries once,
  rendering just the unresolved URLs untruncated (skip list) — falling
  back to the pre-fix behavior for that one line rather than compounding
  a bad guess with a second one.

Smaller cleanup:
- README: bare URLs wider than the pane are now documented as
  ellipsis-shortened with the full URL kept as an OSC 8 target.
- indexRunes: dropped the empty-needle branch — unreachable, since
  findPlainRange already rejects an empty plain string before calling it.
- Kept (didn't drop) the budget < 8 floor in truncateBareURLs: the
  nested-indent reserve change above means reserve is no longer small and
  fixed, so an unbounded nesting depth can genuinely push budget below 8 —
  the guard stays reachable and necessary.

New regression tests (red verified before the fix):
TestCollidingTruncationsBothLinked, TestBareURLInFenceUntouched,
TestNestedBareURLNeverWraps in render_test.go;
TestTruncateBareURLsSkipsFencedCode,
TestTruncateBareURLsFenceToggleTildeAndBacktick,
TestTruncateBareURLsSkipsListedURLs,
TestTruncateBareURLsReservesNestedIndent,
TestLinkifyTruncationsCollidingDisplayText in linkify_test.go.

go vet ./... and go test -count=1 ./... both clean.

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

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown

Read the whole diff plus diff.go, ui.go, main.go. go test ./... and go vet ./... are clean here too. The approach is sound and the code is unusually careful — the reasoning in the comments about why reflow can't see OSC 8, and why the shrink has to happen before glamour, is correct and worth having written down.

What holds up. The watcher, scroll preservation, and the p.Send path are untouched. The never-wider-than-the-pane requirement survives: visibleWidth and stripANSI both neutralize OSC 8 before measuring, and tidy still runs before linkifyTruncations, so no trailing-space padding is reintroduced. Colors stay hex/truecolor. The forward-only cursor in linkifyTruncations is the right design and the collision test pins it. The unresolved-to-untruncated fallback is a good safety valve.

Real problems below, most important first.

a. Change detection goes blind on exactly the lines this PR creates

changedLines diffs on stripANSI(l), and stripANSI now strips the OSC 8 wrapper including the target. So the only surviving representation of a long URL in the compared text is the truncated display string — and two URLs sharing a prefix longer than the budget produce the identical display string.

Concretely, at a 40-column pane: renderWidth 38, reserve 2, budget 36. https://github.com/than/sidecar/pull/17 is 39 chars, so it truncates to https://github.com/than/sidecar/pul…. Edit the board to point at /pull/18 and the rendered visible text is byte-identical — no marker, no line flash. The README sells "points at what changed"; this silently disables it for precisely the long-URL lines the PR targets, and only in the narrow panes this tool is built for.

Fix is small: give the diff a comparison key that retains the hyperlink target, e.g. in changedLines use ansiRE.ReplaceAllString(l, "") (SGR only) instead of stripANSI(l). stripANSI itself should keep stripping OSC 8 — isBulletLine and the width path want that.

b. --static into a pipe loses the URL outright

runStatic is documented as "handy for piping, CI", and falls back to width 80 when stdout isn't a TTY. Truncation now applies there too, so a redirected board renders https://github.com/… with the only copy of the full URL buried inside an escape sequence that grep, an editor, or a CI log viewer won't surface. In the TUI the OSC 8 target makes truncation lossless; in a pipe it doesn't. Consider skipping truncateBareURLs when stdout isn't a terminal — runStatic already knows, since it calls term.GetSize.

c. Control bytes in a URL escape the OSC 8 sequence

bareURLLineRE captures https?://\S+, and \S happily matches a raw BEL or ESC. osc8Open emits t.full verbatim, so a board line containing https://x.test/<BEL>evil terminates the OSC early and hands the remaining bytes to the terminal as commands. osc8RE has the mirror problem — [^\x07]* stops at the embedded BEL, so visibleWidth and stripANSI both mis-measure that line. The board is written by an agent, so the content is effectively arbitrary. One-line guard: skip truncation when the URL contains any byte outside 0x20–0x7e, which is what OSC 8 requires of the target anyway.

d. Budget is counted in runes, not cells (nit)

len([]rune(url)) <= budget and the rune-slice cut both assume one cell per rune. An IDN or otherwise non-ASCII bare URL measures short and renders wide, glamour wraps it anyway, findPlainRange misses, and the fallback quietly reverts to the pre-fix broken behavior. Using visibleWidth for the check and cutting on accumulated cell width would make it consistent with the rest of the file.

e. Fence tracking ignores the fence character (nit)

fenceRE toggles inFence on any ``` or ~~~ line regardless of which opened the block, so a ~~~ line inside a backtick fence flips the state and the rest of that code block becomes eligible for truncation. Recording the opening fence's character and requiring a match to close would fix it.

f. Coverage gap, for the record (nit)

The PR documents the bare-URL-only scoping as a trade-off, which is fair. Worth naming the shapes that still fall through: 1. https://…, > https://…, and - [ ] https://…. The task-list form is the likeliest to bite on a to-do board, since Task is a styled block in styleConfig.

One thing to eyeball

No test exercises the full TUI path, only renderMarkdown directly. Bubble Tea's renderer and the viewport do their own line-width work on the final frame, and whether those helpers understand OSC 8 (charmbracelet/x/ansi does; muesli/reflow does not) decides whether a hyperlinked line survives the last hop intact. Worth one manual look in Ghostty at roughly a 40-column pane with a long URL before merging — if the wrong helper is in that path, the symptom is a line cut mid-escape and the rest of the frame swallowed, which no unit test here would catch.

Item (a) is the one I'd want fixed before merge; (b) and (c) are cheap. The rest are nits.

AB1 (must) — changedLines compared on stripANSI(l), which strips OSC 8
wrappers including the target. Two long URLs colliding on their truncated
display text (identical visible text, different targets) produced identical
comparison keys, so a target-only edit went completely undetected: no ▸
marker, no flash — exactly on the lines this PR creates. changedLines now
compares on an SGR-only strip (ansiRE.ReplaceAllString) so the OSC 8 target
survives as part of the key. stripANSI itself is unchanged (still strips
OSC 8 for isBulletLine/visibleWidth, where the target must stay invisible).

AB2 — `--static` piped into another tool (grep, a file, ...) buried the only
full copy of a long URL inside an OSC 8 escape, unrecoverable by whatever
reads the pipe. Added renderMarkdownPlain (no truncation, no hyperlinking)
and switched runStatic to it whenever stdout isn't a terminal, reusing the
existing term.GetSize call's success/failure as the terminal signal rather
than adding a second IsTerminal check.

AB3 — a raw control byte (BEL, ESC, ...) captured by bareURLLineRE's "\S+"
would land verbatim inside an OSC 8 escape's target, letting it terminate
the escape early and inject an attacker-controlled terminal sequence.
osc8Safe now gates the hyperlink attachment: any URL with a byte outside
0x20–0x7E gets no OSC 8 wrapper.

Deliberate scope note (see below for why this isn't also gating
truncation): the display-text truncation in truncateBareURLs is NOT gated by
this check. The truncated text is plain markdown, rendered exactly the way
glamour already renders any bare URL's raw bytes today — no new escape
sequence is built from it, so no new injection surface. Gating truncation
too would have reintroduced issue #15's wrap bug for any URL containing a
legitimate wide/non-ASCII rune (AB4), since those also fall outside
0x20–0x7E. So: unsafe URLs still get width-correct truncation (no visual
wrap), just never a hyperlink.

AB4 — the fit/cut budget was measured in runes, not terminal cells: a
wide-rune URL (CJK domain, box-drawing, ...) measured shorter than it
actually renders, was let through untruncated, and glamour word-wrapped it
anyway — falling back to the exact wrapped-URL bug this PR exists to fix,
just triggered by rune width instead of rune count. Both the fits-check and
the cut now use cell width: visibleWidth (already used throughout this file
for width math) for the check, and a new cutToCellWidth helper (built on
go-runewidth, already in the dependency tree via muesli/reflow) for the cut.

AB5 — fence tracking flipped state on ANY fence delimiter, so a ~~~ line
inside a ``` block (or vice versa) closed the wrong fence and let the
"reopened" remainder through to truncation. fenceRE now captures which
character opened the fence, and only a matching character closes it —
mirroring CommonMark's own rule.

AB6 — bareURLLineRE now documents the shapes deliberately left unhandled:
an ordered-list URL ("1. https://…"), a block-quoted URL ("> https://…"),
and a task-list URL ("- [ ] https://…"). None currently appear on real
boards using this viewer; each would need its own reserve term to size
correctly. A URL in any of these shapes still renders, just with the
pre-#15-fix wrapping behavior if it's too long.

New regression tests (each confirmed red before its fix):
TestChangedLinesOSC8TargetChangeDetected,
TestChangedLinesDetectsCollidingURLTargetChange (AB1);
TestRenderMarkdownPlainNoHyperlink (AB2);
TestOsc8SafeRejectsControlBytes, TestLinkifyTruncationsSkipsUnsafeURL,
TestControlByteURLNeverHyperlinked (AB3);
TestTruncateBareURLsCutsWideRunesByCellWidth, TestWideRuneURLNeverWraps
(AB4); TestTruncateBareURLsFenceCharMustMatch (AB5).

go vet ./... and go test -count=1 ./... both clean.

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

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown

Reviewed at c950fa5. go build, go vet, and go test ./... are all clean locally.

Overall: solid

The core move — shrink the line before glamour, stitch the OSC 8 back on after — is the right shape for this pipeline, and the diagnosis is correct: reflow's width counter and wordwrap only recognise CSI, so an OSC 8 escape fed through them corrupts on the first lowercase letter in the URL. Working around that instead of through it was the right call.

The invariants I care about all hold:

  • Width. Nothing renders wider than the pane. visibleWidth strips OSC 8 before measuring, and the failure mode when the reserve estimate is wrong is a wrap — which glamour bounds to the width — so findPlainRange misses and the untruncated fallback kicks in. Degrading into "the old bug" rather than "an over-wide line" is the correct direction to fail, and renderMarkdown's retry-with-skip makes that explicit rather than silent.
  • Compact spacing / no trailing padding. linkifyTruncations runs after tidy, and only substitutes bytes within a line — the replacement contains no newline and no trailing space, so neither invariant can be disturbed. Ordering here matters and it's right.
  • Colors. The injected style is truecolor derived from colorLink; no palette indexes.
  • Bare URLs. Short URLs are left completely alone — no new escapes on the existing autolink path. That's the important part.
  • Watcher / scroll / concurrency. Untouched, and correctly so. Everything new in linkify.go is pure functions over strings; the package-level vars are compiled regexps, so the watcher goroutine's p.Send path gains no shared mutable state.

The changedLines split from stripANSI is subtle but justified, and the comment earns its length. Test coverage is genuinely good — the colliding-display-text and nested-indent regressions are exactly the ones I'd have gone looking for.

Worth fixing

a) The osc8Safe skip doesn't advance the cursor in linkifyTruncations.

The forward-only cursor is what keeps colliding display strings from stacking onto the first occurrence — but the unsafe-URL branch continues before the search, leaving the cursor where it was. So if an unsafe URL and a later safe one truncate to the same display text:

- https://example.test/aaaa…<BEL>x
- https://example.test/aaaa…y

...the second truncation searches from the stale cursor, finds the first line's text, and hyperlinks it to the second URL — the wrong line gets the link, and the intended line gets none. This is the same failure the cursor was added to prevent, just reached through the other early exit. Needs a control byte in a URL to trigger, so it's low severity, but the fix is small: on the unsafe skip, still run findPlainRange and advance cursor past the match before continuing.

b) The ellipsis is assumed to be one cell wide.

cutToCellWidth(url, budget-1) hardcodes 1 for the . U+2026 is East Asian Ambiguous — go-runewidth's default condition picks up RUNEWIDTH_EASTASIAN, and failing that infers from LANG/LC_CTYPE, so under a CJK locale RuneWidth('…') is 2. The display text then comes out one cell over budget, glamour wraps it, findPlainRange misses, and every long URL silently falls through to the untruncated path — issue 15 quietly unfixed for those users, with no width violation to show for it. Use runewidth.RuneWidth('…') instead of the literal 1 (or measure the assembled string). Same argument you already make for cell-width vs rune-count, one layer up.

c) osc8RE's [^\x07]* crosses newlines.

Safe today because open and close are always emitted as a pair inside one line. But stripANSI is a general helper and tests already hand it whole documents — if an unpaired open ever reaches it, the match runs to the next BEL anywhere downstream and eats visible text along the way. [^\x07\n]* costs nothing and makes the bound structural rather than incidental.

One design note, not a blocker

In a terminal that ignores OSC 8, the visible https://github.com/exa… is still shaped like a URL, so that terminal's own matcher will likely linkify the valid-looking prefix. That's arguably worse than the dead fragments it replaces: a clickable link that quietly goes somewhere else beats one that goes nowhere, for the wrong reasons. The trade-offs section frames this as "no worse than a truncated bookmark title," which undersells it slightly. Not something to change here — OSC 8 support is the common case and Ghostty is the stated target — but worth naming honestly in the README line if you touch it again.

AC1 — the osc8Safe skip in linkifyTruncations continued before running
findPlainRange, leaving the forward-only cursor stale at an unsafe
truncation's position. A later, safe truncation colliding on the same
display text then searched from that stale cursor, matched the unsafe
truncation's own (still-plain) occurrence, and hyperlinked the wrong line —
the exact failure the cursor was introduced to prevent, just reached via the
other early-exit path. Fixed by always running findPlainRange first; an
unsafe match still advances the cursor past itself, it just doesn't get
wrapped in an OSC 8 escape. (An unsafe truncation that isn't found at all
still contributes nothing to `unresolved`, matching the prior behavior —
there's no fallback retry to trigger for a URL that was never going to be
hyperlinked anyway.)

AC2 — cutToCellWidth's caller hardcoded the ellipsis at 1 cell. U+2026 is
East Asian Ambiguous: under an EastAsianWidth/CJK locale, go-runewidth
measures it as 2 cells, so the assembled display text landed one cell over
budget, glamour wrapped it anyway, and every long URL silently fell back to
the pre-#15-fix bug in that locale. The reserved width for the ellipsis is
now runewidth.RuneWidth('…') instead of a literal 1.

New regression tests (both confirmed red against the pre-fix code via
`git stash push -- linkify.go`, green after):
TestLinkifyTruncationsUnsafeSkipAdvancesCursor (AC1),
TestTruncateBareURLsAccountsForWideEllipsis (AC2, forces
runewidth.DefaultCondition.EastAsianWidth for the duration of the test).

go vet ./... and go test -count=1 ./... both clean.

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

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review

Overall this is careful work. The core insight — shrink before glamour, hyperlink after tidy — is right, the fenced-code and cursor-collision handling are correct, and the failure mode when the reserve estimate is wrong (fall back to untruncated) degrades to the pre-fix behavior rather than to something worse. visibleWidth/stripANSI correctly treat OSC 8 as invisible, so the "never wider than the pane" invariant and the /flash pipeline (isBulletLine, applyLineBg, composeMarked) all still hold. Scroll preservation and the watcher are untouched. Three things below.


a. Non-ASCII URLs lose their tail with no way to recover it

osc8Safe rejects every byte outside 0x20–0x7e, which rejects all of UTF-8. But truncateBareURLs truncates regardless of osc8Safe — so for a URL containing any non-ASCII rune (IDN host, unencoded path segment, a Wikipedia link with an accent), a long line gets:

  • visibly truncated to https://ex…, and
  • no OSC 8 target attached (linkifyTruncations skips it and does not report it unresolved, so the fallback retry never fires).

The full URL is then unrecoverable from the rendered output. That is a regression against the pre-PR behavior, where the URL wrapped but every character was at least on screen. TestWideRuneURLNeverWraps exercises exactly this shape and only asserts width, so it passes while the URL is silently destroyed.

The right fix is to percent-encode rather than reject. OSC 8 wants a URI; percent-encoding non-ASCII is the standard transform and is what the target should carry anyway:

// osc8Target returns url encoded for use as an OSC 8 target: bytes outside
// printable ASCII are percent-encoded. Returns false only for bytes that
// cannot appear in a URI at all (C0 controls, DEL) — those would terminate
// or restart our own escape.
func osc8Target(url string) (string, bool) {
    var b strings.Builder
    for i := 0; i < len(url); i++ {
        c := url[i]
        switch {
        case c < 0x20 || c == 0x7f:
            return "", false
        case c > 0x7e:
            fmt.Fprintf(&b, "%%%02X", c)
        default:
            b.WriteByte(c)
        }
    }
    return b.String(), true
}

The BEL/ESC injection guard — the part that actually matters — is preserved, and legitimate content stops being collateral damage. If you would rather keep the scope tight, the minimum acceptable alternative is to skip truncation too when osc8Safe fails, so those URLs keep the old wrap-but-intact behavior instead of becoming inert truncated text.


b. Please verify bubbletea does not re-truncate these lines with reflow

This is the one I could not check from this environment, and it is the highest-consequence item if it holds.

visibleWidth was taught to strip OSC 8 before handing anything to reflow — correct, and the PR description explains exactly why (reflow's ansi.IsTerminator matches [A-Za-z], so the first lowercase letter of the URL inside the escape ends the sequence early and the rest of the URL counts as visible text). But that fix only covers this repo's calls into reflow.

bubbletea's standardRenderer.flush() has historically truncated every line it writes to the terminal width using github.com/muesli/reflow/truncate, which has the same non-OSC-aware scanner. If v1.3.10 still does that, a linkified line measures as roughly width + len(url) - 6 cells to reflow, gets cut inside the escape's URL payload, and the actual visible text — which comes after the escape — is dropped entirely, leaving an unterminated OSC 8 sequence on the wire. Every test here operates on the return value of renderMarkdown, so none would catch it; it would only show up in the real TUI.

Worth a direct check before merge, e.g.:

out, _ := renderMarkdown("- "+longURL+"\n", 38) // pane 40
for _, line := range strings.Split(out, "\n") {
    if got := stripANSI(truncate.String(line, 40)); got != stripANSI(line) {
        t.Errorf("renderer-level truncation ate the line: %q -> %q", stripANSI(line), got)
    }
}

If bubbletea v1.3.x has moved to charmbracelet/x/ansi (which has a real OSC parser), this is a non-issue and the test above documents that. If it has not, the OSC 8 approach needs a guard.


c. Minor: visibleWidth now runs a regexp on every line of every frame

osc8RE.ReplaceAllString is unconditional, and visibleWidth is called per line in tidy, in the owner pass of composeMarked, and again in applyLineBg. On a 300-line board that is roughly a thousand regexp scans per recompose, and the flash toggles recompose twice per change — all to serve the handful of lines that ever contain an OSC 8 escape. A cheap guard keeps the common path allocation-free:

func visibleWidth(line string) int {
    if strings.Contains(line, "\x1b]8") {
        line = osc8RE.ReplaceAllString(line, "")
    }
    return reflowansi.PrintableRuneWidth(line)
}

Smaller notes, not blocking

  • The changedLines split from stripANSI is well reasoned and the comment earns its length. Good catch on the collision case.
  • In a terminal without OSC 8 support, the visible https://github.com/exa… will be regex-linkified to a wrong URL rather than to no URL. Roughly the same badness as the dead fragments it replaces, but worth a sentence in the README trade-off note, since the current text implies the shortened text always opens the right place.
  • A bare URL that is a lazy-continuation line of a multi-line paragraph matches bareURLLineRE and gets truncated even though the reflowed paragraph might have had room. Harmless (it either resolves or falls back), just not what the "own line" convention means.
  • TestTruncateBareURLsAccountsForWideEllipsis mutates the package-global runewidth.DefaultCondition. Fine today since nothing runs parallel, but it will bite whoever adds t.Parallel().

than and others added 2 commits August 6, 2026 12:16
AD1 — osc8Safe rejected any URL with a byte outside 0x20-0x7E outright,
which meant no non-ASCII/IDN URL (accented domain, CJK path, ...) could ever
be hyperlinked, even though that's legitimate content, not an attack.
Replaced with osc8Target(url) (string, bool): bytes above 0x7E are
percent-encoded into the OSC 8 target instead of being rejected; only C0
control bytes (0x00-0x1F) and DEL (0x7F) return ok=false, since embedding
one of those verbatim is what actually lets it terminate the escape early
and inject terminal control sequences. Truncation in truncateBareURLs was
already unconditional (round 2); linkifyTruncations now builds the OSC 8
target through osc8Target, and — per round 3's AC1 — still advances the
cursor past a rejected match before continuing.

AD2 — the open question about bubbletea re-truncating rendered lines (and
whether that would corrupt an OSC 8 hyperlink) is resolved: bubbletea
v1.3.10's standardRenderer truncates every line to the pane width with
charmbracelet/x/ansi.Truncate (standard_renderer.go:241), which has a real
OSC-aware parser — not muesli/reflow's CSI-only one this package works
around elsewhere. Added TestOSC8SurvivesBubbleteaTruncate, which renders a
linkified line and asserts x/ansi.Truncate leaves its visible text
unchanged at the pane width, documenting (and guarding) that fact against
a future bubbletea upgrade changing it.

AD3 — visibleWidth ran the OSC 8 regexp against every line on every frame,
including the overwhelming majority with no hyperlink at all. Now guarded
behind strings.Contains(line, "\x1b]8") first, so the common case stays a
single allocation-free linear scan.

AD4 — README: added the sentence that in a terminal without OSC 8 support,
the shortened visible text is just what's left to click, and that terminal's
own regex-based URL detection can resolve it to the wrong page — the same
class of wrong-link risk as the old dead fragments, not worse. The existing
wording over-promised by only describing the OSC 8-capable case.

AD5 — TestTruncateBareURLsAccountsForWideEllipsis mutated the package-global
runewidth.DefaultCondition with a plain defer; switched to t.Cleanup and
added a comment warning it isn't t.Parallel()-safe without moving to a
scoped runewidth.Condition.

New/renamed tests: TestOsc8TargetRejectsControlBytes (renamed from
TestOsc8SafeRejectsControlBytes), TestOsc8TargetPercentEncodesNonASCII,
TestAccentedURLTruncatedAndLinkedPercentEncoded (AD1);
TestOSC8SurvivesBubbleteaTruncate (AD2).

go.mod: charmbracelet/x/ansi promoted from indirect to direct (already in
the module graph via bubbletea; only the test now imports it directly).

go vet ./... and go test -count=1 ./... both clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ot search

STRUCTURAL — linkifyTruncations used to locate each truncation by searching
the whole rendered document for its display text, with no anchoring to the
line the truncation actually came from. Three confirmed findings all traced
to this one root cause: prose containing a display string as a substring
could "win" the hyperlink over the real bare-URL line; two truncations
colliding on identical display text (a reserve miss plus a wrap) could link
the wrong one and silently discard the healthy one's target; a line pasted
back from the pane (literally equal to a previous truncation's display text)
could be mistaken for the truncation's own line.

Rewrote the matching to anchor by LINE IDENTITY instead of a substring
search. A rendered line is a candidate for truncation t only if its entire
visible payload — ANSI stripped, then the glamour bullet/indent prefix
trimmed — is EXACTLY t.display, not merely contains it. Truncations sharing
an identical display are paired with their candidate lines by position (both
truncation order and candidate-line order are document order, so this is
sound). If a display's candidate-line count doesn't match its truncation
count — a decoy, a pasted-back line, or a swallowed wrap changing either
side — NONE of that display's truncations are guessed at; all are reported
unresolved so renderMarkdown's existing retry fallback fires and those
specific lines render fully, untruncated, unlinked, rather than risk
attaching a hyperlink to the wrong line.

This also structurally resolves round 3's AC1 (a forward-only cursor was
needed only because matching was document-wide; line-anchored matching has
no shared cursor to go stale) — the fix is now folded into the redesign
rather than patched again.

VERBATIM GUARDS — truncateBareURLs guarded fenced code blocks but not two
adjacent CommonMark verbatim shapes:

  - Indented code blocks (4+ tab-expanded leading columns) got no
    protection at all: bareURLLineRE's optional bullet prefix happily
    matched "    - https://…" and silently rewrote code-block content.
    leadingColumns() now tab-expands (CommonMark tab stops are every 4
    columns) and any line at 4+ columns is treated as verbatim, matching
    fenced blocks' treatment.

  - The fence tracker itself had two gaps: it flipped state on ANY fence
    character (so a ``` line inside a ````-delimited block falsely closed
    it) instead of requiring CommonMark's own rule — same character, AT
    LEast as long as the opener; and it accepted a fence marker indented 4+
    columns as a real delimiter, when per CommonMark that's not a fence at
    all. fenceRE is now capped at 3 leading spaces and records the opening
    run's length; a shorter or differently-charactered run inside the block
    is content, not a closer.

The indented-code-block fix has a side effect that also fixes a THIRD,
separately-reported bug: a leading tab expands to a full 4-column tab stop
on its own, so a tab-indented nested bullet is now excluded from truncation
entirely rather than being budgeted with an inaccurate tab-as-1-column
guess that could under-reserve it, wrap it anyway, and cascade into the
structural findings above. Chose exclusion over trying to compute a
fractional tab-to-column reserve, per the "choose and document" option in
the review notes.

SMALLER:

  - renderMarkdown's unresolved→retry fallback built its skip set keyed by
    full URL text; a duplicate URL on two lines where only one was
    unresolved would untruncate BOTH on retry, regressing the healthy line
    back to force-wrapped, hyperlink-less output. urlTruncation now carries
    the raw line index it came from, and skip is keyed by that index
    instead — truncateBareURLs's skip parameter is now map[int]bool.

  - The unresolved→retry path itself had no test exercising it end to end
    (replacing the whole fallback block with `return out, nil` passed the
    full suite). TestPastedBackTruncatedTextDoesNotStealHyperlink now pins
    it: forces a genuine ambiguous-pairing case through renderMarkdown and
    asserts the retry actually restores the full, untruncated URL.

CONTESTED — TestLongBareURLNeverWraps/TestNestedBareURLNeverWraps counted
lines containing "http" as a wrap detector; a force-wrapped continuation
starts mid-path ("com/example/...") and contains no "http", so the count
stayed 1 whether or not the line actually wrapped — confirmed dead as a
wrap detector by reproduction, even though (per the other reviewer) the
tests' OSC 8 assertion happened to catch the same regressions via a
different mechanism. Replaced with an assertion that fails on its own:
compute the exact truncated display text via truncateBareURLs (the same
function under test) and assert it appears intact, as a contiguous
substring, on exactly one rendered line. Verified this actually fires by
injecting the panel's own hypothesized regression (an off-by-one budget)
and confirming both tests fail before the fix and pass after.

New/changed tests — see linkify_test.go and render_test.go for the full
set; each structural and verbatim-guard fix was confirmed red (via a
temporary, reverted patch removing just that guard) before being confirmed
green with the fix restored:

TestTruncateBareURLsFenceCloserMustBeAtLeastAsLongAsOpener,
TestTruncateBareURLsIndentedFenceMarkerIgnored,
TestTruncateBareURLsSkipsIndentedCodeBlock,
TestTruncateBareURLsSkipsTabIndentedLines,
TestTruncateBareURLsSkipIsPerLineNotPerDuplicateURL,
TestLinkifyTruncationsIgnoresDecoyLine,
TestLinkifyTruncationsAmbiguousCountUnresolved,
TestProseDecoyContainingDisplayTextDoesNotStealHyperlink (render_test.go),
TestPastedBackTruncatedTextDoesNotStealHyperlink (render_test.go).

gofmt, go vet ./..., and go test -count=2 ./... all clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@than than closed this Aug 6, 2026
@than
than deleted the fix-15-link-wrap branch August 6, 2026 17:58
than added a commit that referenced this pull request Aug 7, 2026
…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 added a commit that referenced this pull request Aug 7, 2026
* fix: stash bare URLs before glamour render so wrap can't split them

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>

* fix: OSC 8 hyperlink bare URLs instead of letting them overflow

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>

* fix review: preserve trailing content, drop-not-overflow, CRLF, indented 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>

* fix review: metric-consistent elision, list-vs-code disambiguation

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>

* fix review: ordered-list URLs, non-TTY skips hyperlinking entirely

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>

* fix review: sanitize display text against escape injection

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>

* fix review: hyperlink styling forced truecolor, not termenv auto-detect

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>

* fix review: indented code is a block, not a per-line check

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>

* fix review: elision budget was measuring glamour's own padding as content

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>

* fix review: fence check skipped entirely on an indented-code-close line

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>

* fix review: neutralize control byte in raw input, trim comment archaeology

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>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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