Skip to content

feat(tui): lem TUI on .ctml — compositor-rendered screens#12

Merged
Snider merged 28 commits into
devfrom
feat/tui
Jul 22, 2026
Merged

feat(tui): lem TUI on .ctml — compositor-rendered screens#12
Snider merged 28 commits into
devfrom
feat/tui

Conversation

@Snider

@Snider Snider commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

The lem workspace TUI's rendering moves from hand-composed lipgloss strings to .ctml markup rendered by go-html's terminal compositor (dappco.re/go/html v0.11.0) — screens become declarative markup a reader can see whole; behaviour, state, and keyboard handling stay untouched. One slice per commit-set, old render path deleted in the same commit as its replacement, tui test suite green at every step.

Landed pre-flip (already in dev/main): slice 1 — tab strip on .ctml with per-tab mouse switching through the box map + teabox (first mouse support in the TUI), palette mapped onto TermTheme classes.

This PR collects, in order:

  • slice 2 — settings form (row-binding idiom lands; scope finding: preferences.go is the preference STORE, no render path existed; the settings screen is currently unwired — pre-existing, flagged)

  • slice 3 — leaf widgets: picker + tools (markdown.go = the Glamour render cache, nothing to convert; and <raw> cannot carry ANSI — transcript chrome composes around Glamour, noted for slice 6)

  • slice 4 — overlays: 8 converted on two layout idioms (full HCF; HF band frames split at the H slot's recorded box height, Bubbles widgets between bands); agentcap.go = the capability model, not a view

  • slice 5 — scope truth: records/migrations/datasetmigrations are DB tables (ORM schemas + DuckDB migration runners), zero rendering; the lane applied the go-html v0.12.0 acceptance across slices 1-4 instead (17 interpolation contortions removed, tools scalar on Values, renders byte-identical)

  • slice 6 — datapanel + inspector on ctml (first Verbatim use: Glamour ANSI bodies byte-exact); export.go = the session exporter writing Markdown/JSON files, no render path (fifth store-shaped strike); retirement pass: before/active/after sequence splits die repo-wide for v0.13.0 row-scoped class binds, tabs rebuilt on FitSlots native slot boxes, the tab-strip box bridge deleted

  • slice 7 — app shell H/F bands on shell.ctml (verbatim header/footer; app.go untouched — View already delegated); no Responsive (all three breakpoints share the HF shape, chooseLayout owns the axis); the REGION stays host-composed pending go-html round 4 (middle-band slots hardcode chrome that word-wraps pre-fitted verbatim ANSI — probed; and verbatim resolves parse-time, blocking a declarative transcript); pin bumped v0.13.1

  • slice 8 — command palette on palette.ctml (SubcommandList adjudicated not-fit: it walks a core.Core Command registry, unrelated to the palette catalogue — a registry bridge is not a rendering slice); old View had zero test coverage, Good/Bad/Ugly added (419); pin bumped v0.14.0

  • slice 7b — region on shellwide.ctml (HCRF) / shellregion.ctml (HCF) with zero-chrome themes; transcript = each + row-scoped verbatim (Builder loop deleted); two doctrine-tied deltas (wide inspector 28 not 32 — fixed unexported termAsideWidth, pinned; separator rule glyph → blank gutter — no theme hook) with round-5 frictions filed; Overlay+inspector stays host-composed (no HLCRF mid-page pair); 420 tests

  • settings wired — F2 opens the Settings overlay (F1-help precedent, restores the original tab-era first-class path); edits the same a.cfg + preferenceStore the inspector uses

Parked by design: the shared ui/ dual-target (HTML+console) idea — a working TUI first; renderer portability stays an inherited property, not a structure.

Gate note: the CI pr gate covers lint + host tests; the metal_runtime suite runs only on the Mac and is verified locally before merge (the declared flip-time constraint).

🤖 Generated with Claude Code
Co-Authored-By: Virgil virgil@lethean.io

Snider and others added 3 commits July 22, 2026 11:26
The lem TUI rendering moves from hand-composed lipgloss to .ctml markup
rendered by go-html's terminal compositor (dappco.re/go/html v0.11.0),
slice by slice, behaviour unchanged. Slice 1 (tab strip + teabox mouse)
landed on dev pre-flip; slices 2-8 land here, one PR per the post-v1
PR-gated workflow.

Co-Authored-By: Virgil <virgil@lethean.io>
Slice 2 of the .ctml migration: the Settings screen's hand-composed
lipgloss view is replaced by embedded settings.ctml parsed with
ctml.Parse and rendered by go-html's terminal renderer. One <dl> per
knob row gives the form shape natively (dt value line, dd hint, blank
separator); rows flow through the rowsBefore/rowsActive/rowsAfter
sequence split around the cursor, copying the tab strip's idiom for
static class attributes; settingsFormTheme maps the class tokens onto
the existing uiStyles palette. Selection gutters become marker glyphs
(active >, idle o) because the parser drops whitespace-only runs, so a
two-space gutter cannot be expressed in markup. State, cursor movement,
and adjust logic are untouched; the old lipgloss composition is deleted
in the same change.

preferences.go turns out to be the preference STORE (config
load/set/commit) with no rendering of its own — the preference-editing
UI is the inspector — so there is no preferences render path to
migrate; the README's new 'Form screens' note records that and the
row-binding idiom slices 3-6 copy.

Gates (cli/): go vet clean, go build clean,
go test -count=1 ./tui/ 377 passed.

Co-Authored-By: Virgil <virgil@lethean.io>
The form-row idiom lands: one dl per knob, selection carried by the
before/active/after sequence split, palette via theme classes. Scope
finding recorded: preferences.go is the preference STORE (no render path
existed to migrate); the settings screen itself is currently unwired
(zero production callers, pre-existing). 377 tui tests green. Three new
go-html friction items filed in the design doc for upstream.

Co-Authored-By: Virgil <virgil@lethean.io>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 66b3e65d-b1de-442f-948b-c85ac7afeea6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Snider and others added 25 commits July 22, 2026 11:46
Slice 3 of the .ctml migration — the leaf widgets.

picker.go + picker.ctml: the Models panel renders from markup while ALL
state stays in the Bubbles list.Model (items, cursor, fuzzy filter,
pagination through a.picker.Update — behaviour untouched). Bindings
derive from the model's own state: the current page of visible items
split rowsBefore/rowsActive/rowsAfter around the cursor, plus
zero-or-one-row sequences for the typed filter line, the "No items."
empty state, and the "page x/y" position. Rows are the settings <dl>
shape (marker + name, indented type/path hint) at the same three-cell
density the default delegate paginates by, with host-side ansi.Truncate
because <dt> lines never wrap. The old lipgloss path — the delegate
render behind a.picker.View() and the Styles.Title paint — is deleted;
newPicker() drops its styles parameter.

tools.go + tools.ctml: the Tools tab view converts with two new idioms:
adjacent single-line rows ride ONE <p> with a <br> closing each <each>
row (separate blocks would gain blank separators), and the conditional
"recent calls" section is an <each> over a zero-or-one-row sequence.
The name-description gutter travels in the bound value because
whitespace-only source runs drop. toolState and its keyboard seams
(setEnabled/toggle via the inspector) are untouched; the old
core.Builder composition is deleted.

markdown.go turns out to have no ctml-shaped rendering to convert: it
is the Glamour render CACHE (per-width renderers, hashed turn results,
stream refresh plumbing) — a store in the preferences.go sense, not a
view. Its pre-styled ANSI output cannot ride a .ctml document at all —
ANSI escapes are invalid XML characters, <raw> content is static
(bindings stay literal inside it), and termRawContent StripTags-es any
content carrying '<' — so the future transcript slice must compose
Glamour output around ctml chrome. Left untouched; README records the
finding.

Gates (cli/): go vet clean, go build clean,
go test -count=1 ./tui/ 383 passed (377 at base + 6 new).

Co-Authored-By: Virgil <virgil@lethean.io>
Leaf widgets land with two new idioms (single-line rows via <p>+<br> per
each-row; conditional sections as zero-or-one-row sequences) and the
picker's state-stays-in-Bubbles shape (list.Model keeps cursor/filter/
pagination; bindings derive the page). markdown.go honestly not converted
— it is the Glamour render cache, nothing ctml-shaped. 383 tui tests
green. Key upstream finding: <raw> cannot carry pre-styled ANSI at all —
the transcript slice must compose Glamour output around ctml chrome, or
go-html needs a true verbatim block node.

Co-Authored-By: Virgil <virgil@lethean.io>
Slice 4 of the .ctml migration: the nine overlays in dataoverlay.go and
agentoverlay.go swap their hand-composed Lip Gloss strings for
<layout>/HLCRF markup, exercising ctml.ParseLayout. Two idioms, chosen
per overlay and noted in each .ctml header:

- All-text overlays (databulk, launchreview read-only) render as full
  <layout variant="HCF"> in one RenderTerm call.
- Widget-carrying overlays (datanote, datafilter, dataeditor,
  workeditor, agentanswer, changereview, agentselect) render as
  <layout variant="HF">: renderOverlayFrame splits the render at the H
  slot's recorded box height — the renderer's own receipt — and the
  host composes the live Bubbles widgets (whose ANSI cannot ride a
  .ctml document) between the bands. Captions trapped between two
  widgets stay host-side; a .ctml document renders contiguously.

State, keyboard, and open/close logic untouched; alternate texts
(armed prompts, create/edit titles, the acknowledge/apply gate) ride
zero-or-one-row sequence splits; receipt bodies bind one row per line
with <br>, blank lines surviving as empty rows. overlayFrameTheme maps
the overlay classes onto the existing uiStyles palette and owns the
footer band's blank spacing row.

agentcap.go is the agent capability model, not a view — nothing to
migrate; recorded in the README with the overlay idiom.

Gates: cli go vet clean, go build clean, go test ./tui/ 413 passed
(383 at base + 30 new Good/Bad/Ugly render + frame tests).

Co-Authored-By: Virgil <virgil@lethean.io>
Eight rendering overlays land on two layout idioms: full HCF for all-text
overlays, HF band frames for widget-carrying ones — the frame renders via
Layout.RenderTermBoxes and splits at the H slot's recorded box height (the
renderer's own receipt), with Bubbles widgets composed between the bands.
agentcap.go honestly untouched: it is the capability model, not a view.
413 tui tests green (+30).

Co-Authored-By: Virgil <virgil@lethean.io>
…, Verbatim

The four friction fixes from the migration land upstream: dt wraps to the
render width; Bindings.Values gives scalar bindings (path misses render
empty, row scope still wins); mixed text+bind runs interpolate; and the
Verbatim node passes pre-styled ANSI through the term renderer byte-exact
via the verbatim element — the transcript unlock for slice 6.

Co-Authored-By: Virgil <virgil@lethean.io>
… not views

Slice 5 of the .ctml migration inspected its named files first and found
all three are tables in the database sense only — records.go (ORM record
schemas), migrations.go (lem.duckdb migration runner),
datasetmigrations.go (datasets.duckdb migration runner). None composes a
screen, so there is no rendering to migrate; README's new "Tables and
stores" note records that plus the decision for a future genuinely
columnar screen (RenderTerm's real <table> path via lipgloss/table vs
the <dl> idiom for label+detail rows).

The slice's live acceptance of the v0.12.0 surface lands on the screens
slices 1-4 converted:

- Mixed text+bind interpolation retires the separate-runs contortion at
  all 17 sites — the unclassed inner <span> isolating a bind from
  adjacent literal text drops from tabs, settings, picker, databulk and
  launchreview ("o <span>{{row.name}}</span>" becomes "o {{row.name}}").
- Bindings.Values retires the one-row-sequence carrier for the tools
  enabled-state line ({{state}} at document scope). The nil-guarded
  one-row sequences (databulk bulk, launchreview review) stay: a Values
  miss renders empty while surrounding literal text remains, so they are
  load-bearing conditionals, not carriers.
- The dt-never-wraps truncation rationale in picker/README is replaced
  with the real one: the page math budgets three cells per row, so a
  wrapped dt would overflow the delegate-sized page.

Render output is unchanged: all 413 tui tests green, no test dropped.

Co-Authored-By: Virgil <virgil@lethean.io>
…eptance applied across slices 1-4 (lane/ctml-tables)

records/migrations/datasetmigrations are ORM schemas and DuckDB migration
runners — zero rendering to migrate (the store-shaped pattern's fourth
strike). The lane instead landed the v0.12.0 acceptance across the
existing markup: 17 interpolation-contortion sites removed, the tools
state line on Bindings.Values, dt-wrap rationale corrected — renders
byte-identical under the pinned tests. Boundary banked: nil-guarded
zero-or-one-row sequences stay the conditional idiom (a Values miss
leaves surrounding literals standing); Values serves always-present
scalars only. 413 tui tests green.

Co-Authored-By: Virgil <virgil@lethean.io>
The Settings form (settings.go + settings.ctml) rendered a working knob
list — cursor, adjust/move, renderSettings — but had zero production
callers: git history shows Settings began as a top-level TAB
(f74b414), was folded into the contextual inspector when the tabs were
reworked, and the standalone form was migrated to .ctml (f7b8e31) as
the row-binding idiom demo yet never re-wired to a screen.

Make it reachable as a modal in the slice-4 overlay layer, opened by
F2. Entry point rationale: F1 (help) is the established precedent — a
modal screen opened by a function key and advertised in the footer — so
F2 (settings) sits beside it, is reliable across terminals (unlike
ctrl+punctuation), and restores a first-class path to the generation
knobs the original tab gave.

Data path is honest — no second source of truth. The overlay edits
a.cfg, the exact settings value the inspector already edits and that
contextLen()/maxTokens()/thinking() read at load and generation time,
through the form's own move/adjust. Ctrl+S commits the three
generation.* knobs (context length, max tokens, thinking) to the SAME
preferenceStore the inspector's Save writes; Esc closes without
reverting the live edit, exactly as closing the inspector does. All
three knobs map to real preference keys, so there are no dead toggles.

F2 is a first-class keyMap binding (footer + F1 full help + README
Keys table). The .ctml footer now advertises the save/close gestures.

Deliberately NOT wired: mouse row-click. Overlays are not mouse-routed
(onMouse defers to route() whenever an overlay is active) and form rows
record no per-row boxes (an <each> row cannot vary its id) — wiring a
click would need new box-derivation and overlay mouse routing, which
the brief scopes out ("do not build new derivation machinery").

Gates (cli/): go vet clean, go build clean,
go test -count=1 ./tui/ 416 passed (413 base + 3). New tests:
TestSettingsOverlay_Good (F2 opens it, renders in the overlay layer, a
knob adjust round-trips through the real store on ctrl+s, esc closes
cleanly) and _Bad (a commit with no store fails loudly and keeps the
overlay open); keymap gains the F2 row.

Co-Authored-By: Virgil <virgil@lethean.io>
The orphan resolves: F2 opens Settings in the slice-4 overlay layer (the
F1-help precedent; git history shows Settings began as a top-level tab
before folding into the inspector). Edits the same a.cfg the inspector
edits; Ctrl+S commits the three generation.* knobs through the SAME
preferenceStore path — no second source of truth, no dead toggles. 416
tui tests green (+3). Mouse row-click deliberately out: overlays are not
mouse-routed and each-rows record no boxes — no derivation machinery.

Co-Authored-By: Virgil <virgil@lethean.io>
…utter/indent doctrine

Round-2 frictions land upstream: {{path}} interpolation in ANY attribute
resolved per each-row at construction (rows carry real classes AND record
distinct boxes — the three-sequence split is obsolete); opt-in
TermOptions.FitSlots packs layout slots to content width (the tab strip's
true fix); whitespace gutters closed as marker-glyph doctrine; the
C-region indent documented as band alignment.

Co-Authored-By: Virgil <virgil@lethean.io>
Slice 6 part A: the two remaining working panels leave hand-composed Lip
Gloss. export.go was inspected and does not render — it is the session
exporter (Markdown/JSON files through coreio), the fifth store-shaped
strike — so it is untouched.

- datalist.ctml: the Data list as an HF band frame — the overlays' chrome+
  widget idiom applied to a primary panel, the live Bubbles filter input
  composed between the bands. Rows are ONE sequence with row-scoped class
  binds (class="{{row.state}}", go-html v0.13.0) instead of the
  before/active/after split; the cursor marker is bound ("› "/"○ ") because
  a row can open the F band, whose leading edge trims a bare gutter; the
  slug truncates to the row budget (band inner width minus the fixed
  segments) so rows stay one line each.
- datadetail.ctml: the detail pane as a plain document. First real use of
  <verbatim value="key"/>: the Glamour-rendered CONTENT and SCORES bodies
  pass through byte-for-byte; both values are always supplied (empty when
  nothing is selected) because a verbatim value must exist at parse time,
  and each carries one trailing newline so the blank before the next
  heading travels in the caller-owned bytes. Review/lineage lines ride
  {s,t,v} triple-span rows; metadata fields, the welfare flag, and the
  no-selection hint are zero-or-more-row sequences.
- inspector.ctml: all four panel-shaped bodies in one document, exactly one
  gated in per render by its zero-or-one-row body sequence. Sections use
  the <p>-per-section + <br>-row idiom so the pane's vertical rhythm is
  unchanged inside its height budget; knob rows are a single sequence with
  row-scoped classes; work/runtime/knowledge/feature-group lines flatten to
  {class, text} rows, each line choosing its own palette class — the group
  headings, feature rows, and inter-group blank separators ride one
  sequence.
- overlayframe.go: renderBandFrame/renderBandLayout are the theme-agnostic
  cores of the HF/HCF seams; renderOverlayFrame/renderOverlayLayout wrap
  them with the overlay theme, and the Data list passes its own band theme
  (plain bands, no footer padding) so the bands join back contiguously.

Render deltas, all block-native geometry: CONTENT/SCORES headings sit as
their own <p> above a <verbatim>, gaining the renderer's paragraph blank
beneath them; long unstyled runs (the key-hint footer, capability reasons)
now wrap at the pane width where fitPane previously clipped them; the
Data list's idle rows carry the ○ marker in place of the two-space gutter
(a gutter cannot open a flushed paragraph); the empty state's leading
blank line is trimmed at the band junction. State and keyboard handling
are untouched; all 416 tui tests pass unchanged.

Co-Authored-By: Virgil <virgil@lethean.io>
Slice 6 part B: the landed .ctml markup moves onto go-html v0.13.0's
row-scoped attribute binds, and the first slice's ANSI-segment derivation
dies.

- tabs: the strip is now a <layout variant="LC"> rendered with
  TermOptions.FitSlots — brand and tabs as content-sized slots packing
  edge-to-edge on one row, recording native slot boxes ("L", "C") that
  tile the strip. mergePanelTabBoxes and panelTabBlockID are deleted;
  panelBarHit resolves the C slot through teabox and walks panelBarCells —
  the ONE cell source both the bindings and the hit test read — so the
  render and the resolution cannot disagree, and inter-tab gaps stay
  non-hits. panelBarTheme keeps the fit geometry's fixed 4-column L-slot
  chrome one row tall (space-glyph left/right border), verified
  column-exact against the live render; recorded boxes are clamped to the
  fitted width, matching fitLine's truncation. Tabs ride one sequence
  with class="{{tab.class}}"; the tabsBefore/Active/After split is gone.
- settings + picker: rowsBefore/Active/After collapse to ONE sequence per
  screen — selection styling rides class="{{row.state}}" with the marker
  glyph bound per row — and rows gain real box ids
  (id="knob-{{row.name}}", id="model-{{row.id}}" keyed by the model path
  discovery de-duplicates on): inert under RenderTerm, recorded under a
  box-recording render, author-owned uniqueness.
- The zero-or-one-row splits in the overlays are NOT the retired idiom
  and stay: they select between alternate static texts, which are markup
  copy (i18n keys), not a per-row style variation.
- README: the .ctml section now teaches the single-sequence + row-scoped
  bind idioms, the FitSlots strip, the panel band frames, the <verbatim>
  contract, and adds export.go to the store-shaped roster.

Test updates, each because the derived per-tab boxes no longer exist:
TestRenderPanelBarBoxes_Good/_Ugly now assert the native slot boxes and
per-tab hit spans (panelTabSpan mirrors the production cell walk);
TestPanelBarHit_* pass the active/kind cell inputs; app_test's tabClick
derives its click point from the tabs slot + cell walk. Strip render
delta, slot chrome only: one leading border column and a wider
brand-to-first-tab gap. All 416 tui tests pass.

Co-Authored-By: Virgil <virgil@lethean.io>
…ridge retired (lane/ctml-panels)

Slice 6 of the .ctml TUI migration. datapanel/inspector render through
datalist.ctml, datadetail.ctml, inspector.ctml — first Verbatim use
(Glamour ANSI bodies byte-exact). Repo-wide retirement: before/active/after
sequence splits die in favour of v0.13.0 row-scoped class binds;
tabs rebuilt on FitSlots native slot boxes, mergePanelTabBoxes deleted.
export.go adjudicated store-shaped (fifth strike) — untouched.

Gates: go vet clean, go build clean, go test ./tui/ 416 passed.

Co-Authored-By: Virgil <virgil@lethean.io>
renderFrame's header (tab strip + session strip) and footer (status +
key hints) now ride a `<layout variant="HF">` (shell.ctml) via
renderBandFrame — the same HF band-split idiom overlays and the Data
list's live filter input already use — instead of a hand-built
JoinVertical. Both bands arrive as pre-rendered, pre-fitted verbatim
values, byte-identical to the prior composition (confirmed by the full
existing ./tui suite, unchanged: 416 passed, 3 pre-existing
environment-gated skips, 0 failed).

The region (main panel + wide/toggled inspector) and the chat
transcript stay hand-composed: go-html's Content slot (and Sidebar/
Aside) hardcode width offsets with no theme override, so a full-width
verbatim placed there is corrupted (word-wrapped mid-line); a
per-turn transcript verbatim is separately blocked because <verbatim>
resolves its value once at parse time, not per <each> row. Both
findings, and the resulting scope, are documented in shell.ctml and
README.md's new "The app shell" section.

Co-Authored-By: Virgil <virgil@lethean.io>
Slice 7. shell.ctml (HF layout, verbatim header/footer bands) rendered
through renderBandFrame in layout.go; app.go untouched (View already
delegated). Region stays host-composed: go-html's middle-band slots
(C/L/R) hardcode chrome offsets that word-wrap pre-fitted verbatim ANSI
onto spurious rows — probed, filed as friction round 4 with the
parse-time-verbatim limit that blocks a declarative transcript.
No Responsive wrapper: all three breakpoints share the HF shape.

Gates: vet+build clean, 416 tui tests, zero edits, byte-identical.

Co-Authored-By: Virgil <virgil@lethean.io>
…idth contract, §15.4 gutter asymmetry

Co-Authored-By: Virgil <virgil@lethean.io>
Converts commandPalette.View from raw list.Model.View() to
palette.ctml: an HF band (renderBandFrame) with a fixed "Commands"
header, row-scoped class binds for selection (picker.ctml's <dl>
idiom), and the live Bubbles FilterInput composed between the bands
while filtering — datalist.ctml's own borrowing of the overlay
widget-carrying idiom, lent back to an actual overlay.

go-html's ctml.SubcommandList (docs/ctml.md S:16) was checked and does
not fit: it walks a *core.Core CommandAction registry and renders a
bare <ul> of I18nKey() labels, with no selection state, filtering, or
availability/reason — none of which workspaceCommand has any
relationship to core.Command. The palette instead composes the
existing generic row-list idiom.

Dead list.Model.Styles.Title assignments (newCommandPalette,
rebuildTheme) are removed now that ctml owns the palette's paint via
commandPaletteTheme, matching picker.go's precedent of never setting
that field once a screen renders through go-html.

Adds TestRenderCommandPalette_Good/_Bad/_Ugly (419 passed, was 416) —
the palette's rendered output had no prior test coverage at all.
README.md gains a matching ".ctml" subsection.
Slice 8. palette.ctml (HF bands, dl rows with row-scoped state class,
zero-or-one empty/page sequences, host-composed live FilterInput
between bands); Bubbles list rendering deleted. SubcommandList
adjudicated not-fit: it walks a core.Core Command registry — the
palette's workspaceCommand catalogue has no relationship to it; a
registry bridge is not a rendering slice. Old View had zero test
coverage; Good/Bad/Ugly added.

Gates: vet+build clean, 419 tui tests (floor 416, +3).

Co-Authored-By: Virgil <virgil@lethean.io>
…batim

Co-Authored-By: Virgil <virgil@lethean.io>
Slice 7b, unblocked by go-html v0.14.0's two levers: a themeable Content
slot (TermTheme.Content, matching the pre-existing themeable Aside) and
row-scoped verbatim (<verbatim value="{{row.field}}"/> inside <each>).

Region (layout.go, renderFrame): shellwide.ctml (<layout variant="HCRF">)
binds the main panel to C and the inspector to R for the Wide layout kind,
one RenderTerm call (renderBandLayout) replacing lipgloss.JoinHorizontal
plus a manual "|" separator column. shellregion.ctml (<layout
variant="HCF">) does the same for every single-pane shape (Narrow either
way; Overlay with the inspector closed), replacing a bare fitPane call.
shell.ctml narrows to the ONE shape that still cannot join them: Overlay
with the inspector open, stacking two independently-sized panes vertically
with a rule between -- HLCRF's five-letter vocabulary has no pair for that
(H/F are whole-page bands, not a reusable mid-page pair), so
renderInspectorStack keeps the pre-.ctml host composition for that one case
only. This is the friction this slice reports rather than works around.

Width delta: go-html's non-FitSlots middle band gives R a FIXED 28-column
budget whenever present (termAsideWidth, unexported, ctml.md S:15.1) -- no
TermOptions/Layout lever requests a different width, confirmed by reading
term_layout.go and a throwaway probe. The Wide inspector is now 28 columns,
not 32; the main panel gains the 4 columns back. regionAsideWidth mirrors
the fixed budget as a documented local constant (go-html exports no
accessor, S:15.5); TestRegionAsideWidth_MatchesGoHTML pins it against a
live RenderTermBoxes call so upstream drift fails loudly rather than
silently reflowing the frame. The old visible "|" rule is also gone: a
zero-chrome C does not collapse the C/R junction, go-html always inserts
its own blank single-space gutter before R, and there is no theme lever to
paint a glyph into it.

Transcript (app.go, transcript.ctml): the hand-grown core.Builder
accumulation loop becomes a zero-or-one-row "notice" sequence plus one row
per turn, each row's verbatim "body" the turn's complete pre-formatted
output (renderTranscriptTurn). What did NOT convert: a turn's STRUCTURE
(inline "you "+text for user, label+result for tool, optional thinking
line + label + Glamour/streaming body + tool calls + spinner for
assistant) stays host-decided, because <switch>/<if>/<unless> resolve
against Context.Data once per render (ctml.md S:7) -- the same context for
every row an <each> produces, not a per-row scope. A row can vary its
VALUES, never its STRUCTURE; this is the documented closed-vocabulary
boundary (S:1.1), not a workaround.

Mouse hit-testing needed no re-plumbing: no region content resolves a
mouse coordinate against its own composition today, so there was no "old
composition maths" to move onto boxes.

Test floor: 419 -> 420 (TestRegionAsideWidth_MatchesGoHTML added; no
existing test dropped or weakened -- TestWorkspaceFrame_Good/_Bad/_Ugly
and every Transcript* test pass unchanged).

Co-Authored-By: Virgil <virgil@lethean.io>
Slice 7b on go-html v0.14.0's two levers. Region renders through
shellwide.ctml (HCRF, wide) / shellregion.ctml (HCF, single-pane) with
zero-chrome themes; the chat transcript is an each + row-scoped
verbatim (transcript.ctml) — the Builder accumulation loop deleted,
per-turn role branching factored host-side unchanged. Overlay-with-
inspector-open stays host-composed (HLCRF has no mid-page pair —
friction filed). Two doctrine-tied deltas: wide inspector 28 not 32
(fixed unexported termAsideWidth, pinned by
TestRegionAsideWidth_MatchesGoHTML), separator rule glyph → go-html's
own blank gutter (no theme hook). Round 5 items filed for both.

Gates: vet+build clean, 420 tui tests (floor 419, +1).

Co-Authored-By: Virgil <virgil@lethean.io>
…l pane pairs

Co-Authored-By: Virgil <virgil@lethean.io>
…pair idiom (slice 7c)

Three shipped-vs-historic deltas from slice 7, all unblocked by go-html
v0.15.0:

- The Wide inspector requests its historic 32 columns again via
  TermOptions.AsideWidth (renderWideLayout, layout.go) instead of go-html's
  unrequested 28-column default; wideInspectorWidth restores the pre-.ctml
  constant name and value, and the main panel gets its 4 columns back.
- shellWideTheme sets TermTheme.GutterRule = "│" (Rule repointed at
  styles.separator's own colour) so the C/R junction gutter go-html always
  reserves paints the historic visible rule again instead of a blank gap.
- renderInspectorStack (the Overlay-with-inspector-open region: 80-119
  cols) no longer hand-stacks lipgloss.JoinVertical plus a manually
  core.Repeat("─", ...) rule line. docs/ctml.md S:15.7 documents a mid-page
  vertical pane pair as an ordinary "HC" layout — H the upper pane with its
  own bottom border as the divider, C the lower pane — since only the
  middle band's L/C/R packing is width-gated; H/middle/F stack vertically
  at any width. shellinspectorpair.ctml + inspectorPairTheme render that
  pair through its own RenderTerm call.

That pair render stays a SEPARATE call from shell.ctml's H/F band rather
than nesting fully into one page-wide call, and this is the one friction
beyond S:15.7's worked example: TermTheme is one flat struct threaded
through every nested Layout in a single render (term_layout.go's
termRenderer carries exactly one theme field), so a unified call would
force the page header's H (needing zero chrome, pre-fitted byte-exact like
every sibling shell) and the pair's own H (needing its natural bordered
chrome, since that border IS the divider) to share one Header style. A
throwaway probe during this slice confirmed it: zeroing Header for the
page header silently zeroed the pair's divider too. shellinspectorpair.ctml
keeps its own theme instead, and shell.ctml's H/F split stays exactly as
slice 7 left it — host-joined by renderFrame, not part of the shipped
diff's probe.

TestRegionAsideWidth_MatchesGoHTML is reframed as
TestWideInspectorWidth_MatchesRequest: since AsideWidth is a request, not
an upstream constant to mirror, the test now renders the real
shellwide.ctml path with AsideWidth set and reads the honoured width back
from the live box map (S:15.5's "box map is the render-time source of
truth"), rather than asserting a hardcoded upstream default.

No other render deltas: go vet, go build, and go test -count=1 ./tui/ all
pass at the 420-test floor with zero test count change (one test renamed
and reframed in place, none added or removed).

Co-Authored-By: Virgil <virgil@lethean.io>
…e/ctml-restore)

Slice 7c. AsideWidth request restores the wide inspector to 32
(pin test reframed to read the honoured request from the box map);
GutterRule │ in the historic styles.separator colour;
renderInspectorStack rides shellinspectorpair.ctml (HC — H's bottom
border IS the divider), the JoinVertical hand-stack deleted. One
friction proven two ways and reported: nested layouts share ONE
renderer theme (no per-instance override) — the pair renders
standalone with its own theme; filed for a future round if a real
screen needs a unified call.

Gates: vet+build clean, 420 tui tests, floor exact.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
@Snider
Snider marked this pull request as ready for review July 22, 2026 15:48
@sonarqubecloud

Copy link
Copy Markdown

@Snider
Snider merged commit b64060c into dev Jul 22, 2026
5 of 6 checks passed
@Snider
Snider deleted the feat/tui branch July 22, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant