fix: OSC 8 hyperlink bare URLs instead of letting them wrap-split - #21
Conversation
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>
ReviewThe core design is right. Index-keyed placeholders kill the collision class structurally, Four things, one of them real. a.
|
…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>
ReviewSolid 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
Worth changinga. Width-metric drift in The pane-width invariant is now enforced with One line fixes both, using the same package the invariant is measured with: That is grapheme-aware, counts the tail toward the width, returns the URL unchanged when it fits — and it drops the b. The 4-space skip reopens the original bug for list continuations The 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
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 |
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>
ReviewVerdict: solid. Right approach, the hard invariants hold, and the test suite is unusually good for a change of this shape — What I verified
Worth addressinga. Elide from the middle, not the tail. b. Ordered-list and blockquoted URLs still hit the original bug. c. Notes, not blockers
|
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>
ReviewThe core approach is right, and the parts that mattered most are handled well. What holds up:
The display text is not sanitized the way the target is.
display := xansi.Truncate(url, budget, "…")
styled := termenv.String(display)...
lines[li] = prefix + hyperlink(url, styled) + rest
So a board line whose URL contains a literal ESC byte — say
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:
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>
|
Reviewed the full diff against 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:
Watcher, reload, and scroll paths are untouched, and Three things: a. The link styling is the only color path that depends on termenv's auto-detected profile
styled := termenv.String(display).Foreground(termenv.TrueColor.Color(colorLink)).Underline().String()
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 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
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 c. A URL reflowed into a paragraph can be elided down to nothing legible
That's the exact fixture in Noted, not asking for changes
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>
|
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 Watcher, reload, and scroll-restore paths are untouched — Two things worth fixing before merge, then some nits. a) Multi-line indented code blocks are only protected on their first line
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 b) Two bare URLs on the same reflowed line starve the second one
collapses to one rendered line holding both 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 c) Please confirm the width-15 overflow predates this PR
Nits
Comment density is heavy in places, and a couple of the longest comments are load-bearing for logic that should not need them — the |
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>
ReviewSolid 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 Verified locally on the branch:
One real bug: the budget math is wrong when two placeholders share a rendered line
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. Those are one paragraph, so glamour reflows both placeholders onto a single rendered line — the same reflow that The pane-width invariant itself survives (each later URL measures against the real expanded prefix, and 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 Worth a test with two bare URLs in one paragraph asserting both targets are intact and both display texts are non-trivial. Minor
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>
|
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 One real bug. Fence tracking breaks when a fence follows an indented code block In Repro: intro
indented code
```
https://example.test/long-url-inside-a-fence
```Trace: Fix is to hoist the fence check out of 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
}
Smaller notes a. The non-TTY path still splits URLs mid-token. b. Even split of the shared line budget. Two placeholders on one physical line each get c. Nothing else of substance: colors stay truecolor hex (raw |
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>
ReviewThis is solid work, and the approach is right. Verified locally: On the hard requirements:
Things worth changing: a. raw = strings.ReplaceAll(raw, "\x1f", "")b. A dropped URL takes the whole line with it. When c. Leftover budget isn't reclaimed. 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 ( 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>
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:
renderMarkdown's return value only.viewport.Modelapplies lipglossMaxWidthto 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'struncate.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:
visibleWidthswitched frommuesli/reflow(CSI-only) tocharmbracelet/x/ansi.StringWidth(OSC-aware) — width/padding math and "never wider than the pane" hold for hyperlinked lines too, no exemption needed.changedLinesneeded no change — its existing SGR-only strip never touched OSC 8, so the diff key already retains the link target.Testing
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, andTestHyperlinkSurvivesDownstreamTruncation— the exact gate Keep wrapped URLs clickable (#15) #18 couldn't check, pinned directly againstx/ansi.Truncate.go vetclean.Closes #15
🤖 Generated with Claude Code