Keep wrapped URLs clickable (#15) - #18
Conversation
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>
Review: keep wrapped URLs clickableThe 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 a. Two truncations with the same display text mislink (real bug)
Sharing a display string is the common case, not a corner — two URLs collide whenever they agree on their first 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
c. reserve = 2 only holds for a top-level bullet
More generally, that silent Smaller notes
|
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>
|
Read the whole diff plus What holds up. The watcher, scroll preservation, and the Real problems below, most important first. a. Change detection goes blind on exactly the lines this PR creates
Concretely, at a 40-column pane: Fix is small: give the diff a comparison key that retains the hyperlink target, e.g. in b.
|
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>
|
Reviewed at Overall: solidThe 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:
The Worth fixinga) The The forward-only cursor is what keeps colliding display strings from stacking onto the first occurrence — but the unsafe-URL branch ...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 b) The ellipsis is assumed to be one cell wide.
c) Safe today because open and close are always emitted as a pair inside one line. But One design note, not a blockerIn a terminal that ignores OSC 8, the visible |
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>
|
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. a. Non-ASCII URLs lose their tail with no way to recover it
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. 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 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.
bubbletea's 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 c. Minor:
func visibleWidth(line string) int {
if strings.Contains(line, "\x1b]8") {
line = osc8RE.ReplaceAllString(line, "")
}
return reflowansi.PrintableRuneWidth(line)
}Smaller notes, not blocking
|
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>
…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>
* 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>
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.
visibleWidthandstripANSIwere 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
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.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
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) andTestOSC8IsInvisible(the hyperlink escape never counts towardvisibleWidthor survivesstripANSI).linkify_test.gocoveringtruncateBareURLs(short URLs left alone, inline URLs left alone, long ones shortened to fit) andfindPlainRange(ANSI-aware substring location, including the no-match case).go test ./...andgo vet ./...both clean.Closes #15
🤖 Generated with Claude Code