ADR-0072: Self-contained wrapped rows — one SGR tracker instead of per-feature patches¶
Date: 2026-10-20
Status¶
Accepted
Context¶
A word wrap can split a styled span across rows, and a terminal carries SGR state across the newline — but nothing downstream of the wrap does. A viewport window can start on a continuation row, a per-row tint closes each row with a reset, a selection rebuilds a row from scratch. The symptom that surfaced it: the workflow attach view dims reasoning per streamed chunk, and the first words of every wrapped row rendered white (the row-by-row background tint reset the dim the previous row had left open).
Each earlier symptom had been patched where it appeared: sgrThinkBg/
thinkRowSurface and sgrDiffBg/diffRow carried background state between
rows for the two full-row painters; leadingANSI, trailingOpenANSI and a
"last code" carry in applyInlineMarkdown and splitTurns carried dim across
newlines (a single code cannot represent bold and dim); withPanelBackground
re-applied a tint after the literal ESC[0m only; the colorize-cache seams
force-appended resets; selection stripped every escape from selected rows; a
local stripANSI only understood sequences ending in m. About fifteen
trackers, each a partial model of the same state.
Decision¶
Make rows self-contained once, at the wrap, and let everything downstream treat rows independently.
internal/sgris the single SGR model:State(bold/dim/italic/ underline/blink/reverse/hidden/strike + fg + bg, in every colour form),Walk/Scan, and the primitives built on it —SelfContain(re-open the inherited state at the start of each row, close at its end; idempotent),WithBackground(re-applies a tint after anything that clears the background, includingESC[mand0;…),LastBackground(which of a set of backgrounds a row shows) andHighlight(mark a visible-rune range while keeping the row's own styling).wrapRows(cmd/milk/wraprows.go) isansi.Wrap+SelfContain; every wrap of styled transcript content goes through it (transcript, colorize cache, streaming suffix, attach view). The cache therefore stores balanced rows and the fast-path seam needs no forced reset.- The think and diff painters become per-row (
LastBackground→ pad →WithBackground/close-then-reopen); there is no carried state and no "open at row end" branch. - Selection (transcript, panels, attach) and the input selection use
Highlightinstead of strip-and-rebuild, so selected rows keep their colours and surfaces; the four copies of the range arithmetic collapse intoorderedSelection/selectionSpan. applyInlineMarkdownandsplitTurnscarry ansgr.Stateinstead of the last escape code. A differential run of old vs. new over ~20k generated inputs showed the only behavioural changes are edge cases the old code got wrong: a leadingESC[0mtreated as an outer context, text re-dimmed after a source line's own reset, and multi-attribute contexts reduced to their last code.colorizeandthinkSurfaceLinessharepaintLines; the localstripANSIis gone in favour ofansi.Strip.
Consequences¶
- A scrolled viewport that starts mid-line, a tinted row, a selected row and a truncated row all see the same self-contained text — fixing the dim/colour loss on continuation rows that the attach view, the transcript and the fold header's wrap all shared.
- New code that wraps styled text calls
wrapRows, and needs no state tracking of its own; the unit of correctness is "every row is balanced". - Attributes
sgr.Statedoes not model (overline, underline colour) are not re-opened on continuation rows; nothing in milk emits them. internal/ansi(TTY-gatedColorize/Dimfor packages belowcmd/milk) is deliberately unchanged.