Adr
Architecture decisions
The ten ADRs, verbatim — what was decided, what was rejected, and why.
Architecture Decision Records
TermMosaic’s architecture decisions are recorded here as ADRs. Each is a dated document that states what was decided, what was rejected, and why.
An ADR is not deleted when it stops being true. If a decision is reversed, the original stays and a new one supersedes it, so the reasoning history survives. That is why ADR 0008 is 60 KB and ADR 0005 is 50 KB: they carry the evidence, including the parts that did not work out.
These are verbatim
The ten pages below are the framework’s own files, copied byte for byte.
They are not summaries, because a summary is a second source of truth that drifts
— and a decision record that disagrees with itself is worse than none. The only
edit is the removal of each file’s own # Title heading, since the site renders
the title from front matter and would otherwise emit two <h1>s.
One piece of plumbing: sibling .md links inside the ADRs are rewritten to point
at the corresponding page here, by a Hugo link render hook. That is the reason you
can click from ADR 0005 to ADR 0007 and land on a page.
If you want the originals:
docs/adr/.
Index
| # | Title | Status | Date |
|---|---|---|---|
| 0001 | Backend strategy | Accepted | 2026-10-03 |
| 0002 | Cell buffer representation | Accepted | 2026-10-03 |
| 0003 | Renderer mode | Accepted | 2026-10-03 |
| 0004 | Layout engine | Accepted | 2026-10-03 |
| 0005 | Input decoding | Accepted | 2026-10-04 |
| 0006 | Sub-buffer cell access | Accepted | 2026-10-04 |
| 0007 | Responsive screens | Accepted | 2026-10-04 |
| 0008 | Style, theme and text | Accepted | 2026-10-04 |
| 0009 | Commands and keymap | Accepted | 2026-10-05 |
| 0010 | Mouse routing | Accepted | 2026-10-05 |
Decisions at a glance
-
0001 — Backend: pluggable, own the core. Two narrow interfaces (
Terminal,Sink) with a directgolang.org/x/sysimplementation and a headless memory sink. We wrap no terminal library. The evidence that settled it:tcell’s flush costs 280,814 ns/op on a one-row-dirty workload where our two-tier diff costs 7,133 ns/op, andtcell’s headless backend cannot expose the cell buffer that widget tests need. -
0002 — Buffer: AoS with a padding-free 16-byte
Cell. This overturned the previous leaning toward struct-of-arrays. OpenTUI’s SoA row-skip advantage is a Zigmem.eqladvantage and does not transfer to Go: the packed AoS row skip ties with SoA (5,373 vs 5,128 ns/op) and is 4.4× faster when every row is dirty (147.2 vs 636.7 ns/op), because it is one wide memcmp instead of four. A two-tier diff on a 200×60 scene writes ~141× fewer bytes than a full repaint. Byte-wise row comparison additionally requires the compared range to be contiguous, not justCellto be padding-free — see the 2026-10-04 amendment. -
0003 — Renderer: hybrid. A retained widget tree invalidated by rectangle, with widgets describing themselves on demand. No reconciler, no Elm loop. Static chrome is cheap because of the diff, not because of the renderer mode.
-
0004 — Layout: constraint-based, own solver.
Length/Min/Max/Percentage/Ratio/Fill, matching what Bubble Tea users already know.Fillis order-insensitive — a deliberate, tested divergence from tmux’s priority-ordered rule. Flexbox via Yoga was rejected because cgo breaksCGO_ENABLED=0cross-compilation, contradicting the single-static-binary goal. -
0005 — Input: a pure decoder under a resumable driver, in a new
inputpackage.Decode(seq []byte, cfg Config) (Event, int, Status)is pure, so the worst input bug — a sequence split across tworead(2)calls — is a one-line table-driven test rather than a flaky timing test. AParserholds only the unavoidable bytes, and aSourcemerges input and resize into one ordered stream. Scope verdicts: kitty keyboard IN (progressive enhancement, request onlydisambiguate, 100 ms bounded probe); paste IN and always oneEventPastecarrying the whole payload, never a stream; mouse decoding IN (SGR 1006, urxvt 1015, X10) but capture OFF by default because it steals selection and scrollback from the user’s shell; focus decoding IN, reporting OFF by default; IME DEFERRED and scoped out, withEventComposeand aComposepayload field reserved so it is a later feature rather than a rewrite. -
0006 — Cell access: a row accessor, not a flat slice.
Buffer.Cells()is removed. It returns the flat backing slice, whose indexy*Width()+xis silently wrong for a sub-buffer — the same unsoundness ADR 0002 fixed once already, inSubBuffer, left open at a different door. It is replaced byRow(y) []Cell, which is correct on top-level buffers and views alike because the stride never leaves thebufferpackage.RowBytesbecomes a*Buffermethod that panics on a sub-buffer — settling ADR 0002’s v1.0 risk item 1b now rather than at v1.0 — anddiff.Framecarries*buffer.Bufferinstead of[]buffer.Cell, so the “diff only top-level buffers” rule is enforced by a type rather than by a doc comment.Stride()is deliberately not exported. -
0007 — Responsive screens: a budget, not a reflow. There are no size classes and no framework breakpoints — a size class is a lossy function of two numbers and a product decision in the wrong layer, and every threshold a widget needs is a local named constant beside its own
Draw. What the framework shares is the arithmetic:geometry.ClampCount(n, available),geometry.Budget(regions, available)with a four-valuePriorityscale, and an optionaltermmosaic.Minimizableinterface declaring a widget’s smallest meaningful size. Policy stays per widget; safety and arithmetic are shared. TheWidgetinterface is unchanged — the space is already reachable fromBounds(), and adaptation is lazy and self-detecting (a widget re-derives anything size-derived whenBounds()differs), which beats a fourth mandatory method precisely because a widget cannot forget it. Degenerate sizes are a decided contract: no panic, ever; clip, never blank — belowMinSize()a widget draws its minimum layout clipped, and 0×0 is a valid size that writes zero bytes. A resize always repaints the whole screen, becausebuffer.Resizediscards the cells a partial diff would need; and drag-resize coalescing is free, because an app that callsr.Resizeper event and lets the pacer decide when to paint already gets it. Amended 2026-10-04: the rect is not the only thing a size-derived cache depends on. A widget that caches column widths onBounds()and is then handedHeader = truerenders the old layout permanently — nothing will produce a different rect to repair it. SoInvalidate()now also means drop every value the widget has cached, and a widget exposing a setter for anythingDrawreads must invalidate in that setter. -
0008 — Style, theme, and text: one
Stylevalue, oneSpantype, one border vocabulary, and deliberately no theme in v1.buffer.Stylebundles fg/bg/attr and is passed by value — 12 bytes, three registers, no allocation, so it costs exactly what the three loose arguments it replaces cost and keeps ADR 0002’s 0-allocs frame path. It lives inbufferbecausegeometrycannot hold it without an import cycle (bufferimportsgeometry;StyleneedsColour), which is the same cycle-exclusion reasoning that put ADR 0007’s vocabulary in the leaf. Styled text goes throughSpan+Buffer.SetSpans, whose load-bearing rule is that a wide glyph’s continuation cell takes its owning span’s style — a mismatched one never compares equal and flickers that row forever.Wrap/Truncateallocate and are therefore never called fromDraw; they are built on the size-change check ADR 0007 §3 already established. There is no theme in v1: widgets carryStylefields, the framework’s defaults are the terminal’s own colours plus named attribute styles, and a theme is triggered by the first role two widgets must share. Borders are one set of names —BorderPlain/Rounded/Double/Thick/ASCII— with the glyph tables inbufferand oneBlockas the only thing in the catalog that draws one. -
0009 — Commands and keymap: a named action, and a key as one way to reach it. Implemented in v0.6.0; no palette. Read ADR 0009 for the reasoning. The
keymappackage shipped — named commands, a 16-byte comparableChord, and resolution by context specificity (focus, then screen, then global, with no numeric priority) at 0 allocs/op — andWidgetis byte-identical, so nothing you wrote breaks. What has not shipped: theCtrl+Kpalette UI that ADR 0009 §9 deliberately scopes out; the optionalCommandable/Clickableinterfaces, which no catalog widget implements; andRegistry.SetFocus, soDescribe(ScopeFocus)is over-inclusive rather than incomplete. A key the keymap consumes shadows a widget’s ownswitchand the registry cannot report that overlap — it is told a widget’s bounds and published chords, never what itsHandledoes. See Limitations. -
0010 — Mouse routing: widgets hit-test themselves. One sentence — a widget handles a pointer event only if the pointer is inside its
Bounds()— and it covers everyMouseaction, wheel included. The alternative considered and rejected is a framework routing helper (RouteMouse(root, ev) Widget, or an optionalHittableinterface): it is new exported API against aWidgetthat ADR 0007 and ADR 0009 both freeze, it needs a tree walk thatWidgetcannot express because there is noChildren(), and it can only answer “which rect” where a widget can answer “which cell means what”. So the fix is three call sites and one shared helper. The defect was real and not cosmetic:form.Tabs, first inexamples/markets’ focus ring, consumed every wheel notch in the application whether or not the pointer was over it. Two exemptions are stated rather than left implicit: a release ends a drag wherever the pointer is, and a drag continues outsideBoundsonce a press has claimed it.
Still open
- Kitty graphics protocol in v1, or stay text-only? Leaning no. Images undermine the grid-of-cells assumption the whole renderer rests on, and the feature is not in the widget catalog.
- Headless backend: v1 or v0.5? Largely settled by ADR 0001; the remaining sub-question is its assertion surface.
- Windows console support. ADR 0001 commits to owning the terminal layer,
which makes console mode flags our problem. The packaging half is closed —
CGO_ENABLED=0builds are verified forwindowsandlinux/arm64— and the runtime half is a stub that returns a loud error. At v1.0.0 this was narrowed from “risk” to decision: Windows is out of scope for v1.0.0, the supported platforms are Linux and macOS, and the Windows CI test leg was dropped (the cross-compile legs remain). - The cache-poisoning debug mode is built, and it found eight stale caches.
It shipped in v0.5.0 as ADR 0007 §3’s deferred “expensive half”, and
widgets/cacheauditfails the build on a finding. What it does not cover is every exported raw field — the gate covers the transitions it names, and several same-shaped fields are not named by any. - The
widgets/widgettestcompatibility question is no longer open. It moved undecided → DECIDED on 2026-10-06: the package is excluded from the v1.0.0 stability promise, because a test harness must evolve with the framework. It was undecided at release time because v1.0.0 shipped without excluding it, which froze the public package by default; PR #28 records the exclusion in the framework’sdocs/STATUS.mdandCHANGELOG.md. An earlier entry on this page listed the question as open; it is recorded here as decided rather than erased. - The colour model is no longer open. It moved PROPOSED → DECIDED on
measurement at v1.0.0 (2026-10-06): PR #18’s CIEDE2000 audit found the
redmean weights inert and PR #19 replaced the quantiser through the
buffer.Quantiserhook. An earlier entry on this page recorded thatdocs/adr/README.md’s open list disagreed withdocs/STATUS.mdon this — the framework’s README has since been corrected, and both now record the model as decided. The record of what was wrong is kept in the audit’s own source rather than erased.
Full detail is in
docs/STATUS.md.
Pages in this section
- Backend strategy Own the terminal layer behind two narrow interfaces, and wrap no terminal library.
- Cell buffer representation AoS with a padding-free 16-byte Cell — overturning the prior leaning toward struct-of-arrays.
- Renderer mode Hybrid: a retained tree invalidated by rectangle, with no reconciler.
- Layout engine Constraint-based, own solver, pure Go. Fill is order-insensitive.
- Input decoding A pure decoder under a resumable driver, in a new input package.
- Sub-buffer cell access Row(y) replaces Cells(); RowBytes is a *Buffer method that panics on a view.
- Responsive screens A budget, not a reflow. No breakpoints, no size classes.
- Style, theme and text One Style value, one Span type, one border vocabulary, and no theme in v1.