Skip to content

Commit 3ce6ba6

Browse files
committed
merge: shell region + declarative transcript on ctml (lane/ctml-region)
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>
2 parents 8c76486 + fdf3c9b commit 3ce6ba6

8 files changed

Lines changed: 484 additions & 166 deletions

File tree

cli/tui/README.md

Lines changed: 72 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -249,56 +249,78 @@ settings and the picker established.
249249

250250
### The app shell
251251

252-
`layout.go` (`renderFrame` + `shell.ctml`) closes the migration at the
253-
outermost layer: the permanent frame every primary panel renders inside
254-
— tab strip, session strip, the active panel/overlay body, footer key
255-
hints, all inside one rounded border.
256-
257-
- **A `<layout variant="HF">` band-split, not a full HLCRF page.** The
258-
header (tab strip + session strip, already joined and each already
259-
fitted to the frame's inner width) and the footer (status + key hints,
260-
likewise pre-fitted) ride `<verbatim>` in the H and F slots;
261-
`renderBandFrame` (the same HF idiom overlays and the Data list's live
262-
filter input already use) splits the render at the H slot's own
263-
recorded box height, and the host composes the region — the active main
264-
panel, plus the wide/toggled inspector, `renderWorkspaceRegion`
265-
between the two bands, exactly as a widget-carrying overlay composes a
266-
live Bubbles widget between its own bands.
267-
- **The Content slot is a dead end for full-width verbatim.** Unlike
268-
Header/Footer/Sidebar/Aside, `renderTermContent` hardcodes a `(0,1)`
269-
padding with no `TermTheme` override; content already fitted to the
270-
slot's own width arrives one column too wide once that padding lands,
271-
and the slot's own `Width()` enforcement word-wraps the overflow onto a
272-
spurious extra row instead of leaving it byte-exact. `renderTermBox`
273-
(Sidebar/Aside — L and R) carries the same defect through its own
274-
hardcoded `-4`/`-2` offsets. H and F have no such tax — both route
275-
through nothing but the (fully themeable, here stripped-to-blank)
276-
`theme.Header`/`theme.Footer` style — so the region stays host-composed
277-
and rides between the bands rather than through any middle-band slot.
278-
`wideInspectorWidth`, `measureFrame`'s per-`layoutKind` maths, and
279-
`renderWorkspaceRegion`'s wide/compact/narrow arrangements are therefore
280-
unchanged: none of it is a rendering-composition concern .ctml can take
281-
over, since the one slot that could hold it corrupts pre-fitted content,
282-
and the arithmetic itself has nowhere to live in a language with no
283-
operators (docs/ctml.md S:8.4).
284-
- **Mouse hit-testing needed no re-plumbing.** `onMouse` resolves against
285-
its own independent `renderPanelBarBoxes` call, keyed off
286-
`frameInsetRows`/`frameInsetCols` and the tab strip's row-0 position —
287-
both unchanged by this slice, since the tab strip's own render path
288-
(`renderPanelBar`, `tabs.ctml`) is untouched; it now merely arrives at
289-
the shell as a pre-rendered value instead of a `JoinVertical` argument.
290-
- **The chat transcript stays hand-composed.** `renderTranscript`'s
291-
turn-by-turn Glamour composition is a list whose row COUNT is unbounded
292-
and whose per-row body is pre-styled ANSI — exactly the `<each>` +
293-
per-row `<verbatim>` shape a transcript would need — but `<verbatim
294-
value="key">` resolves `key` once against the document-wide
295-
`Bindings.Values` at PARSE time (`ctml/parse.go`'s `parseVerbatim`), not
296-
per row at render time the way a `{{path}}` bind does; every row inside
297-
an `<each>` would render the identical fixed content. Converting the
298-
transcript would need either an unbounded flat list of named verbatim
299-
slots (impractical) or row-scoped verbatim resolution go-html does not
300-
have yet (mirroring how `id="row-{{row.id}}"` already resolves per row
301-
for box identification). Left unconverted, not silently skipped.
252+
`layout.go` (`renderFrame` + `shell.ctml`/`shellregion.ctml`/`shellwide.ctml`)
253+
closes the migration at the outermost layer: the permanent frame every
254+
primary panel renders inside — tab strip, session strip, the active
255+
panel/overlay body, footer key hints, all inside one rounded border.
256+
257+
- **The header/footer bands are a `<layout variant="HF">` band-split** in
258+
all three shell files alike. The header (tab strip + session strip,
259+
already joined and each already fitted to the frame's inner width) and
260+
the footer (status + key hints, likewise pre-fitted) ride `<verbatim>` in
261+
the H and F slots.
262+
- **The region joins the same declarative surface as H/F for every shape
263+
but one**, since go-html v0.14.0 shipped a themeable `Content` style
264+
(`TermTheme.Content`, matching the pre-existing themeable `Aside`) and
265+
row-scoped verbatim: a zero-chrome C (and R) now passes pre-fitted
266+
content through byte-exact at the slot's full width, closing the gap the
267+
previous round left open (docs/ctml.md S:15.2/S:15.5). Wide layouts
268+
(`shellwide.ctml`, `<layout variant="HCRF">`) bind the main panel to C
269+
and the inspector to R, one `RenderTerm` call (`renderBandLayout`) in
270+
place of `lipgloss.JoinHorizontal` and a manual `` separator column.
271+
Every single-pane shape — Narrow either way, Overlay with the inspector
272+
closed — binds the one active pane to C (`shellregion.ctml`,
273+
`<layout variant="HCF">`), replacing a bare `fitPane` call. The ONE shape
274+
that still cannot join them is Overlay with the inspector open: two
275+
independently-sized panes stacked vertically with a rule between, which
276+
HLCRF's five-letter slot vocabulary has no pair for (H/F are whole-page
277+
top/bottom bands, not a reusable mid-page pair, and go-html's own
278+
automatic L/C/R vertical stacking triggers only below 80 columns —
279+
S:15.1 — above this layout kind's own floor). That one shape stays
280+
exactly the pre-.ctml host composition: `shell.ctml`'s H/F-only layout,
281+
split by `renderBandFrame`, with `renderInspectorStack` composing the
282+
region between the bands — the same HF+host-composition idiom a
283+
widget-carrying overlay uses for a live Bubbles widget between its own
284+
bands.
285+
- **The Wide inspector is now 28 columns, not 32.** go-html's non-FitSlots
286+
middle band gives R a fixed outer-width budget (the unexported
287+
`termAsideWidth`, S:15.1) whenever R is present at ≥80 columns — there is
288+
no `TermOptions`/`Layout` lever to request a different width, and a
289+
zero-chrome C does not collapse the C/R junction either: go-html always
290+
inserts its own single blank-space gutter before R, so the old visible
291+
`` rule is gone with it (the gutter is blank; there is no theme lever to
292+
paint a glyph into it). `regionAsideWidth` (`layout.go`) mirrors the
293+
fixed budget as a documented local constant — go-html exports no
294+
accessor for it (S:15.5) — and `TestRegionAsideWidth_MatchesGoHTML` pins
295+
it against a live `RenderTermBoxes` call so upstream drift fails loudly
296+
rather than silently reflowing the frame. The main panel gains the 4
297+
columns back (`measureFrame`'s Wide case); this is a real, visible width
298+
delta, tied entirely to that one doctrine clause.
299+
- **Mouse hit-testing needed no re-plumbing, this slice included.**
300+
`onMouse` resolves against its own independent `renderPanelBarBoxes`
301+
call, keyed off `frameInsetRows`/`frameInsetCols` and the tab strip's
302+
row-0 position — both unchanged since the tab strip's own render path
303+
(`renderPanelBar`, `tabs.ctml`) is untouched. No region content (chat
304+
transcript, Work/Data/Models/Service panels, the inspector) resolves a
305+
mouse coordinate against its own composition today, so there was no
306+
"old composition maths" to re-plumb onto boxes for this slice either.
307+
- **The chat transcript's row LIST is declarative; each row's own
308+
STRUCTURE stays host-decided.** `transcript.ctml` is a zero-or-one-row
309+
"notice" sequence (the session status banner) followed by one row per
310+
turn, each row's verbatim `body` the turn's complete pre-formatted
311+
output — row-scoped verbatim (`<verbatim value="{{turn.body}}"/>`,
312+
materialised per row against the enclosing `<each>`, S:6.5) replaced the
313+
old hand-grown `core.Builder` accumulation loop. What did NOT convert:
314+
a turn's SHAPE (an inline "you "+text line for `user`, a label+result
315+
pair for `tool`, or an optional thinking line + label + Glamour/streaming
316+
body + tool calls + a live spinner for `assistant`) still branches in Go
317+
(`renderTranscriptTurn`), because `<switch>`/`<if>`/`<unless>` compile to
318+
a `func(*Context) bool/string` closure resolved once per RENDER against
319+
`Context.Data` (S:7) — the same context for every row an `<each>`
320+
produces, not a per-row scope the way a `{{path}}` bind is. A row can
321+
vary its VALUES; it cannot vary its STRUCTURE. This is the documented
322+
closed-vocabulary boundary (S:1.1: a named lookup, never an expression),
323+
not a workaround.
302324

303325
### The command palette
304326

cli/tui/app.go

Lines changed: 97 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package tui
44

55
import (
66
"context"
7+
_ "embed"
78
"sync"
89
"time"
910

@@ -16,11 +17,21 @@ import (
1617
"github.com/charmbracelet/lipgloss"
1718

1819
core "dappco.re/go"
20+
"dappco.re/go/html"
21+
"dappco.re/go/html/ctml"
1922
"dappco.re/go/inference"
2023
"dappco.re/go/inference/dataset"
2124
"dappco.re/go/inference/decode/parser"
2225
)
2326

27+
// transcriptCTML is the chat panel's turn-list markup -- see transcript.ctml
28+
// for the row-scoped verbatim idiom it exposes and why per-turn STRUCTURE
29+
// stays host-decided (renderTranscriptTurn) while the row LIST itself is
30+
// declarative.
31+
//
32+
//go:embed transcript.ctml
33+
var transcriptCTML []byte
34+
2435
// turn is one rendered element in the transcript.
2536
type turn struct {
2637
id string
@@ -3051,62 +3062,101 @@ func (a app) transcriptHeight() int {
30513062
return h
30523063
}
30533064

3065+
// renderTranscript renders transcript.ctml: a zero-or-one-row "notice"
3066+
// sequence (the session status banner) followed by one row per turn, each
3067+
// row's verbatim "body" the complete pre-formatted turn (renderTranscriptTurn)
3068+
// -- go-html owns the row iteration and the byte-exact verbatim passthrough,
3069+
// the host still owns every turn's own content (transcript.ctml's header
3070+
// comment has the full account of why). A parse failure never happens
3071+
// against this embedded, static markup outside a build defect (the same
3072+
// assumption renderBandFrame/renderBandLayout make, overlayframe.go) and
3073+
// degrades to an empty transcript rather than panicking.
30543074
func (a app) renderTranscript() string {
3055-
var b core.Builder
3075+
tree, err := ctml.Parse(transcriptCTML, a.transcriptBindings())
3076+
if err != nil {
3077+
return ""
3078+
}
3079+
return lipgloss.NewStyle().Width(a.view.Width).Render(html.RenderTerm(tree, html.NewContext()))
3080+
}
3081+
3082+
// transcriptBindings supplies transcript.ctml's two row sequences. notice is
3083+
// zero-or-one row so an absent banner renders nothing (not an empty line);
3084+
// every turn row's own leadingBlank folds in the "\n" go-html's own
3085+
// block-level join does not supply a second one of, reproducing the
3086+
// original "\n\n" turn gap byte-for-byte (transcript.ctml's header comment).
3087+
func (a app) transcriptBindings() ctml.Bindings {
30563088
notice := a.sessionTranscriptNotice()
3089+
var noticeRows []map[string]any
30573090
if notice != "" {
3058-
b.WriteString(a.styles.attention.Render(notice))
3091+
noticeRows = []map[string]any{{"text": a.styles.attention.Render(notice)}}
30593092
}
3093+
turnRows := make([]map[string]any, 0, len(a.turns))
30603094
for i, t := range a.turns {
3061-
if i > 0 || notice != "" {
3062-
b.WriteString("\n\n")
3063-
}
3064-
switch t.role {
3065-
case "user":
3066-
b.WriteString(a.styles.user.Render("you ") + a.styles.answer.Render(t.text))
3067-
case "tool":
3068-
label := "tool result"
3069-
if len(t.calls) > 0 {
3070-
label = core.Concat("tool · ", t.calls[0])
3071-
}
3072-
b.WriteString(a.styles.thought.Render(label))
3073-
if result := visibleToolResult(t.text); result != "" {
3074-
b.WriteString("\n" + a.styles.answer.Render(result))
3075-
}
3076-
default:
3077-
if t.thought != "" {
3078-
b.WriteString(a.styles.thought.Render("· thinking · "+core.Trim(t.thought)) + "\n")
3079-
}
3080-
label := t.model
3081-
if label == "" {
3082-
label = a.modelName
3083-
}
3084-
if label == "" {
3085-
label = "assistant"
3086-
}
3087-
b.WriteString(a.styles.assistant.Render(label))
3088-
streaming := a.generating && i == len(a.turns)-1
3089-
if t.text != "" {
3090-
b.WriteString("\n")
3091-
if streaming {
3092-
b.WriteString(a.styles.answer.Render(t.text))
3093-
} else {
3094-
turnID := t.id
3095-
if turnID == "" {
3096-
turnID = core.Sprintf("transcript-%d", i)
3097-
}
3098-
b.WriteString(a.markdown.Render(turnID, t.text, max(1, a.view.Width-2)))
3095+
leadingBlank := i > 0 || notice != ""
3096+
turnRows = append(turnRows, map[string]any{"body": a.renderTranscriptTurn(i, t, leadingBlank)})
3097+
}
3098+
return ctml.Bindings{Sequences: map[string][]map[string]any{
3099+
"notice": noticeRows,
3100+
"turns": turnRows,
3101+
}}
3102+
}
3103+
3104+
// renderTranscriptTurn formats exactly one turn -- byte-for-byte what the
3105+
// pre-.ctml renderTranscript loop wrote inline for the same turn, role
3106+
// branching included (transcript.ctml's header comment explains why that
3107+
// branching stays here rather than in markup). leadingBlank prepends the
3108+
// single "\n" this row owes the inter-turn gap; blocks() (go-html) supplies
3109+
// the other half of the "\n\n" via its own row-to-row join.
3110+
func (a app) renderTranscriptTurn(i int, t turn, leadingBlank bool) string {
3111+
var b core.Builder
3112+
if leadingBlank {
3113+
b.WriteString("\n")
3114+
}
3115+
switch t.role {
3116+
case "user":
3117+
b.WriteString(a.styles.user.Render("you ") + a.styles.answer.Render(t.text))
3118+
case "tool":
3119+
label := "tool result"
3120+
if len(t.calls) > 0 {
3121+
label = core.Concat("tool · ", t.calls[0])
3122+
}
3123+
b.WriteString(a.styles.thought.Render(label))
3124+
if result := visibleToolResult(t.text); result != "" {
3125+
b.WriteString("\n" + a.styles.answer.Render(result))
3126+
}
3127+
default:
3128+
if t.thought != "" {
3129+
b.WriteString(a.styles.thought.Render("· thinking · "+core.Trim(t.thought)) + "\n")
3130+
}
3131+
label := t.model
3132+
if label == "" {
3133+
label = a.modelName
3134+
}
3135+
if label == "" {
3136+
label = "assistant"
3137+
}
3138+
b.WriteString(a.styles.assistant.Render(label))
3139+
streaming := a.generating && i == len(a.turns)-1
3140+
if t.text != "" {
3141+
b.WriteString("\n")
3142+
if streaming {
3143+
b.WriteString(a.styles.answer.Render(t.text))
3144+
} else {
3145+
turnID := t.id
3146+
if turnID == "" {
3147+
turnID = core.Sprintf("transcript-%d", i)
30993148
}
3149+
b.WriteString(a.markdown.Render(turnID, t.text, max(1, a.view.Width-2)))
31003150
}
3101-
for _, c := range t.calls {
3102-
b.WriteString("\n" + a.styles.thought.Render("→ "+c))
3103-
}
3104-
if t.text == "" && t.thought == "" && a.generating && i == len(a.turns)-1 {
3105-
b.WriteString(a.styles.thought.Render(a.spin.View() + " …"))
3106-
}
3151+
}
3152+
for _, c := range t.calls {
3153+
b.WriteString("\n" + a.styles.thought.Render("→ "+c))
3154+
}
3155+
if t.text == "" && t.thought == "" && a.generating && i == len(a.turns)-1 {
3156+
b.WriteString(a.styles.thought.Render(a.spin.View() + " …"))
31073157
}
31083158
}
3109-
return lipgloss.NewStyle().Width(a.view.Width).Render(b.String())
3159+
return b.String()
31103160
}
31113161

31123162
func (a app) transcriptTurnOffset(turnID string) int {

0 commit comments

Comments
 (0)