From 966f34be9447bffe3149c782be28db00701f913f Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 11:26:02 +0100 Subject: [PATCH 01/17] feat(tui): open the .ctml migration feature branch 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 From f7b8e319e84b7dbd49af83afc5a186ef1c14d2d2 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 11:27:08 +0100 Subject: [PATCH 02/17] feat(tui): render the Settings form through settings.ctml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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 --- cli/tui/README.md | 28 +++++++++++ cli/tui/settings.ctml | 40 +++++++++++++++ cli/tui/settings.go | 82 +++++++++++++++++++++++++------ cli/tui/settings_test.go | 103 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 cli/tui/settings.ctml create mode 100644 cli/tui/settings_test.go diff --git a/cli/tui/README.md b/cli/tui/README.md index 9a07ae262..c6dc8d5c0 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -86,6 +86,34 @@ by go-html's terminal renderer (`dappco.re/go/html`). The tab strip A left click on a tab switches panels through exactly the same path as `Tab`/`Shift+Tab` (`selectPanel`); the wheel keeps scrolling the transcript. +### Form screens + +The Settings form (`settings.go` + `settings.ctml`) establishes the +row-binding idiom form-shaped screens copy: + +- **One `
` per knob row** — `
` is the value line (marker + name + + `‹ value ›`), `
` the hint, which the terminal renderer draws as + exactly the row shape a form needs: value line, indented hint line, blank + separator. +- **Rows flow through three sequences** (`rowsBefore` / `rowsActive` / + `rowsAfter`) split around the cursor, exactly like the tab strip: class + attributes are static, so selection styling is carried by which sequence + a row lands in (`settingsFormBindings`). +- **Selection gutters are marker glyphs** (`›` active, `○` idle): the + parser drops whitespace-only runs between siblings, so a plain two-space + gutter cannot be expressed in markup. +- **Rows are block-shaped**, so the `.ctml` file indents freely; only the + single-row constructs inside a row (the `
` line, the footer) stay on + one source line. +- **No per-row boxes** — an `` row cannot vary its `id` any more than + its `class`, so form rows record nothing in a box map; a screen that + needs row clicks either enumerates its rows statically or derives boxes + from the render as the tab strip does. + +`preferences.go` is the preference *store* (config load/set/commit), not a +screen — it has no rendering to migrate; the preference-editing UI is the +inspector. + ## Keys | Key | Scope | Action | diff --git a/cli/tui/settings.ctml b/cli/tui/settings.ctml new file mode 100644 index 000000000..5c04b007b --- /dev/null +++ b/cli/tui/settings.ctml @@ -0,0 +1,40 @@ + + +
+

settings

+
+
{{row.name}}{{row.value}}
+
{{row.hint}}
+
+
+
{{row.name}}{{row.value}}
+
{{row.hint}}
+
+
+
{{row.name}}{{row.value}}
+
{{row.hint}}
+
+

↑/↓ select · ←/→ change · values apply as hinted

+
diff --git a/cli/tui/settings.go b/cli/tui/settings.go index 599e7ecba..0dd64c6b7 100644 --- a/cli/tui/settings.go +++ b/cli/tui/settings.go @@ -2,7 +2,15 @@ package tui -import core "dappco.re/go" +import ( + _ "embed" + + "github.com/charmbracelet/lipgloss" + + core "dappco.re/go" + "dappco.re/go/html" + "dappco.re/go/html/ctml" +) // The Settings tab: a cursor list of knobs adjusted with ←/→ (or h/l). Values // apply to the NEXT load (context) or the next turn (the rest) — the honest @@ -103,19 +111,65 @@ func (s settings) move(delta int) settings { return s } -func (s settings) view(width int, styles uiStyles) string { - var b core.Builder - b.WriteString(styles.title.Render("settings") + "\n\n") - for i, row := range s.rows() { - cursor := " " - name := styles.answer.Render(row.name) - if i == s.cursor { - cursor = styles.accent.Render("› ") - name = styles.accent.Render(row.name) +// settingsCTML is the Settings form's markup — see settings.ctml for the +// seams it exposes (row sequences, class tokens, the settings-form block id). +// +//go:embed settings.ctml +var settingsCTML []byte + +// settingsFormBindings splits the knob rows around the cursor. Three +// sequences (before / active / after) because .ctml class attributes are +// static strings — an row cannot vary its own class — so selection +// styling is carried by which sequence a row lands in. A form this size +// re-binds on every change for free. +func settingsFormBindings(form settings) ctml.Bindings { + sequences := map[string][]map[string]any{ + "rowsBefore": {}, + "rowsActive": {}, + "rowsAfter": {}, + } + for index, row := range form.rows() { + entry := map[string]any{"name": row.name, "value": row.value, "hint": row.hint} + switch { + case index < form.cursor: + sequences["rowsBefore"] = append(sequences["rowsBefore"], entry) + case index == form.cursor: + sequences["rowsActive"] = append(sequences["rowsActive"], entry) + default: + sequences["rowsAfter"] = append(sequences["rowsAfter"], entry) } - b.WriteString(cursor + name + " " + styles.title.Render("‹ "+row.value+" ›") + "\n") - b.WriteString(" " + styles.thought.Render(row.hint) + "\n\n") } - b.WriteString(styles.status.Render("↑/↓ select · ←/→ change · values apply as hinted")) - return b.String() + return ctml.Bindings{Sequences: sequences} +} + +// settingsFormTheme maps the markup's class tokens onto the existing palette, +// so the .ctml render reuses uiStyles paint exactly — no colours of its own. +func settingsFormTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Text = styles.answer + theme.Heading = styles.title // the

form title + theme.Classes = map[string]lipgloss.Style{ + "row-idle": styles.answer, + "row-active": styles.accent, + "row-value": styles.title, + "row-hint": styles.thought, + "form-keys": styles.status, + } + return theme +} + +// renderSettings parses settings.ctml with the current row bindings and +// renders it through the go-html terminal renderer: the form title, one +//
row per knob (value line + indented hint), and the key footer. +func renderSettings(form settings, width int, styles uiStyles) string { + if width <= 0 { + return "" + } + tree, err := ctml.Parse(settingsCTML, settingsFormBindings(form)) + if err != nil { + // settings.ctml is embedded and static, so a parse failure is a build + // defect; TestRenderSettings_Good pins the markup as parseable. + return "" + } + return html.RenderTerm(tree, html.NewContext(), html.TermOptions{Width: width, Theme: settingsFormTheme(styles)}) } diff --git a/cli/tui/settings_test.go b/cli/tui/settings_test.go new file mode 100644 index 000000000..30c758c28 --- /dev/null +++ b/cli/tui/settings_test.go @@ -0,0 +1,103 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func TestRenderSettings_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + view := renderSettings(newSettings(), 100, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "settings" { + t.Fatalf("form must open with its title line: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if !strings.Contains(plain, "› context length") { + t.Fatalf("cursor row must carry the active marker: %q", plain) + } + for _, text := range []string{"○ max tokens", "○ thinking"} { + if !strings.Contains(plain, text) { + t.Fatalf("unselected row missing the idle marker %q: %q", text, plain) + } + } + for _, text := range []string{"‹ model default ›", "‹ 4096 ›"} { + if !strings.Contains(plain, text) { + t.Fatalf("form missing the value %q: %q", text, plain) + } + } + for _, text := range []string{"KV cache size", "per-reply budget", "reasoning channel"} { + if !strings.Contains(plain, text) { + t.Fatalf("form missing the hint %q: %q", text, plain) + } + } + if !strings.Contains(plain, "↑/↓ select · ←/→ change · values apply as hinted") { + t.Fatalf("form missing the key footer: %q", plain) + } + + row := -1 + for index, line := range lines { + if strings.Contains(line, "› context length") { + row = index + break + } + } + if row < 0 || row+2 >= len(lines) { + t.Fatalf("cursor row not found in the rendered lines: %q", plain) + } + if !strings.Contains(lines[row+1], "KV cache size") { + t.Fatalf("hint must sit directly beneath its value line: %q", lines[row+1]) + } + if lines[row+2] != "" { + t.Fatalf("rows must be separated by a blank line: %q", lines[row+2]) + } + + for index, line := range lines { + if got := lipgloss.Width(line); got > 100 { + t.Fatalf("line %d width = %d, exceeds 100: %q", index, got, line) + } + } +} + +func TestRenderSettings_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + for _, width := range []int{0, -4} { + if got := renderSettings(newSettings(), width, styles); got != "" { + t.Fatalf("renderSettings(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderSettings_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + form := newSettings().move(-1) // wraps the cursor to the last row + form = form.adjust(1) // thinking: model default → on + view := renderSettings(form, 100, styles) + plain := ansi.Strip(view) + + if !strings.Contains(plain, "› thinking") { + t.Fatalf("wrapped cursor must select the last row: %q", plain) + } + if !strings.Contains(plain, "‹ on ›") { + t.Fatalf("adjusted value must flow through the bindings: %q", plain) + } + if !strings.Contains(plain, "○ context length") || strings.Contains(plain, "› context length") { + t.Fatalf("unselected rows must carry the idle marker: %q", plain) + } + + first := strings.Index(plain, "context length") + second := strings.Index(plain, "max tokens") + third := strings.Index(plain, "thinking") + if first < 0 || second < first || third < second { + t.Fatalf("the sequence split must preserve row order: %q", plain) + } +} From a886ea2cf943267251195d425fd746857a8022ad Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 11:46:38 +0100 Subject: [PATCH 03/17] feat(tui): render the Models picker and Tools tab through .ctml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
shape (marker + name, indented type/path hint) at the same three-cell density the default delegate paginates by, with host-side ansi.Truncate because
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

with a
closing each row (separate blocks would gain blank separators), and the conditional "recent calls" section is an 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, 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 --- cli/tui/README.md | 26 ++++++ cli/tui/app.go | 5 +- cli/tui/picker.ctml | 52 ++++++++++++ cli/tui/picker.go | 115 +++++++++++++++++++++++++-- cli/tui/picker_test.go | 175 +++++++++++++++++++++++++++++++++++++++++ cli/tui/tools.ctml | 34 ++++++++ cli/tui/tools.go | 93 +++++++++++++++++----- cli/tui/tools_test.go | 102 ++++++++++++++++++++++++ 8 files changed, 575 insertions(+), 27 deletions(-) create mode 100644 cli/tui/picker.ctml create mode 100644 cli/tui/picker_test.go create mode 100644 cli/tui/tools.ctml create mode 100644 cli/tui/tools_test.go diff --git a/cli/tui/README.md b/cli/tui/README.md index c6dc8d5c0..a302d4ef2 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -114,6 +114,32 @@ row-binding idiom form-shaped screens copy: screen — it has no rendering to migrate; the preference-editing UI is the inspector. +### Leaf widgets + +The Models panel (`picker.go` + `picker.ctml`) and the Tools tab +(`tools.go` + `tools.ctml`) add the leaf-widget idioms: + +- **A Bubbles-list screen keeps its state in `list.Model`** (items, cursor, + fuzzy filter, pagination through `Update`) and derives its row bindings + from it — the current page split before/active/after; the host truncates + each field to the row budget because `

` lines never wrap. +- **Adjacent single-line rows ride ONE `

` with a `
` closing each + `` row** — separate block elements would gain blank separators. +- **A section that appears only with data is an `` over a + zero-or-one-row sequence** (the same one-row trick a lone dynamic value + uses); an empty sequence renders nothing, heading included. +- **A plain gutter between two bound spans travels in the bound value** + (whitespace-only source runs drop; a gutter with a glyph can stay in + markup as tabs/settings do). + +`markdown.go` is the Glamour render *cache* (per-width renderers, hashed +turn results, stream refresh plumbing), not a view — it composes nothing +and has no rendering to migrate. Its output is pre-styled ANSI text, which +cannot ride a `.ctml` document at all: ANSI escapes are invalid XML +characters, `` content is static (bindings stay literal inside it), +and the terminal renderer re-wraps inline runs. The transcript screen +composes Glamour output *around* ctml-rendered chrome instead. + ## Keys | Key | Scope | Action | diff --git a/cli/tui/app.go b/cli/tui/app.go index edf3e30dc..5f229fb97 100644 --- a/cli/tui/app.go +++ b/cli/tui/app.go @@ -402,7 +402,7 @@ func newApp(modelPath string, ctxLen, maxTokens int) app { help: newHelpOverlay(keys, styles), inspector: newInspector(), agent: agent, - picker: newPicker(styles), + picker: newPicker(), spin: sp, input: in, cfg: cfg, @@ -1174,7 +1174,6 @@ func (a *app) rebuildTheme(selected theme) { } a.styles = newUIStyles(selected) a.markdown = newMarkdownRenderer(selected.name) - a.picker.Styles.Title = a.styles.title if a.palette != nil { a.palette.list.Styles.Title = a.styles.title } @@ -3162,7 +3161,7 @@ func (a app) panelView() string { } switch a.activePanel { case panelModels: - return a.picker.View() + return renderPicker(a.picker, measureFrame(a.width, a.height, a.inspectorOpen).mainWidth, a.styles) case panelService: return a.svc.view(a.modelName, a.width, a.styles) case panelWork: diff --git a/cli/tui/picker.ctml b/cli/tui/picker.ctml new file mode 100644 index 000000000..2503012bb --- /dev/null +++ b/cli/tui/picker.ctml @@ -0,0 +1,52 @@ + + +

+

Models

+

Filter: {{row.value}}

+
+
{{row.name}}
+
{{row.detail}}
+
+
+
{{row.name}}
+
{{row.detail}}
+
+
+
{{row.name}}
+
{{row.detail}}
+
+

{{row.text}}

+

{{row.label}}

+

↑/↓ select · / filter · enter load

+
diff --git a/cli/tui/picker.go b/cli/tui/picker.go index ef2385b5e..d96d757e6 100644 --- a/cli/tui/picker.go +++ b/cli/tui/picker.go @@ -3,12 +3,17 @@ package tui import ( + _ "embed" "sort" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" core "dappco.re/go" + "dappco.re/go/html" + "dappco.re/go/html/ctml" "dappco.re/go/inference" ) @@ -74,13 +79,113 @@ func displayName(path string) string { return core.PathBase(path) } -// newPicker builds the model list with the house dark styling. -func newPicker(styles uiStyles) list.Model { - delegate := list.NewDefaultDelegate() - l := list.New(nil, delegate, 0, 0) +// newPicker builds the model list. The list.Model owns picker STATE only — +// items, cursor, filter, pagination — and its default delegate survives as +// the page-size driver (height 2 + spacing 1, the same three cells a +// picker.ctml row occupies); rendering goes through renderPicker, so the +// list carries no lipgloss styling of its own. +func newPicker() list.Model { + l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l.Title = "Models" l.SetShowStatusBar(false) l.SetFilteringEnabled(true) - l.Styles.Title = styles.title return l } + +// pickerCTML is the Models panel's markup — see picker.ctml for the seams it +// exposes (row/filter/empty/page sequences, class tokens, the model-picker +// block id). +// +//go:embed picker.ctml +var pickerCTML []byte + +// modelPickerBindings derives the panel's rows from the list model's own +// state: the current page of visible items split around the cursor +// (before / active / after, because .ctml class attributes are static +// strings — an row cannot vary its own class), plus the +// zero-or-one-row conditional sections for the typed filter, the empty +// state, and the page position. Name and hint are truncated host-side to +// the row budget because
lines never wrap. +func modelPickerBindings(picker list.Model, width int) ctml.Bindings { + sequences := map[string][]map[string]any{ + "filter": {}, + "rowsBefore": {}, + "rowsActive": {}, + "rowsAfter": {}, + "empty": {}, + "page": {}, + } + if picker.FilterState() == list.Filtering { + sequences["filter"] = append(sequences["filter"], map[string]any{"value": picker.FilterValue()}) + } + visible := picker.VisibleItems() + start, end := picker.Paginator.GetSliceBounds(len(visible)) + active := picker.Index() - start + budget := max(1, width-2) + for index, item := range visible[start:end] { + entry, ok := item.(list.DefaultItem) + if !ok { + continue + } + row := map[string]any{ + "name": ansi.Truncate(entry.Title(), budget, "…"), + "detail": ansi.Truncate(entry.Description(), budget, "…"), + } + switch { + case index < active: + sequences["rowsBefore"] = append(sequences["rowsBefore"], row) + case index == active: + sequences["rowsActive"] = append(sequences["rowsActive"], row) + default: + sequences["rowsAfter"] = append(sequences["rowsAfter"], row) + } + } + if len(visible) == 0 && picker.FilterState() != list.Filtering { + sequences["empty"] = append(sequences["empty"], map[string]any{"text": "No items."}) + } + if picker.Paginator.TotalPages > 1 { + sequences["page"] = append(sequences["page"], map[string]any{ + "label": core.Sprintf("page %d/%d", picker.Paginator.Page+1, picker.Paginator.TotalPages), + }) + } + return ctml.Bindings{Sequences: sequences} +} + +// modelPickerTheme maps the markup's class tokens onto the existing palette, +// so the .ctml render reuses uiStyles paint exactly — no colours of its own. +func modelPickerTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Text = styles.answer + theme.Heading = styles.title // the

panel title + theme.Classes = map[string]lipgloss.Style{ + "row-idle": styles.answer, + "row-active": styles.accent, + "row-hint": styles.thought, + "filter-prompt": styles.accent, + "filter-value": styles.answer, + "picker-empty": styles.status, + "picker-page": styles.status, + "picker-keys": styles.status, + } + return theme +} + +// renderPicker parses picker.ctml with bindings derived from the list +// model's current state and renders it through the go-html terminal +// renderer: the panel title, the filter line while filtering, one
row +// per model on the current page (marker + name, indented type/path hint), +// the empty and page states, and the key footer. Cursor movement, fuzzy +// filtering, and pagination stay in list.Model — this is the render swap +// over a.picker.Update. +func renderPicker(picker list.Model, width int, styles uiStyles) string { + if width <= 0 { + return "" + } + tree, err := ctml.Parse(pickerCTML, modelPickerBindings(picker, width)) + if err != nil { + // picker.ctml is embedded and static, so a parse failure is a build + // defect; TestRenderPicker_Good pins the markup as parseable. + return "" + } + return html.RenderTerm(tree, html.NewContext(), html.TermOptions{Width: width, Theme: modelPickerTheme(styles)}) +} diff --git a/cli/tui/picker_test.go b/cli/tui/picker_test.go new file mode 100644 index 000000000..6feb73d22 --- /dev/null +++ b/cli/tui/picker_test.go @@ -0,0 +1,175 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + core "dappco.re/go" +) + +func pickerWithItems(items ...list.Item) list.Model { + picker := newPicker() + picker.SetSize(80, 30) + picker.SetItems(items) + return picker +} + +func TestRenderPicker_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + picker := pickerWithItems( + modelItem{path: "/models/alpha", name: "alpha", modelType: "fake"}, + modelItem{path: "/models/beta", name: "beta", modelType: "fake"}, + modelItem{path: "/models/gamma", name: "gamma", modelType: "fake"}, + ) + picker.Select(1) + view := renderPicker(picker, 80, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Models" { + t.Fatalf("panel must open with its title line: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if !strings.Contains(plain, "› beta") { + t.Fatalf("selected row must carry the active marker: %q", plain) + } + for _, text := range []string{"○ alpha", "○ gamma"} { + if !strings.Contains(plain, text) { + t.Fatalf("unselected row missing the idle marker %q: %q", text, plain) + } + } + for _, text := range []string{"fake /models/alpha", "fake /models/beta", "fake /models/gamma"} { + if !strings.Contains(plain, text) { + t.Fatalf("row missing the type/path hint %q: %q", text, plain) + } + } + if !strings.Contains(plain, "↑/↓ select · / filter · enter load") { + t.Fatalf("panel missing the key footer: %q", plain) + } + if strings.Contains(plain, "No items.") || strings.Contains(plain, "page ") { + t.Fatalf("a populated single page must carry no empty or page state: %q", plain) + } + + row := -1 + for index, line := range lines { + if strings.Contains(line, "› beta") { + row = index + break + } + } + if row < 0 || row+1 >= len(lines) { + t.Fatalf("selected row not found in the rendered lines: %q", plain) + } + if !strings.Contains(lines[row+1], "fake /models/beta") { + t.Fatalf("hint must sit directly beneath its name line: %q", lines[row+1]) + } + + first := strings.Index(plain, "alpha") + second := strings.Index(plain, "beta") + third := strings.Index(plain, "gamma") + if first < 0 || second < first || third < second { + t.Fatalf("the sequence split must preserve row order: %q", plain) + } + + for index, line := range lines { + if got := lipgloss.Width(line); got > 80 { + t.Fatalf("line %d width = %d, exceeds 80: %q", index, got, line) + } + } +} + +func TestRenderPicker_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + picker := pickerWithItems(modelItem{path: "/models/alpha", name: "alpha", modelType: "fake"}) + for _, width := range []int{0, -4} { + if got := renderPicker(picker, width, styles); got != "" { + t.Fatalf("renderPicker(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderPicker_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + + // A pickerless start renders the honest empty state, not a bare pane. + empty := newPicker() + empty.SetSize(40, 20) + plain := ansi.Strip(renderPicker(empty, 40, styles)) + if !strings.Contains(plain, "No items.") { + t.Fatalf("an empty picker must render its empty state: %q", plain) + } + + // While the filter is being typed, the filter line shows the typed value, + // matches stay live, and misses leave — with no "No items." mislabel. + filtered := pickerWithItems( + modelItem{path: "/models/alpha", name: "alpha", modelType: "fake"}, + modelItem{path: "/models/beta", name: "beta", modelType: "fake"}, + ) + filtered.SetFilterText("beta") + filtered.SetFilterState(list.Filtering) + plain = ansi.Strip(renderPicker(filtered, 80, styles)) + if !strings.Contains(plain, "Filter: beta") { + t.Fatalf("filtering must surface the typed filter: %q", plain) + } + if !strings.Contains(plain, "› beta") || strings.Contains(plain, "alpha") { + t.Fatalf("filtering must render matches only, cursor on the first: %q", plain) + } + filtered.SetFilterText("zzz") + filtered.SetFilterState(list.Filtering) + plain = ansi.Strip(renderPicker(filtered, 80, styles)) + if strings.Contains(plain, "No items.") || strings.Contains(plain, "○ ") { + t.Fatalf("a match-free filter renders no rows and no empty mislabel: %q", plain) + } + + // A long snapshot path truncates to the row budget instead of wrapping, + // so a page of rows keeps the density the list model paginates by. + long := pickerWithItems(modelItem{ + path: "/home/user/.cache/huggingface/hub/models--org--very-long-name/snapshots/0123456789abcdef", + name: "very-long-name", + modelType: "gemma3_text", + }) + long.SetSize(40, 20) + view := renderPicker(long, 40, styles) + plain = ansi.Strip(view) + if !strings.Contains(plain, "…") { + t.Fatalf("an over-budget hint must truncate with an ellipsis: %q", plain) + } + for index, line := range strings.Split(plain, "\n") { + if got := lipgloss.Width(line); got > 40 { + t.Fatalf("line %d width = %d, exceeds 40: %q", index, got, line) + } + } + + // More items than one short pane holds: only the current page renders, + // with the page position stated. + items := make([]list.Item, 0, 30) + for index := range 30 { + letter := string(rune('a' + index%26)) + items = append(items, modelItem{ + path: "/models/m" + letter + core.Sprintf("%02d", index), + name: "m" + core.Sprintf("%02d", index), + modelType: "fake", + }) + } + paged := newPicker() + paged.SetSize(40, 10) + paged.SetItems(items) + plain = ansi.Strip(renderPicker(paged, 40, styles)) + if !strings.Contains(plain, "page 1/") { + t.Fatalf("a paginated list must state its page position: %q", plain) + } + if !strings.Contains(plain, "› m00") { + t.Fatalf("the first page must open on the first item: %q", plain) + } + if strings.Contains(plain, "m29") { + t.Fatalf("rows beyond the current page must not render: %q", plain) + } +} diff --git a/cli/tui/tools.ctml b/cli/tui/tools.ctml new file mode 100644 index 000000000..5abb5c9d4 --- /dev/null +++ b/cli/tui/tools.ctml @@ -0,0 +1,34 @@ + + +
+

tools

+

function calling: {{row.text}}

+

{{tool.name}}{{tool.desc}}

+

recent calls

+

{{row.receipt}}

+

enter toggles · calls appear dim in the chat transcript

+
diff --git a/cli/tui/tools.go b/cli/tui/tools.go index c37cdb4cc..9c5acc8fa 100644 --- a/cli/tui/tools.go +++ b/cli/tui/tools.go @@ -3,9 +3,14 @@ package tui import ( + _ "embed" "time" + "github.com/charmbracelet/lipgloss" + core "dappco.re/go" + "dappco.re/go/html" + "dappco.re/go/html/ctml" "dappco.re/go/inference" "dappco.re/go/inference/decode/parser" ) @@ -100,27 +105,77 @@ func (t *toolState) execute(call inference.ToolCall) string { return "error: unknown tool " + call.Name } -func (t toolState) view(width int, styles uiStyles) string { - var b core.Builder - b.WriteString(styles.title.Render("tools") + "\n\n") - state := "disabled — replies are plain chat" - if t.enabled { - state = "enabled — declarations ride the system turn; calls run locally and feed back" +// toolsCTML is the Tools tab's markup — see tools.ctml for the seams it +// exposes (the state/tools/recent sequences, class tokens, the tools-panel +// block id). +// +//go:embed tools.ctml +var toolsCTML []byte + +// toolsPanelBindings binds the tab's dynamic content: the enabled-state line +// (a lone dynamic value riding a one-row sequence), one row per built-in +// tool, and — only when calls have run — the "recent calls" heading (a +// zero-or-one-row sequence, the conditional-section idiom) with the last +// five receipts. The two-cell gutter between a tool name and its +// description travels in the bound desc value, because a whitespace-only +// source run between sibling spans drops in the parser. +func toolsPanelBindings(state toolState) ctml.Bindings { + stateText := "disabled — replies are plain chat" + if state.enabled { + stateText = "enabled — declarations ride the system turn; calls run locally and feed back" } - b.WriteString(" " + styles.accent.Render("function calling: ") + styles.answer.Render(state) + "\n\n") - for _, bt := range t.tools { - b.WriteString(" " + styles.answer.Render(bt.decl.Name) + " " + styles.thought.Render(bt.decl.Description) + "\n") + sequences := map[string][]map[string]any{ + "state": {{"text": stateText}}, + "tools": {}, + "recentTitle": {}, + "recent": {}, } - if len(t.lastRun) > 0 { - b.WriteString("\n" + styles.title.Render("recent calls") + "\n") - start := len(t.lastRun) - 5 - if start < 0 { - start = 0 - } - for _, r := range t.lastRun[start:] { - b.WriteString(" " + styles.thought.Render(r) + "\n") + for _, tool := range state.tools { + sequences["tools"] = append(sequences["tools"], map[string]any{ + "name": tool.decl.Name, + "desc": " " + tool.decl.Description, + }) + } + if len(state.lastRun) > 0 { + sequences["recentTitle"] = append(sequences["recentTitle"], map[string]any{}) + start := max(0, len(state.lastRun)-5) + for _, receipt := range state.lastRun[start:] { + sequences["recent"] = append(sequences["recent"], map[string]any{"receipt": receipt}) } } - b.WriteString("\n" + styles.status.Render("enter toggles · calls appear dim in the chat transcript")) - return b.String() + return ctml.Bindings{Sequences: sequences} +} + +// toolsPanelTheme maps the markup's class tokens onto the existing palette, +// so the .ctml render reuses uiStyles paint exactly — no colours of its own. +func toolsPanelTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Text = styles.answer + theme.Heading = styles.title // the

section titles + theme.Classes = map[string]lipgloss.Style{ + "tool-label": styles.accent, + "tool-state": styles.answer, + "tool-name": styles.answer, + "tool-desc": styles.thought, + "tool-receipt": styles.thought, + "tool-keys": styles.status, + } + return theme +} + +// renderTools parses tools.ctml with the current tool bindings and renders +// it through the go-html terminal renderer: the tab title, the +// function-calling state line, one line per built-in tool, the recent-call +// receipts when any exist, and the key footer. +func renderTools(state toolState, width int, styles uiStyles) string { + if width <= 0 { + return "" + } + tree, err := ctml.Parse(toolsCTML, toolsPanelBindings(state)) + if err != nil { + // tools.ctml is embedded and static, so a parse failure is a build + // defect; TestRenderTools_Good pins the markup as parseable. + return "" + } + return html.RenderTerm(tree, html.NewContext(), html.TermOptions{Width: width, Theme: toolsPanelTheme(styles)}) } diff --git a/cli/tui/tools_test.go b/cli/tui/tools_test.go new file mode 100644 index 000000000..e8ac65d0b --- /dev/null +++ b/cli/tui/tools_test.go @@ -0,0 +1,102 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func TestRenderTools_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + view := renderTools(newTools(), 100, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "tools" { + t.Fatalf("tab must open with its title line: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if !strings.Contains(plain, "function calling: disabled — replies are plain chat") { + t.Fatalf("fresh state must render as disabled: %q", plain) + } + for _, text := range []string{ + "get_time Get the current local date and time.", + "word_count Count the words in the given text.", + } { + if !strings.Contains(plain, text) { + t.Fatalf("tool row missing %q: %q", text, plain) + } + } + + first := -1 + for index, line := range lines { + if strings.Contains(line, "get_time") { + first = index + break + } + } + if first < 0 || first+1 >= len(lines) { + t.Fatalf("tool rows not found in the rendered lines: %q", plain) + } + if !strings.Contains(lines[first+1], "word_count") { + t.Fatalf("tool rows must sit on adjacent lines: %q then %q", lines[first], lines[first+1]) + } + + if strings.Contains(plain, "recent calls") { + t.Fatalf("a run-free state must not render the recent-calls section: %q", plain) + } + if !strings.Contains(plain, "enter toggles · calls appear dim in the chat transcript") { + t.Fatalf("tab missing the key footer: %q", plain) + } + + for index, line := range lines { + if got := lipgloss.Width(line); got > 100 { + t.Fatalf("line %d width = %d, exceeds 100: %q", index, got, line) + } + } +} + +func TestRenderTools_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + for _, width := range []int{0, -4} { + if got := renderTools(newTools(), width, styles); got != "" { + t.Fatalf("renderTools(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderTools_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + state := newTools() + state.setEnabled(true) + state.lastRun = []string{ + "get_time → one", "get_time → two", "get_time → three", + "get_time → four", "get_time → five", "word_count → 3 words", + } + view := renderTools(state, 100, styles) + plain := ansi.Strip(view) + + if !strings.Contains(plain, "function calling: enabled — declarations ride the system turn; calls run locally and feed back") { + t.Fatalf("enabled state must render its full description: %q", plain) + } + if !strings.Contains(plain, "recent calls") { + t.Fatalf("receipts must surface the recent-calls section: %q", plain) + } + if strings.Contains(plain, "get_time → one") { + t.Fatalf("only the last five receipts may render: %q", plain) + } + for _, receipt := range []string{"get_time → two", "get_time → five", "word_count → 3 words"} { + if !strings.Contains(plain, receipt) { + t.Fatalf("receipt %q missing from the recent-calls section: %q", receipt, plain) + } + } + if strings.Index(plain, "recent calls") > strings.Index(plain, "get_time → two") { + t.Fatalf("receipts must follow their section heading: %q", plain) + } +} From 17f5b3383b004bb33d3216de5fb59e549562655e Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 12:13:59 +0100 Subject: [PATCH 04/17] feat(tui): overlay layer renders through ctml layout documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 of the .ctml migration: the nine overlays in dataoverlay.go and agentoverlay.go swap their hand-composed Lip Gloss strings for /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 in one RenderTerm call. - Widget-carrying overlays (datanote, datafilter, dataeditor, workeditor, agentanswer, changereview, agentselect) render as : 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
, 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 --- cli/tui/README.md | 34 +++++ cli/tui/agentanswer.ctml | 20 +++ cli/tui/agentoverlay.go | 172 +++++++++++++++++++--- cli/tui/agentoverlay_test.go | 266 +++++++++++++++++++++++++++++++++++ cli/tui/agentselect.ctml | 25 ++++ cli/tui/changereview.ctml | 27 ++++ cli/tui/databulk.ctml | 30 ++++ cli/tui/dataeditor.ctml | 25 ++++ cli/tui/datafilter.ctml | 19 +++ cli/tui/datanote.ctml | 20 +++ cli/tui/dataoverlay.go | 125 ++++++++++++---- cli/tui/dataoverlay_test.go | 225 +++++++++++++++++++++++++++++ cli/tui/launchreview.ctml | 31 ++++ cli/tui/overlayframe.go | 77 ++++++++++ cli/tui/overlayframe_test.go | 92 ++++++++++++ cli/tui/workeditor.ctml | 27 ++++ 16 files changed, 1170 insertions(+), 45 deletions(-) create mode 100644 cli/tui/agentanswer.ctml create mode 100644 cli/tui/agentselect.ctml create mode 100644 cli/tui/changereview.ctml create mode 100644 cli/tui/databulk.ctml create mode 100644 cli/tui/dataeditor.ctml create mode 100644 cli/tui/datafilter.ctml create mode 100644 cli/tui/datanote.ctml create mode 100644 cli/tui/launchreview.ctml create mode 100644 cli/tui/overlayframe.go create mode 100644 cli/tui/overlayframe_test.go create mode 100644 cli/tui/workeditor.ctml diff --git a/cli/tui/README.md b/cli/tui/README.md index a302d4ef2..ecfb0f39a 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -140,6 +140,40 @@ characters, `` content is static (bindings stay literal inside it), and the terminal renderer re-wraps inline runs. The transcript screen composes Glamour output *around* ctml-rendered chrome instead. +### Overlays + +The overlay layer (`dataoverlay.go` + `agentoverlay.go`) renders through +``/HLCRF documents (`ctml.ParseLayout`), two idioms chosen per +overlay and noted in each `.ctml` header: + +- **An all-text overlay is a full ``** rendered in + one `RenderTerm` call (`databulk.ctml`, `launchreview.ctml`): H the + title band, C the content, F the key hints. The layout brings its own + geometry — the C region indents one column, and bands butt together + without the blank a block gap would leave. +- **A widget-carrying overlay is a ``**: live + Bubbles widgets (textinput/textarea/viewport) emit pre-styled ANSI, + which cannot ride a `.ctml` document, so `renderOverlayFrame` renders + the layout once through `RenderTermBoxes` and splits the output at the + H slot's own recorded box height — the renderer's receipt for where + the header band ends — and the host composes the widgets between the + bands. Chrome trapped *between* two widgets (the `Response` / `Model` + captions) stays host-side: a `.ctml` document renders contiguously. +- **Alternate texts split into zero-or-one-row sequences** (the armed + prompt, the create/edit title, the acknowledge/apply gate): class and + text are static, so state selects WHICH sequence holds the row. +- **A multi-line receipt body binds one row per line** closing with + `
` — a bound value cannot carry a line break through an inline + run; blank lines survive as empty rows between breaks. +- The footer band's blank spacing row lives in `overlayFrameTheme` + (Footer top padding), not in host composition. Layout slots record + real H/C/F boxes, but overlays are centred by `renderOverlay` after + the fact, so no mouse affordance is wired through them yet. + +`agentcap.go` is the agent capability *model* (feature catalogue, +snapshots, requests, the provider interface and its unavailable stub), +not a view — it composes nothing and has no rendering to migrate. + ## Keys | Key | Scope | Action | diff --git a/cli/tui/agentanswer.ctml b/cli/tui/agentanswer.ctml new file mode 100644 index 000000000..126128072 --- /dev/null +++ b/cli/tui/agentanswer.ctml @@ -0,0 +1,20 @@ + + + +

Answer agent question

{{row.text}}

+

enter submits · esc cancels

+
diff --git a/cli/tui/agentoverlay.go b/cli/tui/agentoverlay.go index 4817ff118..ae3bd8b66 100644 --- a/cli/tui/agentoverlay.go +++ b/cli/tui/agentoverlay.go @@ -3,12 +3,15 @@ package tui import ( + _ "embed" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" core "dappco.re/go" + "dappco.re/go/html/ctml" ) // workEditor keeps Work creation and editing local to the TUI. It deliberately @@ -97,6 +100,35 @@ func (editor *workEditor) values() (string, string, string) { return core.Trim(editor.title.Value()), core.Trim(editor.task.Value()), core.Trim(editor.repository.Value()) } +// workEditorCTML is the Work editor overlay's markup — see workeditor.ctml +// for the seams it exposes (the create/edit title split, the conditional +// validation line, class tokens). +// +//go:embed workeditor.ctml +var workEditorCTML []byte + +// workEditorBindings selects the create-vs-edit title — both texts are +// static markup, so the mode is carried by which zero-or-one-row sequence +// holds the row, the selection-as-sequence-split idiom — and binds the +// save-validation error (zero-or-one row; an empty sequence renders +// nothing). +func workEditorBindings(editor *workEditor) ctml.Bindings { + sequences := map[string][]map[string]any{ + "createTitle": {}, + "editTitle": {}, + "validation": {}, + } + if editor != nil && editor.editingID != "" { + sequences["editTitle"] = append(sequences["editTitle"], map[string]any{}) + } else { + sequences["createTitle"] = append(sequences["createTitle"], map[string]any{}) + } + if editor != nil && editor.validation != "" { + sequences["validation"] = append(sequences["validation"], map[string]any{"text": editor.validation}) + } + return ctml.Bindings{Sequences: sequences} +} + func (editor *workEditor) View(width, height int, styles uiStyles) string { if editor == nil { return "" @@ -105,11 +137,8 @@ func (editor *workEditor) View(width, height int, styles uiStyles) string { editor.title.Width = fieldWidth editor.repository.Width = fieldWidth editor.task.SetWidth(fieldWidth) - title := "Create Work" - if editor.editingID != "" { - title = "Edit Work" - } - return fitPane(core.Join("\n", title, "", "Work title", editor.title.View(), "", "Full task", editor.task.View(), "", "Repository", editor.repository.View(), "", "tab changes field · ctrl+s saves · esc cancels", editor.validation), width, height, styles.panel) + head, foot := renderOverlayFrame(workEditorCTML, width, styles, workEditorBindings(editor)) + return fitPane(core.Join("\n", head, editor.title.View(), "", "Full task", editor.task.View(), "", "Repository", editor.repository.View(), foot), width, height, styles.panel) } type launchReviewOverlay struct { @@ -160,12 +189,31 @@ func (overlay *agentAnswerOverlay) answer() string { return core.Trim(overlay.input.Value()) } +// agentAnswerCTML is the answer overlay's markup — see agentanswer.ctml +// for the seams it exposes (the question row, class tokens). +// +//go:embed agentanswer.ctml +var agentAnswerCTML []byte + +// agentAnswerBindings binds the pending question — a lone dynamic value +// riding a one-row sequence. +func agentAnswerBindings(overlay *agentAnswerOverlay) ctml.Bindings { + question := "" + if overlay != nil { + question = overlay.question + } + return ctml.Bindings{Sequences: map[string][]map[string]any{ + "question": {{"text": question}}, + }} +} + func (overlay *agentAnswerOverlay) View(width, height int, styles uiStyles) string { if overlay == nil { return "" } overlay.input.SetWidth(max(12, width-6)) - return fitPane(core.Join("\n", "Answer agent question", "", overlay.question, "", overlay.input.View(), "", "enter submits · esc cancels"), width, height, styles.panel) + head, foot := renderOverlayFrame(agentAnswerCTML, width, styles, agentAnswerBindings(overlay)) + return fitPane(core.Join("\n", head, "", overlay.input.View(), foot), width, height, styles.panel) } type changeAcceptanceOverlay struct { @@ -208,20 +256,50 @@ func (overlay *changeAcceptanceOverlay) Update(message tea.KeyMsg) bool { return true } +// changeReviewCTML is the change-acceptance overlay's markup — see +// changereview.ctml for the seams it exposes (the title row, the +// conditional warning, the three-way prompt split, class tokens). +// +//go:embed changereview.ctml +var changeReviewCTML []byte + +// changeAcceptanceBindings binds the review title and warning, and selects +// the gate prompt: all three prompt texts are static markup, so the gate +// stage is carried by which zero-or-one-row sequence holds the row, the +// selection-as-sequence-split idiom. +func changeAcceptanceBindings(overlay *changeAcceptanceOverlay) ctml.Bindings { + sequences := map[string][]map[string]any{ + "review": {}, + "warn": {}, + "promptContinue": {}, + "promptAcknowledge": {}, + "promptApply": {}, + } + if overlay != nil { + sequences["review"] = append(sequences["review"], map[string]any{"title": overlay.review.Title}) + if overlay.review.Warning != "" { + sequences["warn"] = append(sequences["warn"], map[string]any{"text": overlay.review.Warning}) + } + switch { + case overlay.final: + sequences["promptApply"] = append(sequences["promptApply"], map[string]any{}) + case overlay.review.NeedsAcknowledgement && !overlay.acknowledged: + sequences["promptAcknowledge"] = append(sequences["promptAcknowledge"], map[string]any{}) + default: + sequences["promptContinue"] = append(sequences["promptContinue"], map[string]any{}) + } + } + return ctml.Bindings{Sequences: sequences} +} + func (overlay *changeAcceptanceOverlay) View(width, height int, styles uiStyles) string { if overlay == nil { return "" } - prompt := "enter continues · esc cancels" - if overlay.review.NeedsAcknowledgement && !overlay.acknowledged { - prompt = "a acknowledges no validation · enter continues · esc cancels" - } - if overlay.final { - prompt = "enter applies this exact reviewed receipt · esc cancels" - } overlay.viewport.Width, overlay.viewport.Height = max(1, width-4), max(1, height-6) overlay.viewport.SetContent(overlay.review.Body) - return fitPane(core.Join("\n", overlay.review.Title, overlay.review.Warning, "", overlay.viewport.View(), "", prompt), width, height, styles.panel) + head, foot := renderOverlayFrame(changeReviewCTML, width, styles, changeAcceptanceBindings(overlay)) + return fitPane(core.Join("\n", head, overlay.viewport.View(), foot), width, height, styles.panel) } func newLaunchReviewOverlay(review agentReview, provider, model string) *launchReviewOverlay { @@ -311,6 +389,67 @@ func (overlay *launchReviewOverlay) selection() (string, string) { return core.Trim(overlay.providerInput.Value()), core.Trim(overlay.modelInput.Value()) } +// launchReviewCTML and agentSelectCTML are the launch-review overlay's two +// markup shapes — launchreview.ctml (read-only receipt review, full HCF) +// and agentselect.ctml (editable provider/model selection, HF around the +// inputs); see each file for the seams it exposes. +// +//go:embed launchreview.ctml +var launchReviewCTML []byte + +//go:embed agentselect.ctml +var agentSelectCTML []byte + +// reviewBodyRows splits a multi-line receipt body into one row per line — +// a bound value cannot carry a line break through an inline run, so each +// row closes with
in the markup; empty lines ride as empty rows. +func reviewBodyRows(body string) []map[string]any { + rows := []map[string]any{} + for _, line := range core.Split(body, "\n") { + rows = append(rows, map[string]any{"line": line}) + } + return rows +} + +// launchReviewBindings binds the read-only shape: the title with the +// defaulted provider/model pair (one row), the conditional warning, and +// the receipt body lines. +func launchReviewBindings(overlay *launchReviewOverlay, provider, model string) ctml.Bindings { + sequences := map[string][]map[string]any{ + "review": {}, + "warn": {}, + "body": {}, + } + if overlay != nil { + sequences["review"] = append(sequences["review"], map[string]any{ + "title": overlay.review.Title, "provider": provider, "model": model, + }) + if overlay.review.Warning != "" { + sequences["warn"] = append(sequences["warn"], map[string]any{"text": overlay.review.Warning}) + } + sequences["body"] = reviewBodyRows(overlay.review.Body) + } + return ctml.Bindings{Sequences: sequences} +} + +// agentSelectionBindings binds the editable shape: the title (one row), +// the receipt body lines, and the conditional warning. +func agentSelectionBindings(overlay *launchReviewOverlay) ctml.Bindings { + sequences := map[string][]map[string]any{ + "review": {}, + "body": {}, + "warn": {}, + } + if overlay != nil { + sequences["review"] = append(sequences["review"], map[string]any{"title": overlay.review.Title}) + sequences["body"] = reviewBodyRows(overlay.review.Body) + if overlay.review.Warning != "" { + sequences["warn"] = append(sequences["warn"], map[string]any{"text": overlay.review.Warning}) + } + } + return ctml.Bindings{Sequences: sequences} +} + func (overlay *launchReviewOverlay) View(width, height int, styles uiStyles) string { if overlay == nil { return "" @@ -323,12 +462,13 @@ func (overlay *launchReviewOverlay) View(width, height int, styles uiStyles) str model = "default model" } if !overlay.editable { - return fitPane(core.Join("\n", overlay.review.Title, "", "Provider: "+provider, "Model: "+model, "", overlay.review.Warning, "", overlay.review.Body, "", "enter confirms · esc cancels"), width, height, styles.panel) + return fitPane(renderOverlayLayout(launchReviewCTML, width, styles, launchReviewBindings(overlay, provider, model)), width, height, styles.panel) } fieldWidth := max(12, width-6) overlay.providerInput.Width = fieldWidth overlay.modelInput.Width = fieldWidth - return fitPane(core.Join("\n", overlay.review.Title, "", "Provider", overlay.providerInput.View(), "Model", overlay.modelInput.View(), "", overlay.review.Body, "", overlay.review.Warning, "", "tab selects provider/model · enter confirms · esc cancels"), width, height, styles.panel) + head, foot := renderOverlayFrame(agentSelectCTML, width, styles, agentSelectionBindings(overlay)) + return fitPane(core.Join("\n", head, overlay.providerInput.View(), "Model", overlay.modelInput.View(), foot), width, height, styles.panel) } type agentActionMsg struct { diff --git a/cli/tui/agentoverlay_test.go b/cli/tui/agentoverlay_test.go index 977e2b142..99dfc6290 100644 --- a/cli/tui/agentoverlay_test.go +++ b/cli/tui/agentoverlay_test.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" core "dappco.re/go" ) @@ -54,6 +55,64 @@ func TestWorkEditor_MultilineValidation(t *testing.T) { } } +func TestRenderWorkEditor_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + editor := newWorkEditor(workItemRecord{Title: "Initial", Task: "Full task", Repo: "/tmp/repo"}) + view := editor.View(48, 16, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Create Work" { + t.Fatalf("fresh editor must open with the create title: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if strings.TrimSpace(lines[2]) != "Work title" { + t.Fatalf("the first caption must sit beneath the separator: %q", lines[2]) + } + if !strings.Contains(lines[3], "Initial") { + t.Fatalf("the title input must sit directly beneath its caption: %q", lines[3]) + } + for _, text := range []string{"Full task", "Repository", "tab changes field · ctrl+s saves · esc cancels"} { + if !strings.Contains(plain, text) { + t.Fatalf("editor missing %q: %q", text, plain) + } + } + for index, line := range lines { + if got := lipgloss.Width(line); got > 48 { + t.Fatalf("line %d width = %d, exceeds 48: %q", index, got, line) + } + } +} + +func TestRenderWorkEditor_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + editor := newWorkEditor(workItemRecord{}) + for _, width := range []int{0, -4} { + if got := editor.View(width, 16, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderWorkEditor_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + editor := newWorkEditor(workItemRecord{ID: "work-1", Title: "Existing", Task: "T", Repo: "/tmp/repo"}) + editor.validation = "title is required" + plain := ansi.Strip(editor.View(48, 18, styles)) + + if !strings.Contains(plain, "Edit Work") || strings.Contains(plain, "Create Work") { + t.Fatalf("an editing record must swap in the edit title: %q", plain) + } + if !strings.Contains(plain, "title is required") { + t.Fatalf("a rejected save must render its validation line: %q", plain) + } + if strings.Index(plain, "ctrl+s saves") > strings.Index(plain, "title is required") { + t.Fatalf("validation must follow the key footer: %q", plain) + } +} + func TestLaunchReview_ConfirmCancelAndRedaction(t *testing.T) { overlay := newLaunchReviewOverlay(agentReview{ Feature: agentFeatureDispatch, Title: "Review native agent launch", @@ -133,6 +192,213 @@ func TestAgentOverlay_ChangeReviewViewportScrollsWithoutDroppingContent(t *testi } } +func TestRenderAgentAnswer_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newAgentAnswerOverlay("run-9", "question-2", "Which target?") + view := overlay.View(48, 14, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Answer agent question" { + t.Fatalf("overlay must open with its title line: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if strings.TrimSpace(lines[2]) != "Which target?" { + t.Fatalf("the question must sit beneath the separator: %q", lines[2]) + } + if !strings.Contains(plain, "Type the answer for this native run") { + t.Fatalf("overlay missing the textarea placeholder: %q", plain) + } + if !strings.Contains(plain, "enter submits · esc cancels") { + t.Fatalf("overlay missing the key footer: %q", plain) + } + for index, line := range lines { + if got := lipgloss.Width(line); got > 48 { + t.Fatalf("line %d width = %d, exceeds 48: %q", index, got, line) + } + } +} + +func TestRenderAgentAnswer_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newAgentAnswerOverlay("run-9", "question-2", "Which target?") + for _, width := range []int{0, -4} { + if got := overlay.View(width, 14, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderAgentAnswer_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + // A blank question (everything trims away) drops the question row + // rather than rendering an empty paragraph. + overlay := newAgentAnswerOverlay("run-9", "question-2", " ") + plain := ansi.Strip(overlay.View(48, 14, styles)) + lines := strings.Split(plain, "\n") + if strings.TrimSpace(lines[0]) != "Answer agent question" { + t.Fatalf("overlay must open with its title line: %q", lines[0]) + } + if !strings.Contains(plain, "enter submits · esc cancels") { + t.Fatalf("question-free overlay must keep the key footer: %q", plain) + } +} + +func TestRenderChangeReview_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newChangeAcceptanceOverlay(agentReview{ + Feature: agentFeatureChangesReview, Title: "Review agent changes", + Body: "Diff:\n+change", Warning: "No validation command is configured; acknowledge this explicitly.", + ConfirmRequired: true, NeedsAcknowledgement: true, AcceptanceAllowed: true, + }) + view := overlay.View(70, 16, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Review agent changes" { + t.Fatalf("overlay must open with its title line: %q", lines[0]) + } + if !strings.Contains(plain, "No validation command is configured; acknowledge this explicitly.") { + t.Fatalf("overlay missing the warning: %q", plain) + } + for _, text := range []string{"Diff:", "+change"} { + if !strings.Contains(plain, text) { + t.Fatalf("viewport body missing %q: %q", text, plain) + } + } + if !strings.Contains(plain, "a acknowledges no validation · enter continues · esc cancels") { + t.Fatalf("unacknowledged review must show the acknowledge prompt: %q", plain) + } + for index, line := range lines { + if got := lipgloss.Width(line); got > 70 { + t.Fatalf("line %d width = %d, exceeds 70: %q", index, got, line) + } + } +} + +func TestRenderChangeReview_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newChangeAcceptanceOverlay(agentReview{Title: "Review agent changes"}) + for _, width := range []int{0, -4} { + if got := overlay.View(width, 16, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderChangeReview_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newChangeAcceptanceOverlay(agentReview{ + Feature: agentFeatureChangesReview, Title: "Review agent changes", + Body: "Diff:\n+change", AcceptanceAllowed: true, + }) + overlay.Update(tea.KeyMsg{Type: tea.KeyEnter}) // arm the final confirmation + plain := ansi.Strip(overlay.View(70, 16, styles)) + + if !strings.Contains(plain, "enter applies this exact reviewed receipt · esc cancels") { + t.Fatalf("armed review must show the apply prompt: %q", plain) + } + if strings.Contains(plain, "a acknowledges") || strings.Contains(plain, "enter continues") { + t.Fatalf("armed review must show only the apply prompt: %q", plain) + } + if lines := strings.Split(plain, "\n"); !strings.Contains(lines[1], "Diff:") { + t.Fatalf("a warning-free review must put the viewport directly beneath the title: %q", lines[1]) + } +} + +func TestRenderLaunchReview_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newLaunchReviewOverlay(agentReview{ + Feature: agentFeatureDispatch, Title: "Review native agent launch", + Body: "Command: codex exec --token [REDACTED] --model gpt-5\nSource: /tmp/repo", + Warning: "Native agent execution has host access.", ConfirmRequired: true, + }, "codex", "gpt-5") + view := overlay.View(72, 18, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Review native agent launch" { + t.Fatalf("overlay must open with its title line: %q", lines[0]) + } + for _, text := range []string{ + "Provider: codex", "Model: gpt-5", + "Native agent execution has host access.", + "Command: codex exec --token [REDACTED] --model gpt-5", + "Source: /tmp/repo", + "enter confirms · esc cancels", + } { + if !strings.Contains(plain, text) { + t.Fatalf("read-only review missing %q: %q", text, plain) + } + } + if strings.Index(plain, "Model: gpt-5") > strings.Index(plain, "host access") { + t.Fatalf("the provider/model band must render above the warning: %q", plain) + } + for index, line := range lines { + if got := lipgloss.Width(line); got > 72 { + t.Fatalf("line %d width = %d, exceeds 72: %q", index, got, line) + } + } +} + +func TestRenderLaunchReview_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newLaunchReviewOverlay(agentReview{Title: "Review native agent launch"}, "codex", "gpt-5") + for _, width := range []int{0, -4} { + if got := overlay.View(width, 18, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderLaunchReview_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + // The editable selection shape: captions around live inputs, the body + // (with its blank line preserved between rows) and warning beneath. + overlay := newAgentSelectionOverlay("codex", "gpt-5") + overlay.review.Body = "Registration: pending\n\nQueue: ready" + view := overlay.View(60, 18, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Select native agent" { + t.Fatalf("selection overlay must open with its title: %q", lines[0]) + } + if strings.TrimSpace(lines[2]) != "Provider" { + t.Fatalf("the provider caption must sit beneath the separator: %q", lines[2]) + } + if !strings.Contains(lines[3], "codex") { + t.Fatalf("the provider input must sit directly beneath its caption: %q", lines[3]) + } + if strings.TrimSpace(lines[4]) != "Model" { + t.Fatalf("the model caption must sit between the inputs: %q", lines[4]) + } + if !strings.Contains(lines[5], "gpt-5") { + t.Fatalf("the model input must sit directly beneath its caption: %q", lines[5]) + } + row := -1 + for index, line := range lines { + if strings.Contains(line, "Registration: pending") { + row = index + break + } + } + if row < 0 || row+2 >= len(lines) { + t.Fatalf("selection overlay missing the body rows: %q", plain) + } + if strings.TrimSpace(lines[row+1]) != "" || !strings.Contains(lines[row+2], "Queue: ready") { + t.Fatalf("a blank body line must survive between rows: %q then %q", lines[row+1], lines[row+2]) + } + if !strings.Contains(plain, "Select the provider and model before reviewing project") { + t.Fatalf("selection overlay missing its warning: %q", plain) + } + if !strings.Contains(plain, "tab selects provider/model · enter confirms · esc cancels") { + t.Fatalf("selection overlay missing the key footer: %q", plain) + } +} + type launchReviewProvider struct { caps []agentCapability reviews []agentReview diff --git a/cli/tui/agentselect.ctml b/cli/tui/agentselect.ctml new file mode 100644 index 000000000..db0f2bb7c --- /dev/null +++ b/cli/tui/agentselect.ctml @@ -0,0 +1,25 @@ + + + +

{{row.title}}

Provider

+

{{row.line}}

{{row.text}}

tab selects provider/model · enter confirms · esc cancels

+
diff --git a/cli/tui/changereview.ctml b/cli/tui/changereview.ctml new file mode 100644 index 000000000..023486ed0 --- /dev/null +++ b/cli/tui/changereview.ctml @@ -0,0 +1,27 @@ + + + +

{{row.title}}

{{row.text}}

+

enter continues · esc cancels

a acknowledges no validation · enter continues · esc cancels

enter applies this exact reviewed receipt · esc cancels

+
diff --git a/cli/tui/databulk.ctml b/cli/tui/databulk.ctml new file mode 100644 index 000000000..ff4efaae2 --- /dev/null +++ b/cli/tui/databulk.ctml @@ -0,0 +1,30 @@ + + + +

Bulk {{row.title}}

This will apply to {{row.count}} item(s) matching the current filter.

+

Note: {{row.text}}

+

enter continues · esc cancels

enter applies this action to every listed item · esc cancels

+
diff --git a/cli/tui/dataeditor.ctml b/cli/tui/dataeditor.ctml new file mode 100644 index 000000000..58a2ed880 --- /dev/null +++ b/cli/tui/dataeditor.ctml @@ -0,0 +1,25 @@ + + + +

Edit as derived

Prompt

Prompt (context only — earlier turns are kept as-is)

+

tab changes field · ctrl+s saves as a new derived item · esc cancels

+
diff --git a/cli/tui/datafilter.ctml b/cli/tui/datafilter.ctml new file mode 100644 index 000000000..534716df7 --- /dev/null +++ b/cli/tui/datafilter.ctml @@ -0,0 +1,19 @@ + + + +

Filter

dataset= status= kind= source= <score expr>, comma-separated

+

enter applies · esc cancels

+
diff --git a/cli/tui/datanote.ctml b/cli/tui/datanote.ctml new file mode 100644 index 000000000..3685afc8b --- /dev/null +++ b/cli/tui/datanote.ctml @@ -0,0 +1,20 @@ + + + +

{{row.title}}

{{row.prompt}}

+

enter submits · esc cancels

+
diff --git a/cli/tui/dataoverlay.go b/cli/tui/dataoverlay.go index 81b3d2756..ac90646e6 100644 --- a/cli/tui/dataoverlay.go +++ b/cli/tui/dataoverlay.go @@ -3,11 +3,14 @@ package tui import ( + _ "embed" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" core "dappco.re/go" + "dappco.re/go/html/ctml" "dappco.re/go/inference/dataset" ) @@ -87,6 +90,30 @@ func (editor *dataItemEditor) values() (string, string) { return editor.prompt.Value(), editor.response.Value() } +// dataEditorCTML is the edit-as-derived overlay's markup — see +// dataeditor.ctml for the seams it exposes (the prompt-caption split, +// class tokens). +// +//go:embed dataeditor.ctml +var dataEditorCTML []byte + +// dataItemEditorBindings selects the prompt caption by item kind: both +// captions are static markup text, so the messages-kind note is carried by +// which zero-or-one-row sequence holds the row, the selection-as- +// sequence-split idiom. +func dataItemEditorBindings(editor *dataItemEditor) ctml.Bindings { + sequences := map[string][]map[string]any{ + "pairLabel": {}, + "messagesLabel": {}, + } + if editor != nil && editor.original.Kind == dataset.KindMessages { + sequences["messagesLabel"] = append(sequences["messagesLabel"], map[string]any{}) + } else { + sequences["pairLabel"] = append(sequences["pairLabel"], map[string]any{}) + } + return ctml.Bindings{Sequences: sequences} +} + func (editor *dataItemEditor) View(width, height int, styles uiStyles) string { if editor == nil { return "" @@ -94,15 +121,11 @@ func (editor *dataItemEditor) View(width, height int, styles uiStyles) string { fieldWidth := max(12, width-6) editor.prompt.SetWidth(fieldWidth) editor.response.SetWidth(fieldWidth) - promptLabel := "Prompt" - if editor.original.Kind == dataset.KindMessages { - promptLabel = "Prompt (context only — earlier turns are kept as-is)" - } + head, foot := renderOverlayFrame(dataEditorCTML, width, styles, dataItemEditorBindings(editor)) return fitPane(core.Join("\n", - "Edit as derived", "", - promptLabel, editor.prompt.View(), "", - "Response", editor.response.View(), "", - "tab changes field · ctrl+s saves as a new derived item · esc cancels", + head, editor.prompt.View(), "", + "Response", editor.response.View(), + foot, ), width, height, styles.panel) } @@ -157,12 +180,31 @@ func (overlay *dataNoteOverlay) Bulk() bool { return overlay != nil && overlay.itemID == "" } +// dataNoteCTML is the note/label overlay's markup — see datanote.ctml for +// the seams it exposes (the caller-supplied title/prompt row, class tokens). +// +//go:embed datanote.ctml +var dataNoteCTML []byte + +// dataNoteBindings binds the caller-supplied title and prompt — one row, +// because a lone pair of dynamic values rides a one-row sequence. +func dataNoteBindings(overlay *dataNoteOverlay) ctml.Bindings { + title, prompt := "", "" + if overlay != nil { + title, prompt = overlay.title, overlay.prompt + } + return ctml.Bindings{Sequences: map[string][]map[string]any{ + "note": {{"title": title, "prompt": prompt}}, + }} +} + func (overlay *dataNoteOverlay) View(width, height int, styles uiStyles) string { if overlay == nil { return "" } overlay.input.Width = max(12, width-6) - return fitPane(core.Join("\n", overlay.title, "", overlay.prompt, overlay.input.View(), "", "enter submits · esc cancels"), width, height, styles.panel) + head, foot := renderOverlayFrame(dataNoteCTML, width, styles, dataNoteBindings(overlay)) + return fitPane(core.Join("\n", head, overlay.input.View(), foot), width, height, styles.panel) } // dataBulkOverlay gates a bulk-apply-to-current-filter action behind an @@ -198,24 +240,46 @@ func (overlay *dataBulkOverlay) Confirm(key string) bool { return true } +// dataBulkCTML is the bulk-confirm overlay's markup — see databulk.ctml +// for the seams it exposes (the title/count row, the conditional note, the +// armed-prompt split, class tokens). +// +//go:embed databulk.ctml +var dataBulkCTML []byte + +// dataBulkBindings binds the action title and item count, the optional +// shared note (zero-or-one row), and the phase prompt: both prompt texts +// are static markup, so the armed state is carried by which zero-or-one-row +// sequence holds the row, the selection-as-sequence-split idiom. +func dataBulkBindings(overlay *dataBulkOverlay) ctml.Bindings { + sequences := map[string][]map[string]any{ + "bulk": {}, + "note": {}, + "arm": {}, + "confirm": {}, + } + if overlay != nil { + sequences["bulk"] = append(sequences["bulk"], map[string]any{ + "title": overlay.action.title(), + "count": core.Sprintf("%d", overlay.count), + }) + if overlay.note != "" { + sequences["note"] = append(sequences["note"], map[string]any{"text": overlay.note}) + } + if overlay.armed { + sequences["confirm"] = append(sequences["confirm"], map[string]any{}) + } else { + sequences["arm"] = append(sequences["arm"], map[string]any{}) + } + } + return ctml.Bindings{Sequences: sequences} +} + func (overlay *dataBulkOverlay) View(width, height int, styles uiStyles) string { if overlay == nil { return "" } - prompt := "enter continues · esc cancels" - if overlay.armed { - prompt = "enter applies this action to every listed item · esc cancels" - } - lines := []string{ - "Bulk " + overlay.action.title(), - "", - core.Sprintf("This will apply to %d item(s) matching the current filter.", overlay.count), - } - if overlay.note != "" { - lines = append(lines, "", "Note: "+overlay.note) - } - lines = append(lines, "", prompt) - return fitPane(core.Join("\n", lines...), width, height, styles.panel) + return fitPane(renderOverlayLayout(dataBulkCTML, width, styles, dataBulkBindings(overlay)), width, height, styles.panel) } // dataFilterOverlay edits the Data panel's structural filter as one line @@ -255,15 +319,18 @@ func (overlay *dataFilterOverlay) Value() string { return overlay.input.Value() } +// dataFilterCTML is the filter overlay's markup — see datafilter.ctml for +// the seams it exposes (class tokens; every line is static, so it binds +// nothing). +// +//go:embed datafilter.ctml +var dataFilterCTML []byte + func (overlay *dataFilterOverlay) View(width, height int, styles uiStyles) string { if overlay == nil { return "" } overlay.input.Width = max(12, width-6) - return fitPane(core.Join("\n", - "Filter", "", - "dataset= status= kind= source= , comma-separated", - overlay.input.View(), "", - "enter applies · esc cancels", - ), width, height, styles.panel) + head, foot := renderOverlayFrame(dataFilterCTML, width, styles) + return fitPane(core.Join("\n", head, overlay.input.View(), foot), width, height, styles.panel) } diff --git a/cli/tui/dataoverlay_test.go b/cli/tui/dataoverlay_test.go index 837d8d0fa..571b4fe95 100644 --- a/cli/tui/dataoverlay_test.go +++ b/cli/tui/dataoverlay_test.go @@ -8,6 +8,8 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" core "dappco.re/go" "dappco.re/go/inference/dataset" @@ -94,6 +96,62 @@ func TestDataItemEditor_Nil(t *testing.T) { } } +func TestRenderDataEditor_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + editor := newDataItemEditor(conformancePairItem("ds", "the prompt", "the response", testTime())) + view := editor.View(60, 24, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Edit as derived" { + t.Fatalf("overlay must open with its title line: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if strings.TrimSpace(lines[2]) != "Prompt" { + t.Fatalf("pair-kind editor must caption the first field Prompt: %q", lines[2]) + } + if !strings.Contains(lines[3], "the prompt") { + t.Fatalf("prompt textarea must sit directly beneath its caption: %q", lines[3]) + } + if !strings.Contains(plain, "Response") { + t.Fatalf("overlay missing the Response caption: %q", plain) + } + if !strings.Contains(plain, "tab changes field · ctrl+s saves as a new derived item ·") { + t.Fatalf("overlay missing the key footer: %q", plain) + } + for index, line := range lines { + if got := lipgloss.Width(line); got > 60 { + t.Fatalf("line %d width = %d, exceeds 60: %q", index, got, line) + } + } +} + +func TestRenderDataEditor_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + editor := newDataItemEditor(conformancePairItem("ds", "p", "r", testTime())) + for _, width := range []int{0, -4} { + if got := editor.View(width, 24, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderDataEditor_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + content := dataMessagesJSON(t, []dataset.MessageTurn{{Role: "user", Content: "hi"}, {Role: "assistant", Content: "hello"}}) + editor := newDataItemEditor(dataset.Item{ID: "m1", DatasetID: "ds", Kind: dataset.KindMessages, Content: content}) + plain := ansi.Strip(editor.View(70, 24, styles)) + + if !strings.Contains(plain, "Prompt (context only — earlier turns are kept as-is)") { + t.Fatalf("messages-kind editor must swap in the context-only caption: %q", plain) + } + if strings.Index(plain, "context only") > strings.Index(plain, "Response") { + t.Fatalf("the caption split must keep the prompt caption above Response: %q", plain) + } +} + // ---- dataNoteOverlay ---- func TestDataNoteOverlay_RequiresNonEmptyValueToSubmit(t *testing.T) { @@ -149,6 +207,59 @@ func TestDataNoteOverlay_Nil(t *testing.T) { } } +func TestRenderDataNote_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newDataNoteOverlay(dataActionTag, "item-1", "Tag item", "Tag label", "label") + view := overlay.View(48, 12, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Tag item" { + t.Fatalf("overlay must open with its title line: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if strings.TrimSpace(lines[2]) != "Tag label" { + t.Fatalf("prompt must sit beneath the separator: %q", lines[2]) + } + if !strings.Contains(lines[3], "label") { + t.Fatalf("the input must sit directly beneath the prompt: %q", lines[3]) + } + if !strings.Contains(plain, "enter submits · esc cancels") { + t.Fatalf("overlay missing the key footer: %q", plain) + } + for index, line := range lines { + if got := lipgloss.Width(line); got > 48 { + t.Fatalf("line %d width = %d, exceeds 48: %q", index, got, line) + } + } +} + +func TestRenderDataNote_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newDataNoteOverlay(dataActionTag, "item-1", "Tag", "p", "ph") + for _, width := range []int{0, -4} { + if got := overlay.View(width, 12, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderDataNote_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + // The bulk flavour (empty itemID) renders the same frame — the shared + // note is a caller concern, not a markup one. + overlay := newDataNoteOverlay(dataActionQuarantineClear, "", "Bulk clear quarantine", "Why should these clear?", "note") + plain := ansi.Strip(overlay.View(48, 12, styles)) + if strings.Index(plain, "Bulk clear quarantine") > strings.Index(plain, "Why should these clear?") { + t.Fatalf("title must render above the prompt: %q", plain) + } + if !strings.Contains(plain, "enter submits · esc cancels") { + t.Fatalf("bulk-flavoured note overlay missing the key footer: %q", plain) + } +} + // ---- dataBulkOverlay: the count-confirmation gate ---- // TestDataBulkOverlay_TwoPhaseConfirm proves the exact gate the task brief @@ -206,6 +317,65 @@ func TestDataBulkOverlay_Nil(t *testing.T) { } } +func TestRenderDataBulk_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newDataBulkOverlay(dataActionApprove, 42, "") + view := overlay.View(48, 12, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Bulk Approve" { + t.Fatalf("overlay must open with its action title: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if !strings.Contains(plain, "This will apply to 42 item(s) matching the") { + t.Fatalf("overlay missing the count sentence: %q", plain) + } + if strings.Contains(plain, "Note:") { + t.Fatalf("a note-free bulk action must not render a note row: %q", plain) + } + if !strings.Contains(plain, "enter continues · esc cancels") { + t.Fatalf("unarmed overlay missing the continue prompt: %q", plain) + } + for index, line := range lines { + if got := lipgloss.Width(line); got > 48 { + t.Fatalf("line %d width = %d, exceeds 48: %q", index, got, line) + } + } +} + +func TestRenderDataBulk_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newDataBulkOverlay(dataActionApprove, 1, "") + for _, width := range []int{0, -4} { + if got := overlay.View(width, 12, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderDataBulk_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newDataBulkOverlay(dataActionQuarantineClear, 7, "false positive batch") + overlay.Confirm("enter") // arm + plain := ansi.Strip(overlay.View(48, 12, styles)) + + if !strings.Contains(plain, "Note: false positive batch") { + t.Fatalf("collected note must render its row: %q", plain) + } + if strings.Index(plain, "item(s)") > strings.Index(plain, "Note:") { + t.Fatalf("the note must follow the count sentence: %q", plain) + } + if !strings.Contains(plain, "enter applies this action to every listed item") { + t.Fatalf("armed overlay must swap to the apply prompt: %q", plain) + } + if strings.Contains(plain, "enter continues") { + t.Fatalf("armed overlay must not keep the continue prompt: %q", plain) + } +} + // ---- dataFilterOverlay ---- func TestDataFilterOverlay_EnterAlwaysSubmitsEvenEmpty(t *testing.T) { @@ -236,3 +406,58 @@ func TestDataFilterOverlay_Nil(t *testing.T) { t.Fatalf("nil overlay view = %q", view) } } + +func TestRenderDataFilter_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newDataFilterOverlay("status=pending") + view := overlay.View(80, 12, styles) + plain := ansi.Strip(view) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Filter" { + t.Fatalf("overlay must open with its title line: %q", lines[0]) + } + if strings.TrimSpace(lines[1]) != "" { + t.Fatalf("title must be followed by a blank separator: %q", lines[1]) + } + if !strings.Contains(plain, "dataset= status= kind= source= , comma-separated") { + t.Fatalf("overlay missing the grammar hint: %q", plain) + } + if !strings.Contains(plain, "status=pending") { + t.Fatalf("overlay missing the pre-filled input: %q", plain) + } + if !strings.Contains(plain, "enter applies · esc cancels") { + t.Fatalf("overlay missing the key footer: %q", plain) + } + for index, line := range lines { + if got := lipgloss.Width(line); got > 80 { + t.Fatalf("line %d width = %d, exceeds 80: %q", index, got, line) + } + } +} + +func TestRenderDataFilter_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newDataFilterOverlay("") + for _, width := range []int{0, -4} { + if got := overlay.View(width, 12, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } +} + +func TestRenderDataFilter_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + // A narrow pane wraps the grammar hint; the escaped token + // must survive the entity round trip and no line may overflow. + overlay := newDataFilterOverlay("") + plain := ansi.Strip(overlay.View(24, 12, styles)) + if !strings.Contains(plain, " 24 { + t.Fatalf("line %d width = %d, exceeds 24: %q", index, got, line) + } + } +} diff --git a/cli/tui/launchreview.ctml b/cli/tui/launchreview.ctml new file mode 100644 index 000000000..6d663fd1b --- /dev/null +++ b/cli/tui/launchreview.ctml @@ -0,0 +1,31 @@ + + + +

{{row.title}}

Provider: {{row.provider}}

Model: {{row.model}}

+

{{row.text}}

{{row.line}}

+

enter confirms · esc cancels

+
diff --git a/cli/tui/overlayframe.go b/cli/tui/overlayframe.go new file mode 100644 index 000000000..33a7ac5b3 --- /dev/null +++ b/cli/tui/overlayframe.go @@ -0,0 +1,77 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "github.com/charmbracelet/lipgloss" + + core "dappco.re/go" + "dappco.re/go/html" + "dappco.re/go/html/ctml" +) + +// The overlay layer renders its text chrome through .ctml +// documents (HLCRF). Two idioms, chosen per overlay and noted in each +// file's header comment: +// +// - An all-text overlay is a full : the title +// band, the content region, and the key-hint footer all render in one +// RenderTerm call (databulk.ctml, launchreview.ctml). +// - A widget-carrying overlay is a : live Bubbles +// widgets (textinput/textarea/viewport) emit pre-styled ANSI, which +// cannot ride a .ctml document, so the layout renders the header and +// footer bands and the host composes the widgets between them — +// exactly as the transcript composes Glamour output around +// ctml-rendered chrome. +// +// renderOverlayFrame is the HF idiom's seam: it renders the layout once +// through RenderTermBoxes and splits the output at the H slot's own +// recorded box height — the renderer's receipt for where the header band +// ends — so the host never re-measures rendered chrome. +func renderOverlayFrame(src []byte, width int, styles uiStyles, bindings ...ctml.Bindings) (head, foot string) { + layout, err := ctml.ParseLayout(src, bindings...) + if err != nil { + // Overlay markup is embedded and static, so a parse failure is a + // build defect; the TestRender_Good tests pin each file + // as parseable. + return "", "" + } + rendered, boxes := layout.RenderTermBoxes(html.NewContext(), html.TermOptions{Width: width, Theme: overlayFrameTheme(styles)}) + lines := core.Split(rendered, "\n") + split := min(boxes["H"].Height, len(lines)) + return core.Join("\n", lines[:split]...), core.Join("\n", lines[split:]...) +} + +// renderOverlayLayout is the HCF idiom's seam: one RenderTerm call for an +// overlay whose every region is text. +func renderOverlayLayout(src []byte, width int, styles uiStyles, bindings ...ctml.Bindings) string { + layout, err := ctml.ParseLayout(src, bindings...) + if err != nil { + // Embedded static markup — a parse failure is a build defect (see + // renderOverlayFrame). + return "" + } + return layout.RenderTerm(html.NewContext(), html.TermOptions{Width: width, Theme: overlayFrameTheme(styles)}) +} + +// overlayFrameTheme maps overlay markup onto the existing palette, so the +// .ctml renders reuse uiStyles paint exactly — no colours of their own. +// The layout bands are stripped of the default theme's borders: the +// floating overlay box (renderOverlay) already draws the frame. The +// footer band keeps one row of top padding — the blank line every overlay +// draws above its key hints — so that spacing lives in the theme, not in +// host composition. +func overlayFrameTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Text = styles.answer + theme.Heading = styles.title // the

overlay titles + theme.Header = lipgloss.NewStyle() + theme.Footer = lipgloss.NewStyle().Padding(1, 0, 0, 0) + theme.Classes = map[string]lipgloss.Style{ + "overlay-hint": styles.thought, + "overlay-warn": styles.attention, + "overlay-error": styles.err, + "overlay-keys": styles.status, + } + return theme +} diff --git a/cli/tui/overlayframe_test.go b/cli/tui/overlayframe_test.go new file mode 100644 index 000000000..536246d11 --- /dev/null +++ b/cli/tui/overlayframe_test.go @@ -0,0 +1,92 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestRenderOverlayFrame_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + head, foot := renderOverlayFrame(dataFilterCTML, 48, styles) + headPlain := ansi.Strip(head) + footPlain := ansi.Strip(foot) + + headLines := strings.Split(headPlain, "\n") + if strings.TrimSpace(headLines[0]) != "Filter" { + t.Fatalf("header band must open with the title line: %q", headLines[0]) + } + if !strings.Contains(headPlain, "comma-separated") { + t.Fatalf("header band missing the hint: %q", headPlain) + } + if strings.Contains(headPlain, "enter applies") { + t.Fatalf("footer text leaked into the header band: %q", headPlain) + } + + footLines := strings.Split(footPlain, "\n") + if strings.TrimSpace(footLines[0]) != "" { + t.Fatalf("footer band must open with its padded blank row: %q", footLines[0]) + } + if !strings.Contains(footPlain, "enter applies · esc cancels") { + t.Fatalf("footer band missing the key hints: %q", footPlain) + } + if strings.Contains(footPlain, "Filter") { + t.Fatalf("header text leaked into the footer band: %q", footPlain) + } +} + +func TestRenderOverlayFrame_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + // Not a root — ParseLayout must refuse it and the frame must + // come back empty rather than half-rendered. + head, foot := renderOverlayFrame([]byte("

not a layout

"), 48, styles) + if head != "" || foot != "" { + t.Fatalf("unparseable markup returned a frame: head=%q foot=%q", head, foot) + } +} + +func TestRenderOverlayFrame_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + // Every conditional H sequence empty: the header band shrinks to the + // title alone and the split still lands between the bands. + overlay := newChangeAcceptanceOverlay(agentReview{Title: "Review agent changes"}) + head, foot := renderOverlayFrame(changeReviewCTML, 60, styles, changeAcceptanceBindings(overlay)) + if got := strings.TrimSpace(ansi.Strip(head)); got != "Review agent changes" { + t.Fatalf("warning-free header band = %q, want the title alone", got) + } + if !strings.Contains(ansi.Strip(foot), "enter continues · esc cancels") { + t.Fatalf("stage-free review must fall back to the continue prompt: %q", foot) + } +} + +func TestRenderOverlayLayout_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + overlay := newDataBulkOverlay(dataActionApprove, 3, "") + plain := ansi.Strip(renderOverlayLayout(dataBulkCTML, 48, styles, dataBulkBindings(overlay))) + first := strings.Index(plain, "Bulk Approve") + second := strings.Index(plain, "This will apply to 3 item(s)") + third := strings.Index(plain, "enter continues · esc cancels") + if first < 0 || second < first || third < second { + t.Fatalf("HCF regions must render in H, C, F order: %q", plain) + } +} + +func TestRenderOverlayLayout_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + if got := renderOverlayLayout([]byte("
not a layout
"), 48, styles); got != "" { + t.Fatalf("unparseable markup rendered: %q", got) + } +} + +func TestRenderOverlayLayout_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + // A nil overlay binds every sequence empty: the layout must render its + // static skeleton (the footer band) without a title or content row. + plain := ansi.Strip(renderOverlayLayout(dataBulkCTML, 48, styles, dataBulkBindings(nil))) + if strings.Contains(plain, "Bulk") || strings.Contains(plain, "item(s)") { + t.Fatalf("empty bindings must render no bound rows: %q", plain) + } +} diff --git a/cli/tui/workeditor.ctml b/cli/tui/workeditor.ctml new file mode 100644 index 000000000..b59c402f2 --- /dev/null +++ b/cli/tui/workeditor.ctml @@ -0,0 +1,27 @@ + + + +

Create Work

Edit Work

Work title

+

tab changes field · ctrl+s saves · esc cancels

{{row.text}}

+
From 8b03cce4e7f93dfda0bdf48c900d3ba0654dbc2b Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 12:24:34 +0100 Subject: [PATCH 05/17] =?UTF-8?q?build(cli):=20go-html=20v0.12.0=20?= =?UTF-8?q?=E2=80=94=20dt=20wrap,=20scalar=20bindings,=20interpolation,=20?= =?UTF-8?q?Verbatim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli/go.mod | 2 +- cli/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/go.mod b/cli/go.mod index 1e9963bee..12e0302c3 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -33,7 +33,7 @@ require ( charm.land/log/v2 v2.0.0 // indirect charm.land/wish/v2 v2.0.0 // indirect dappco.re/go/cgo v0.11.2 // indirect - dappco.re/go/html v0.11.0 // direct: cli/tui renders .ctml (tabs.go) + dappco.re/go/html v0.12.0 // direct: cli/tui renders .ctml (tabs.go) dappco.re/go/i18n v0.12.1 // indirect dappco.re/go/log v0.13.1 // indirect dappco.re/go/ratelimit v0.12.1 // indirect diff --git a/cli/go.sum b/cli/go.sum index 6b93e6f46..7e78e88c1 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -22,6 +22,8 @@ dappco.re/go/container v0.11.0 h1:XbZS3VdMoxHIn1EQTnU44kJSB/H95rVrZ6ceujAUPSE= dappco.re/go/container v0.11.0/go.mod h1:DTA3lcbMG3bfve8hlDls1UNxRgGAZELCGuuM5XKSpgg= dappco.re/go/html v0.11.0 h1:Qau6MinQATThPmMamnzyaSV06mr10QzDdAcvkIqwlBk= dappco.re/go/html v0.11.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= +dappco.re/go/html v0.12.0 h1:/5hDG/AcOrFagkGk/7BeRnIichJ6+2qqRAbEdBgYrfE= +dappco.re/go/html v0.12.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= dappco.re/go/i18n v0.12.1 h1:Pm1DF9I0O8bckTr815OrnR+G+7EvbQhP2Vk4ItC0mB4= dappco.re/go/i18n v0.12.1/go.mod h1:AYx1QkPfW+qvzLCfJnuOlc4J+grwlgFzoHnoke4TAYg= dappco.re/go/inference v0.14.0 h1:3Lddj4W8T7/Mg8iTmvS+hCUkUqYYhRPYGe6i/ILF1+o= From d040246177d797a01f7155c74623518c253c14e1 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 12:35:13 +0100 Subject: [PATCH 06/17] refactor(tui): adopt go-html v0.12.0 surface; tables slice is stores, not views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 path via lipgloss/table vs the
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 isolating a bind from adjacent literal text drops from tabs, settings, picker, databulk and launchreview ("o {{row.name}}" 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 --- cli/tui/README.md | 40 +++++++++++++++++++++++++++++++-------- cli/tui/databulk.ctml | 4 ++-- cli/tui/launchreview.ctml | 2 +- cli/tui/picker.ctml | 10 ++++++---- cli/tui/picker.go | 4 +++- cli/tui/settings.ctml | 6 +++--- cli/tui/tabs.ctml | 2 +- cli/tui/tools.ctml | 19 ++++++++++++------- cli/tui/tools.go | 8 ++++---- 9 files changed, 64 insertions(+), 31 deletions(-) diff --git a/cli/tui/README.md b/cli/tui/README.md index ecfb0f39a..f03c6ba1c 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -65,11 +65,16 @@ by go-html's terminal renderer (`dappco.re/go/html`). The tab strip seams the file exposes. 2. **Bindings** — dynamic rows enter at parse time through `ctml.Bindings` `Sequences` and ``, bound in text as - `{{row.field}}`. Re-parse and re-bind on every state change — screens - this size make that free. A per-row style variation cannot ride a class - attribute (they are static), so the host splits rows into one sequence - per style (see `panelBarBindings`: `tabsBefore` / `tabsActive` / - `tabsAfter`). + `{{row.field}}` — literal text and binds mix freely in one run + (`○ {{row.name}}`), and a lone always-present scalar rides + `Bindings.Values` at document scope (`{{state}}` outside any ``; + row scope wins inside one). A `Values` miss renders empty while the + literal text around it remains, so a value whose absence must hide a + whole line still rides a zero-or-one-row sequence. Re-parse and + re-bind on every state change — screens this size make that free. A + per-row style variation cannot ride a class attribute (they are + static), so the host splits rows into one sequence per style (see + `panelBarBindings`: `tabsBefore` / `tabsActive` / `tabsAfter`). 3. **Theme** — `html.TermTheme.Classes` maps the markup's class tokens onto the existing `uiStyles` palette (`panelBarTheme`). The markup carries no colours of its own; the palette in `style.go` stays the single source of @@ -122,12 +127,16 @@ The Models panel (`picker.go` + `picker.ctml`) and the Tools tab - **A Bubbles-list screen keeps its state in `list.Model`** (items, cursor, fuzzy filter, pagination through `Update`) and derives its row bindings from it — the current page split before/active/after; the host truncates - each field to the row budget because `
` lines never wrap. + each field to the row budget because the page math requires exactly one + line per `
` (a `
` wraps to the render width, and a wrapped row + would overflow the page the list delegate sized). - **Adjacent single-line rows ride ONE `

` with a `
` closing each `` row** — separate block elements would gain blank separators. - **A section that appears only with data is an `` over a - zero-or-one-row sequence** (the same one-row trick a lone dynamic value - uses); an empty sequence renders nothing, heading included. + zero-or-one-row sequence**; an empty sequence renders nothing, heading + included. A lone *always-present* value rides `Bindings.Values` instead + (the tools state line) — the conditional section cannot, because a + `Values` miss renders empty while its surrounding literal text remains. - **A plain gutter between two bound spans travels in the bound value** (whitespace-only source runs drop; a gutter with a glyph can stay in markup as tabs/settings do). @@ -174,6 +183,21 @@ overlay and noted in each `.ctml` header: snapshots, requests, the provider interface and its unavailable stub), not a view — it composes nothing and has no rendering to migrate. +### Tables and stores + +`records.go`, `migrations.go`, and `datasetmigrations.go` are tables in +the database sense only: the ORM record schemas, the `lem.duckdb` +migration runner, and the `datasets.duckdb` migration runner. None +composes a screen — like `preferences.go`, `markdown.go`, and +`agentcap.go` before them, they have no rendering to migrate. + +No TUI screen is genuinely columnar today. When one arrives, the +terminal renderer has a real table path — `

`/``/`
` (and +`thead`/`th`) render through lipgloss/table as a bordered, +content-sized grid, and none of those tags is reserved by ctml — but a +label+detail row list is not a table: it stays on the `
` idiom +settings and the picker established. + ## Keys | Key | Scope | Action | diff --git a/cli/tui/databulk.ctml b/cli/tui/databulk.ctml index ff4efaae2..242ba1c5b 100644 --- a/cli/tui/databulk.ctml +++ b/cli/tui/databulk.ctml @@ -24,7 +24,7 @@ whitespace ("This will apply to ") survives. --> -

Bulk {{row.title}}

This will apply to {{row.count}} item(s) matching the current filter.

-

Note: {{row.text}}

+

Bulk {{row.title}}

This will apply to {{row.count}} item(s) matching the current filter.

+

Note: {{row.text}}

enter continues · esc cancels

enter applies this action to every listed item · esc cancels

diff --git a/cli/tui/launchreview.ctml b/cli/tui/launchreview.ctml index 6d663fd1b..3cdb4e3fc 100644 --- a/cli/tui/launchreview.ctml +++ b/cli/tui/launchreview.ctml @@ -25,7 +25,7 @@ whitespace ("Provider: ") survives. --> -

{{row.title}}

Provider: {{row.provider}}

Model: {{row.model}}

+

{{row.title}}

Provider: {{row.provider}}

Model: {{row.model}}

{{row.text}}

{{row.line}}

enter confirms · esc cancels

diff --git a/cli/tui/picker.ctml b/cli/tui/picker.ctml index 2503012bb..37054750a 100644 --- a/cli/tui/picker.ctml +++ b/cli/tui/picker.ctml @@ -25,7 +25,9 @@ separator — three cells per row, the same density the list model's default delegate paginates by (height 2 + spacing 1), so a bound page always fits the pane. The host truncates name and hint to the row - budget because
lines never wrap. Selection gutters are marker + budget because the page math requires exactly one line per
— a +
wraps to the render width, and a wrapped row would overflow the + page the delegate sized. Selection gutters are marker glyphs (› active, ○ idle): the parser drops whitespace-only runs between siblings, so a plain gutter cannot be expressed in markup. Single-row constructs stay on one source line so a text run's edge @@ -35,15 +37,15 @@

Models

Filter: {{row.value}}

-
{{row.name}}
+
○ {{row.name}}
{{row.detail}}
-
{{row.name}}
+
› {{row.name}}
{{row.detail}}
-
{{row.name}}
+
○ {{row.name}}
{{row.detail}}

{{row.text}}

diff --git a/cli/tui/picker.go b/cli/tui/picker.go index d96d757e6..447bba1d7 100644 --- a/cli/tui/picker.go +++ b/cli/tui/picker.go @@ -105,7 +105,9 @@ var pickerCTML []byte // strings — an row cannot vary its own class), plus the // zero-or-one-row conditional sections for the typed filter, the empty // state, and the page position. Name and hint are truncated host-side to -// the row budget because
lines never wrap. +// the row budget because the page math budgets exactly three cells per +// row — a
now wraps to the render width, and a wrapped row would +// overflow the page the list delegate sized. func modelPickerBindings(picker list.Model, width int) ctml.Bindings { sequences := map[string][]map[string]any{ "filter": {}, diff --git a/cli/tui/settings.ctml b/cli/tui/settings.ctml index 5c04b007b..b6a4f6a4f 100644 --- a/cli/tui/settings.ctml +++ b/cli/tui/settings.ctml @@ -25,15 +25,15 @@

settings

-
{{row.name}}{{row.value}}
+
○ {{row.name}} ‹ {{row.value}} ›
{{row.hint}}
-
{{row.name}}{{row.value}}
+
› {{row.name}} ‹ {{row.value}} ›
{{row.hint}}
-
{{row.name}}{{row.value}}
+
○ {{row.name}} ‹ {{row.value}} ›
{{row.hint}}

↑/↓ select · ←/→ change · values apply as hinted

diff --git a/cli/tui/tabs.ctml b/cli/tui/tabs.ctml index a1e7b5519..6f61afcc8 100644 --- a/cli/tui/tabs.ctml +++ b/cli/tui/tabs.ctml @@ -18,4 +18,4 @@ survives only when it does not touch a line break, and the inter-tab gaps (" ○ ") are significant cells in a one-row strip. --> - + diff --git a/cli/tui/tools.ctml b/cli/tui/tools.ctml index 5abb5c9d4..5e7bebcdb 100644 --- a/cli/tui/tools.ctml +++ b/cli/tui/tools.ctml @@ -4,12 +4,17 @@ list, recent call receipts, and the key footer). Host seams (tools.go): - - Sequences state / tools / recentTitle / recent : the enabled-state - line (one row — a lone dynamic value rides a one-row sequence), the - built-in tool rows, the conditional "recent calls" heading (zero or - one row — an over an empty sequence renders nothing, so a - section that appears only with data rides a zero-or-one-row - sequence), and the last five call receipts. + - Value state : the enabled-state text — an always-present lone + scalar rides Bindings.Values and binds document-scope ({{state}}); + the one-row-sequence carrier this line used before Values existed + is retired. A CONDITIONAL section still rides a zero-or-one-row + sequence: a Values miss renders empty but its surrounding literal + text would remain. + - Sequences tools / recentTitle / recent : the built-in tool rows, + the conditional "recent calls" heading (zero or one row — an + over an empty sequence renders nothing, so a section that appears + only with data rides a zero-or-one-row sequence), and the last five + call receipts. - Classes tool-label / tool-state / tool-name / tool-desc / tool-receipt / tool-keys : mapped onto the existing uiStyles palette by toolsPanelTheme. @@ -26,7 +31,7 @@ -->

tools

-

function calling: {{row.text}}

+

function calling: {{state}}

{{tool.name}}{{tool.desc}}

recent calls

{{row.receipt}}

diff --git a/cli/tui/tools.go b/cli/tui/tools.go index 9c5acc8fa..85759a872 100644 --- a/cli/tui/tools.go +++ b/cli/tui/tools.go @@ -112,8 +112,9 @@ func (t *toolState) execute(call inference.ToolCall) string { //go:embed tools.ctml var toolsCTML []byte -// toolsPanelBindings binds the tab's dynamic content: the enabled-state line -// (a lone dynamic value riding a one-row sequence), one row per built-in +// toolsPanelBindings binds the tab's dynamic content: the enabled-state +// text (an always-present lone scalar riding Bindings.Values — the one-row +// sequence it rode before Values existed is retired), one row per built-in // tool, and — only when calls have run — the "recent calls" heading (a // zero-or-one-row sequence, the conditional-section idiom) with the last // five receipts. The two-cell gutter between a tool name and its @@ -125,7 +126,6 @@ func toolsPanelBindings(state toolState) ctml.Bindings { stateText = "enabled — declarations ride the system turn; calls run locally and feed back" } sequences := map[string][]map[string]any{ - "state": {{"text": stateText}}, "tools": {}, "recentTitle": {}, "recent": {}, @@ -143,7 +143,7 @@ func toolsPanelBindings(state toolState) ctml.Bindings { sequences["recent"] = append(sequences["recent"], map[string]any{"receipt": receipt}) } } - return ctml.Bindings{Sequences: sequences} + return ctml.Bindings{Sequences: sequences, Values: map[string]any{"state": stateText}} } // toolsPanelTheme maps the markup's class tokens onto the existing palette, From 92c4c8440fec61902daa4b81248c78ea909a27c0 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 12:43:01 +0100 Subject: [PATCH 07/17] feat(tui): wire the Settings form in as an F2 overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (f74b414b), was folded into the contextual inspector when the tabs were reworked, and the standalone form was migrated to .ctml (f7b8e319) 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 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 --- cli/tui/README.md | 3 ++ cli/tui/app.go | 60 ++++++++++++++++++++++++++- cli/tui/keymap.go | 4 +- cli/tui/keymap_test.go | 1 + cli/tui/palette.go | 1 + cli/tui/settings.ctml | 2 +- cli/tui/settings_test.go | 88 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 154 insertions(+), 5 deletions(-) diff --git a/cli/tui/README.md b/cli/tui/README.md index ecfb0f39a..35f54f979 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -187,7 +187,10 @@ not a view — it composes nothing and has no rendering to migrate. | `Ctrl+O` | global | Toggle the inspector | | `Ctrl+S` | global | Commit inspector settings | | `F1` | global | Full key help | +| `F2` | global | Open the Settings form overlay (context length, max tokens, thinking) | | `Ctrl+C` | global | Cancel jobs, stop service, close resources, and quit | +| arrows or `h`/`j`/`k`/`l` | Settings overlay | Select a knob and change its value | +| `Ctrl+S` / `Esc` | Settings overlay | Save the generation knobs to preferences / close | | `Enter` | Chat | Send a non-empty prompt when a model is loaded | | `Alt+Enter` | Chat | Insert a newline in the composer | | `Esc` | Chat | Cancel the visible session's generation | diff --git a/cli/tui/app.go b/cli/tui/app.go index 5f229fb97..2879b21f3 100644 --- a/cli/tui/app.go +++ b/cli/tui/app.go @@ -1189,6 +1189,31 @@ func (a *app) rebuildTheme(selected theme) { } } +// commitGenerationSettings persists the three generation knobs the Settings +// overlay edits (context length, max tokens, thinking) to the SAME preference +// store the inspector's Save writes through — a.cfg is the single live source +// of truth for these values, so the overlay and the inspector never diverge. +func (a *app) commitGenerationSettings() core.Result { + if a == nil || a.preferences == nil { + return core.Fail(core.E("tui.app.commitGenerationSettings", "preference store is unavailable", nil)) + } + thinking := []string{"model", "on", "off"}[a.cfg.thinkIdx] + entries := []struct { + key string + value any + }{ + {preferenceContextLength, a.cfg.contextLen()}, + {preferenceMaxTokens, a.cfg.maxTokens()}, + {preferenceThinking, thinking}, + } + for _, entry := range entries { + if result := a.preferences.Set(entry.key, entry.value); !result.OK { + return result + } + } + return a.preferences.Commit() +} + func (a app) Init() tea.Cmd { cmds := []tea.Cmd{a.spin.Tick} if a.boot.phase == bootLoading { @@ -1918,6 +1943,13 @@ func (a app) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.activeOverlay = overlayHelp return a, nil } + if key.Matches(msg, a.keys.Settings) { + // The Settings form edits a.cfg live — the same value the inspector + // edits and generation reads — and opens fresh at the first knob. + a.cfg.cursor = 0 + a.activeOverlay = overlaySettings + return a, nil + } if key.Matches(msg, a.keys.NewSession) { if result := a.createSession(); !result.OK { a.errText = result.Error() @@ -2458,6 +2490,28 @@ func (a app) onOverlayKey(message tea.KeyMsg) (tea.Model, tea.Cmd) { a.activeOverlay, a.dataEditor = overlayNone, nil return a, nil } + if a.activeOverlay == overlaySettings { + // Cursor and knob adjustments run against a.cfg — the single live + // source of truth these values share with the inspector — so the two + // screens never diverge. Esc (handled above) closes without reverting + // the live edits, exactly as closing the inspector does; ctrl+s + // persists the three generation.* knobs to the same preference store. + switch message.String() { + case "ctrl+s": + if result := a.commitGenerationSettings(); !result.OK { + a.errText = result.Error() + } + case "up", "k": + a.cfg = a.cfg.move(-1) + case "down", "j": + a.cfg = a.cfg.move(1) + case "left", "h": + a.cfg = a.cfg.adjust(-1) + case "right", "l": + a.cfg = a.cfg.adjust(1) + } + return a, nil + } if a.activeOverlay == overlayGitEnableReview && message.String() != "enter" { if a.launchReview != nil { a.launchReview.Update(message) @@ -3294,6 +3348,8 @@ func (a app) overlayView() string { } else { body = a.changeOverlay.View(bodyWidth, bodyHeight, a.styles) } + case overlaySettings: + body = renderSettings(a.cfg, bodyWidth, a.styles) case overlayProjectReview: body = newLaunchReviewOverlay(a.agentReview, a.agentRequest.Provider, a.agentRequest.Model).View(bodyWidth, bodyHeight, a.styles) case overlayGitEnableReview, overlayLaunchReview, overlayAgentSelection: @@ -3407,9 +3463,9 @@ func (a app) inspectorView() string { } func (a app) footerLine() string { - keys := "tab panels · ctrl+k commands · ctrl+o inspector · f1 help" + keys := "tab panels · ctrl+k commands · ctrl+o inspector · f2 settings · f1 help" if chooseLayout(a.width) == layoutNarrow { - keys = "tab panels · ^K commands · ^O info · F1 help" + keys = "tab panels · ^K commands · ^O info · F2 settings · F1 help" } status := a.statusLine() if status == "" { diff --git a/cli/tui/keymap.go b/cli/tui/keymap.go index 93291ad0a..7858e4423 100644 --- a/cli/tui/keymap.go +++ b/cli/tui/keymap.go @@ -13,6 +13,7 @@ type keyMap struct { ToggleInspector key.Binding Search key.Binding Save key.Binding + Settings key.Binding Help key.Binding } @@ -26,6 +27,7 @@ func newKeyMap() keyMap { ToggleInspector: key.NewBinding(key.WithKeys("ctrl+o"), key.WithHelp("ctrl+o", "inspector")), Search: key.NewBinding(key.WithKeys("ctrl+f"), key.WithHelp("ctrl+f", "search")), Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")), + Settings: key.NewBinding(key.WithKeys("f2"), key.WithHelp("f2", "settings")), Help: key.NewBinding(key.WithKeys("f1"), key.WithHelp("f1", "help")), } } @@ -37,6 +39,6 @@ func (keys keyMap) ShortHelp() []key.Binding { func (keys keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {keys.NewSession, keys.SwitchSession, keys.PreviousSession, keys.NextSession}, - {keys.CommandPalette, keys.ToggleInspector, keys.Search, keys.Save, keys.Help}, + {keys.CommandPalette, keys.ToggleInspector, keys.Search, keys.Save, keys.Settings, keys.Help}, } } diff --git a/cli/tui/keymap_test.go b/cli/tui/keymap_test.go index b076c86ab..fa701d60a 100644 --- a/cli/tui/keymap_test.go +++ b/cli/tui/keymap_test.go @@ -24,6 +24,7 @@ func TestKeyMap_Good(t *testing.T) { {"inspector", tea.KeyMsg{Type: tea.KeyCtrlO}, keys.ToggleInspector}, {"search", tea.KeyMsg{Type: tea.KeyCtrlF}, keys.Search}, {"save", tea.KeyMsg{Type: tea.KeyCtrlS}, keys.Save}, + {"settings", tea.KeyMsg{Type: tea.KeyF2}, keys.Settings}, {"help", tea.KeyMsg{Type: tea.KeyF1}, keys.Help}, } for _, test := range tests { diff --git a/cli/tui/palette.go b/cli/tui/palette.go index 9f8e76be8..ee4600b58 100644 --- a/cli/tui/palette.go +++ b/cli/tui/palette.go @@ -810,6 +810,7 @@ const ( overlayDataNote overlayDataFilter overlayDataBulk + overlaySettings ) type helpOverlay struct { diff --git a/cli/tui/settings.ctml b/cli/tui/settings.ctml index 5c04b007b..92ce01c45 100644 --- a/cli/tui/settings.ctml +++ b/cli/tui/settings.ctml @@ -36,5 +36,5 @@
{{row.name}}{{row.value}}
{{row.hint}}
-

↑/↓ select · ←/→ change · values apply as hinted

+

↑/↓ select · ←/→ change · ctrl+s saves · esc closes

diff --git a/cli/tui/settings_test.go b/cli/tui/settings_test.go index 30c758c28..b36f229d2 100644 --- a/cli/tui/settings_test.go +++ b/cli/tui/settings_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + coreio "dappco.re/go/io" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) @@ -40,7 +42,7 @@ func TestRenderSettings_Good(t *testing.T) { t.Fatalf("form missing the hint %q: %q", text, plain) } } - if !strings.Contains(plain, "↑/↓ select · ←/→ change · values apply as hinted") { + if !strings.Contains(plain, "↑/↓ select · ←/→ change · ctrl+s saves · esc closes") { t.Fatalf("form missing the key footer: %q", plain) } @@ -101,3 +103,87 @@ func TestRenderSettings_Ugly(t *testing.T) { t.Fatalf("the sequence split must preserve row order: %q", plain) } } + +// TestSettingsOverlay_Good drives the wired Settings screen end to end: F2 +// opens it in the overlay layer, a knob adjust edits a.cfg live and persists +// to the real preference store on ctrl+s, and esc closes it cleanly. +func TestSettingsOverlay_Good(t *testing.T) { + medium := coreio.NewMockMedium() + opened := openPreferences(medium, appConfigPath) + if !opened.OK { + t.Fatalf("open preferences: %v", opened.Value) + } + preferences := opened.Value.(preferenceStore) + a := newApp("", 0, 4096) + a.attachPreferences(preferences) + m, _ := a.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + a = m.(app) + + // F2 opens the Settings overlay. + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyF2}) + a = m.(app) + if a.activeOverlay != overlaySettings { + t.Fatalf("F2 did not open the settings overlay: overlay=%d", a.activeOverlay) + } + + // The form renders in the overlay layer — the KV-cache hint is unique to + // the settings form (it is not in the footer or any other screen). + if plain := ansi.Strip(a.View()); !strings.Contains(plain, "KV cache size") { + t.Fatalf("settings form not rendered in the overlay layer:\n%s", plain) + } + + // Navigate to the max-tokens row and bump it one step; the edit lands on + // a.cfg immediately, exactly as the value hint promises. + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyDown}) + a = m.(app) + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyRight}) + a = m.(app) + if a.cfg.maxTokens() != 8192 { + t.Fatalf("adjust did not raise max tokens live: %d", a.cfg.maxTokens()) + } + + // Ctrl+S commits the generation knobs through the same store the + // inspector writes; reopening the store proves the round-trip. + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + a = m.(app) + if a.errText != "" { + t.Fatalf("ctrl+s reported an error: %q", a.errText) + } + reopened := openPreferences(medium, appConfigPath) + if !reopened.OK { + t.Fatalf("reopen preferences: %v", reopened.Value) + } + if values := reopened.Value.(preferenceStore).Values(); values.MaxTokens != 8192 { + t.Fatalf("adjust did not round-trip through the store: %#v", values) + } + + // Esc closes the overlay without disturbing the live edit. + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyEsc}) + a = m.(app) + if a.activeOverlay != overlayNone { + t.Fatalf("esc did not close the settings overlay: overlay=%d", a.activeOverlay) + } + if a.cfg.maxTokens() != 8192 { + t.Fatalf("esc reverted the live edit: %d", a.cfg.maxTokens()) + } +} + +// TestSettingsOverlay_Bad proves the commit path fails loudly when no +// preference store is connected — the overlay stays open and surfaces the +// reason rather than silently discarding the change. +func TestSettingsOverlay_Bad(t *testing.T) { + a := newApp("", 0, 4096) + m, _ := a.Update(tea.KeyMsg{Type: tea.KeyF2}) + a = m.(app) + if a.activeOverlay != overlaySettings { + t.Fatalf("F2 did not open the settings overlay: overlay=%d", a.activeOverlay) + } + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + a = m.(app) + if a.errText == "" { + t.Fatal("ctrl+s without a store did not surface an error") + } + if a.activeOverlay != overlaySettings { + t.Fatalf("a failed commit closed the overlay: overlay=%d", a.activeOverlay) + } +} From 4c40cf3b54b488dacd261c68693102b534a9ad2d Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 12:56:36 +0100 Subject: [PATCH 08/17] =?UTF-8?q?build(cli):=20go-html=20v0.13.0=20?= =?UTF-8?q?=E2=80=94=20row-scoped=20attribute=20binds,=20FitSlots,=20gutte?= =?UTF-8?q?r/indent=20doctrine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli/go.mod | 2 +- cli/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/go.mod b/cli/go.mod index 12e0302c3..b6fe26ef6 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -33,7 +33,7 @@ require ( charm.land/log/v2 v2.0.0 // indirect charm.land/wish/v2 v2.0.0 // indirect dappco.re/go/cgo v0.11.2 // indirect - dappco.re/go/html v0.12.0 // direct: cli/tui renders .ctml (tabs.go) + dappco.re/go/html v0.13.0 // direct: cli/tui renders .ctml (tabs.go) dappco.re/go/i18n v0.12.1 // indirect dappco.re/go/log v0.13.1 // indirect dappco.re/go/ratelimit v0.12.1 // indirect diff --git a/cli/go.sum b/cli/go.sum index 7e78e88c1..3c6fe6b83 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -24,6 +24,8 @@ dappco.re/go/html v0.11.0 h1:Qau6MinQATThPmMamnzyaSV06mr10QzDdAcvkIqwlBk= dappco.re/go/html v0.11.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= dappco.re/go/html v0.12.0 h1:/5hDG/AcOrFagkGk/7BeRnIichJ6+2qqRAbEdBgYrfE= dappco.re/go/html v0.12.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= +dappco.re/go/html v0.13.0 h1:EgMdKzZ2mX++2tlNSVk2VrexKM74SmwAAtlwSuG0cSA= +dappco.re/go/html v0.13.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= dappco.re/go/i18n v0.12.1 h1:Pm1DF9I0O8bckTr815OrnR+G+7EvbQhP2Vk4ItC0mB4= dappco.re/go/i18n v0.12.1/go.mod h1:AYx1QkPfW+qvzLCfJnuOlc4J+grwlgFzoHnoke4TAYg= dappco.re/go/inference v0.14.0 h1:3Lddj4W8T7/Mg8iTmvS+hCUkUqYYhRPYGe6i/ILF1+o= From 7ac49cef59964aaa3dbeb8956780f008cfd5d066 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 13:27:55 +0100 Subject: [PATCH 09/17] feat(tui): Data panel and inspector render through ctml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 : 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

-per-section +
-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

above a , 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 --- cli/tui/datadetail.ctml | 54 ++++++ cli/tui/datalist.ctml | 41 +++++ cli/tui/datapanel.go | 315 ++++++++++++++++++++++----------- cli/tui/inspector.ctml | 54 ++++++ cli/tui/inspector.go | 377 ++++++++++++++++++++++++---------------- cli/tui/overlayframe.go | 39 +++-- 6 files changed, 612 insertions(+), 268 deletions(-) create mode 100644 cli/tui/datadetail.ctml create mode 100644 cli/tui/datalist.ctml create mode 100644 cli/tui/inspector.ctml diff --git a/cli/tui/datadetail.ctml b/cli/tui/datadetail.ctml new file mode 100644 index 000000000..30450638b --- /dev/null +++ b/cli/tui/datadetail.ctml @@ -0,0 +1,54 @@ + + +

+

Select an item for its content, scores, and lineage.

+

{{row.slug}} {{row.status}}

+

{{field.label}}{{field.value}}

+

! WELFARE FLAG

{{flag.note}}

+

CONTENT

+ +

SCORES

+ +

REVIEW

{{line.s}}{{line.t}}{{line.v}}

{{note.text}}

+

LINEAGE

{{line.s}}{{line.v}}{{line.t}}

○ no lineage — an original item

+
diff --git a/cli/tui/datalist.ctml b/cli/tui/datalist.ctml new file mode 100644 index 000000000..696323afb --- /dev/null +++ b/cli/tui/datalist.ctml @@ -0,0 +1,41 @@ + + + +

DATA {{meta}} filter {{row.expr}}

+

{{row.cursor}}{{row.status}} {{row.meta}} {{row.slug}}

○ No items match this filter

Import or capture data with `lem data import` / `lem serve --capture`.

/ filter · j/k select · f filters · s sort · a/r/c/e/t act · A/R/C/T bulk

+
diff --git a/cli/tui/datapanel.go b/cli/tui/datapanel.go index 72c085daa..d6483bd20 100644 --- a/cli/tui/datapanel.go +++ b/cli/tui/datapanel.go @@ -3,14 +3,18 @@ package tui import ( + _ "embed" "sort" "time" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" core "dappco.re/go" + "dappco.re/go/html" + "dappco.re/go/html/ctml" "dappco.re/go/inference/dataset" ) @@ -515,109 +519,207 @@ func (panel *dataPanel) View(width, height int, styles uiStyles) string { return fitPane(view, width, height, styles.panel) } -func (panel *dataPanel) renderList(width, height int, styles uiStyles) string { - builder := core.NewBuilder() - builder.WriteString(styles.title.Render("DATA")) - builder.WriteString(" ") +// dataListCTML is the list pane's markup — see datalist.ctml for the seams +// it exposes (the meta value, the row/filter/empty sequences, class tokens). +// +//go:embed datalist.ctml +var dataListCTML []byte + +// dataDetailCTML is the detail pane's markup — see datadetail.ctml for the +// seams it exposes (the head gate, field/review/lineage sequences, the +// content/scores verbatim values, class tokens). +// +//go:embed datadetail.ctml +var dataDetailCTML []byte + +// dataListBindings binds the list pane: the always-present meta line +// (Bindings.Values), the conditional filter note, and ONE row per visible +// item — selection styling rides the row-scoped class bind +// (class="{{row.state}}"), so no before/active/after sequence split is +// needed. The cursor marker is bound ("› " active, "○ " idle: a row can +// open the F band, whose leading edge trims, so a bare two-space gutter +// cannot survive there); the slug is truncated to the remaining row budget +// because a wrapped row would break the one-line-per-item list shape. +func dataListBindings(panel *dataPanel, width int) ctml.Bindings { sortLabel := "date" if panel.sort == dataSortScore { sortLabel = "score" } - builder.WriteString(styles.status.Render(core.Sprintf("%d items · sort %s", len(panel.rows), sortLabel))) - if filterExpr := panel.filter.String(); filterExpr != "" { - builder.WriteString(" ") - builder.WriteString(styles.thought.Render("filter " + filterExpr)) + sequences := map[string][]map[string]any{ + "filterNote": {}, + "rows": {}, + "empty": {}, } - builder.WriteString("\n") - if panel.list.SettingFilter() || panel.list.FilterState() == list.FilterApplied { - builder.WriteString(panel.list.FilterInput.View()) - builder.WriteString("\n") + if filterExpr := panel.filter.String(); filterExpr != "" { + sequences["filterNote"] = append(sequences["filterNote"], map[string]any{"expr": filterExpr}) } visible := panel.list.VisibleItems() - if len(visible) == 0 { - builder.WriteString("\n") - builder.WriteString(styles.status.Render("○ No items match this filter")) - builder.WriteString("\n") - builder.WriteString(styles.thought.Render("Import or capture data with `lem data import` / `lem serve --capture`.")) - return fitPane(builder.String(), width, height, styles.panel) - } for index, raw := range visible { item, ok := raw.(dataListItem) if !ok { continue } - cursor := " " - rowStyle := styles.answer + state, cursor := "row-idle", "○ " if index == panel.list.Index() { - cursor = "› " - rowStyle = styles.accent + state, cursor = "row-active", "› " } score := "—" if item.row.HasScore { score = core.Sprintf("%.0f", item.row.TopScore.Value) } - builder.WriteString(cursor) - builder.WriteString(styles.status.Render(dataStatusGlyph(item.row.Review.Status) + " " + core.Upper(string(item.row.Review.Status)))) - builder.WriteString(" ") - builder.WriteString(styles.thought.Render(string(item.row.Item.Kind) + " · " + score)) - builder.WriteString(" ") - builder.WriteString(rowStyle.Render(item.row.Dataset.Slug)) - builder.WriteString("\n") - } - builder.WriteString(styles.thought.Render("/ filter · j/k select · f filters · s sort · a/r/c/e/t act · A/R/C/T bulk")) - return fitPane(builder.String(), width, height, styles.panel) + status := dataStatusGlyph(item.row.Review.Status) + " " + core.Upper(string(item.row.Review.Status)) + meta := string(item.row.Item.Kind) + " · " + score + // The F band renders its blocks at width-2 (the frame's own inner + // margin), so the row budget subtracts that before the cursor, the + // two fixed segments, and their two 2-cell gaps. + budget := max(1, width-2-lipgloss.Width(cursor)-lipgloss.Width(status)-lipgloss.Width(meta)-4) + sequences["rows"] = append(sequences["rows"], map[string]any{ + "state": state, + "cursor": cursor, + "status": status, + "meta": meta, + "slug": ansi.Truncate(item.row.Dataset.Slug, budget, "…"), + }) + } + if len(visible) == 0 { + sequences["empty"] = append(sequences["empty"], map[string]any{}) + } + return ctml.Bindings{ + Sequences: sequences, + Values: map[string]any{"meta": core.Sprintf("%d items · sort %s", len(panel.rows), sortLabel)}, + } } -func (panel *dataPanel) renderDetail(width, height int, styles uiStyles) string { +// dataListTheme maps the list markup's class tokens onto the existing +// palette. Both bands render plain — no border, no footer top padding — so +// with no filter widget between them the bands join back into the +// contiguous list this panel always drew. +func dataListTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Text = styles.answer + theme.Header = lipgloss.NewStyle() + theme.Footer = lipgloss.NewStyle() + theme.Classes = map[string]lipgloss.Style{ + "list-title": styles.title, + "list-meta": styles.status, + "list-filter": styles.thought, + "row-active": styles.accent, + "row-idle": styles.answer, + "row-status": styles.status, + "row-meta": styles.thought, + "list-empty": styles.status, + "list-hint": styles.thought, + "list-keys": styles.thought, + } + return theme +} + +// renderList renders the list pane through datalist.ctml: the header band, +// the live Bubbles filter input composed between the bands while filtering +// (the overlays' HF chrome+widget idiom applied to a panel), and the +// row/empty/footer band beneath it. +func (panel *dataPanel) renderList(width, height int, styles uiStyles) string { + head, foot := renderBandFrame(dataListCTML, width, dataListTheme(styles), dataListBindings(panel, width)) + parts := []string{head} + if panel.list.SettingFilter() || panel.list.FilterState() == list.FilterApplied { + parts = append(parts, panel.list.FilterInput.View()) + } + parts = append(parts, foot) + return fitPane(core.Join("\n", parts...), width, height, styles.panel) +} + +// dataDetailBindings binds the detail pane: the zero-or-one-row head gate +// (its absence renders the no-selection hint instead), the non-empty +// metadata fields, the welfare flag, the review and lineage line triples, +// and the two always-present verbatim values carrying the pre-styled +// Glamour CONTENT/SCORES bodies (empty when nothing is selected — a +// value must exist at parse time). +func (panel *dataPanel) dataDetailBindings(width int) ctml.Bindings { + sequences := map[string][]map[string]any{ + "noselect": {}, + "head": {}, + "fields": {}, + "welfare": {}, + "reviewRows": {}, + "reviewNote": {}, + "lineageRows": {}, + "lineageEmpty": {}, + } + values := map[string]any{"content": "", "scores": ""} row, ok := panel.Selected() if !ok { - return styles.status.Render("Select an item for its content, scores, and lineage.") - } - builder := core.NewBuilder() - builder.WriteString(styles.title.Render(row.Dataset.Slug)) - builder.WriteString(" ") - builder.WriteString(styles.status.Render(dataStatusGlyph(row.Review.Status) + " " + core.Upper(string(row.Review.Status)))) - builder.WriteString("\n\n") - dataDetailRow(builder, styles, "kind", string(row.Item.Kind)) - dataDetailRow(builder, styles, "source", string(row.Item.Source)) - dataDetailRow(builder, styles, "source ref", row.Item.SourceRef) - dataDetailRow(builder, styles, "fingerprint", row.Item.ModelFingerprint) - dataDetailRow(builder, styles, "created", row.Item.CreatedAt.Format(time.RFC3339)) + sequences["noselect"] = append(sequences["noselect"], map[string]any{}) + return ctml.Bindings{Sequences: sequences, Values: values} + } + sequences["head"] = append(sequences["head"], map[string]any{ + "slug": row.Dataset.Slug, + "status": dataStatusGlyph(row.Review.Status) + " " + core.Upper(string(row.Review.Status)), + }) + field := func(label, value string) { + if value == "" { + return + } + sequences["fields"] = append(sequences["fields"], map[string]any{"label": label + " ", "value": value}) + } + field("kind", string(row.Item.Kind)) + field("source", string(row.Item.Source)) + field("source ref", row.Item.SourceRef) + field("fingerprint", row.Item.ModelFingerprint) + field("created", row.Item.CreatedAt.Format(time.RFC3339)) if row.Review.Reviewer == dataset.ReviewerAutoWelfare { - builder.WriteString("\n") - builder.WriteString(styles.attention.Render("! WELFARE FLAG")) - builder.WriteString("\n") note := "the welfare screen quarantined this item at ingest" if row.Review.Note != "" { note = core.Concat(note, " — ", row.Review.Note) } - builder.WriteString(styles.answer.Render(note)) - builder.WriteString("\n") + sequences["welfare"] = append(sequences["welfare"], map[string]any{"note": note}) } - builder.WriteString("\n") - builder.WriteString(styles.accent.Render("CONTENT")) - builder.WriteString("\n") - builder.WriteString(panel.renderContent(row.Item, width)) - builder.WriteString("\n") - - builder.WriteString("\n") - builder.WriteString(styles.accent.Render("SCORES")) - builder.WriteString("\n") - builder.WriteString(panel.renderScores(row.Item.ID, width)) + // The trailing newline is the blank line separating each body from the + // next section heading — it travels inside the verbatim bytes because + // the caller owns them. + values["content"] = panel.renderContent(row.Item, width) + "\n" + values["scores"] = panel.renderScores(row.Item.ID, width) + "\n" - builder.WriteString("\n") - builder.WriteString(styles.accent.Render("REVIEW")) - builder.WriteString("\n") - builder.WriteString(panel.renderReviewHistory(row.Item.ID, styles)) + sequences["reviewRows"], sequences["reviewNote"] = panel.reviewHistoryRows(row.Item.ID) + sequences["lineageRows"], sequences["lineageEmpty"] = panel.lineageRows(row.Item) + return ctml.Bindings{Sequences: sequences, Values: values} +} - builder.WriteString("\n") - builder.WriteString(styles.accent.Render("LINEAGE")) - builder.WriteString("\n") - builder.WriteString(panel.renderLineage(row.Item, styles)) +// dataDetailTheme maps the detail markup's class tokens onto the existing +// palette, so the .ctml render reuses uiStyles paint exactly — no colours +// of its own. +func dataDetailTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Text = styles.answer + theme.Classes = map[string]lipgloss.Style{ + "detail-empty": styles.status, + "detail-title": styles.title, + "detail-status": styles.status, + "detail-section": styles.accent, + "detail-warn": styles.attention, + "field-label": styles.status, + "field-value": styles.answer, + "row-status": styles.status, + "row-thought": styles.thought, + "row-answer": styles.answer, + } + return theme +} - return fitPane(builder.String(), width, height, styles.panel) +// renderDetail renders the detail pane through datadetail.ctml: the item +// header and metadata, the welfare flag, the Glamour CONTENT/SCORES bodies +// passing through byte-for-byte, and the review/lineage lines. +func (panel *dataPanel) renderDetail(width, height int, styles uiStyles) string { + tree, err := ctml.Parse(dataDetailCTML, panel.dataDetailBindings(width)) + if err != nil { + // datadetail.ctml is embedded and static, so a parse failure is a + // build defect; TestDataPanel_View_Ugly pins the markup as + // parseable. + return "" + } + rendered := html.RenderTerm(tree, html.NewContext(), html.TermOptions{Width: width, Theme: dataDetailTheme(styles)}) + return fitPane(rendered, width, height, styles.panel) } // renderContent renders an item's prompt/response (or, for a KindTrace @@ -708,52 +810,62 @@ func prettyJSON(raw []byte) string { return core.AsString(raw) } -// renderReviewHistory prefers the full append-only history (see +// reviewHistoryRows prefers the full append-only history (see // [ReviewHistoryStore], a CLI-side optional capability duckDatasetStore // implements) and falls back to ReviewLatest — the one call every // dataset.Store implementation (including the root-module MemoryStore) -// supports — when the connected store does not expose it. -func (panel *dataPanel) renderReviewHistory(itemID string, styles uiStyles) string { +// supports — when the connected store does not expose it. Each review is +// one {s, t, v} line row (status-styled head, thought-styled timestamp) +// plus, when it carries a note, a follow-on row whose indent travels in +// the bound value; the second sequence is the zero-or-one-row +// pending/unavailable state. +func (panel *dataPanel) reviewHistoryRows(itemID string) (rows, note []map[string]any) { + rows, note = []map[string]any{}, []map[string]any{} + appendReview := func(review dataset.Review) { + rows = append(rows, map[string]any{ + "s": core.Sprintf("· %-12s %-14s ", review.Status, review.Reviewer), + "t": review.CreatedAt.Format(time.RFC3339), + "v": "", + }) + if review.Note != "" { + rows = append(rows, map[string]any{"s": "", "t": "", "v": " " + review.Note}) + } + } if historyStore, ok := panel.store.(ReviewHistoryStore); ok { if historyResult := historyStore.ReviewHistory(itemID); historyResult.OK { if history, ok := historyResult.Value.([]dataset.Review); ok && len(history) > 0 { - lines := make([]string, 0, len(history)) for _, review := range history { - lines = append(lines, dataReviewLine(review, styles)) + appendReview(review) } - return core.Join("\n", lines...) + "\n" + return rows, note } } } latestResult := panel.store.ReviewLatest(itemID) if !latestResult.OK { - return styles.status.Render("○ review unavailable: " + latestResult.Error()) + note = append(note, map[string]any{"text": "○ review unavailable: " + latestResult.Error()}) + return rows, note } latest, ok := latestResult.Value.(dataset.Review) if !ok || latest.Status == "" || latest.Status == dataset.StatusPending { - return styles.status.Render("○ pending review") - } - return dataReviewLine(latest, styles) + "\n" -} - -func dataReviewLine(review dataset.Review, styles uiStyles) string { - line := styles.status.Render(core.Sprintf("· %-12s %-14s ", review.Status, review.Reviewer)) + - styles.thought.Render(review.CreatedAt.Format(time.RFC3339)) - if review.Note != "" { - line = core.Concat(line, "\n ", styles.answer.Render(review.Note)) + note = append(note, map[string]any{"text": "○ pending review"}) + return rows, note } - return line + appendReview(latest) + return rows, note } -// renderLineage shows the item's parent (if edit-as-derived produced it) +// lineageRows shows the item's parent (if edit-as-derived produced it) // and any derived children — items in the SAME dataset whose // ParentItemID names this one. dataset.Store has no "children of X" // query, so children are found by scanning the dataset's full item list // (including archived — the original is archived once superseded); an // honest O(dataset size) scan, acceptable for a human review surface, not -// a hot path. -func (panel *dataPanel) renderLineage(item dataset.Item, styles uiStyles) string { - lines := make([]string, 0, 2) +// a hot path. Each line is one {s, v, t} row (status label, answer id, +// thought note); the second sequence is the zero-or-one-row no-lineage +// state. +func (panel *dataPanel) lineageRows(item dataset.Item) (rows, empty []map[string]any) { + rows, empty = []map[string]any{}, []map[string]any{} if item.ParentItemID != "" { if parentResult := panel.store.Item(item.ParentItemID); parentResult.OK { if parent, ok := parentResult.Value.(dataset.Item); ok { @@ -761,7 +873,7 @@ func (panel *dataPanel) renderLineage(item dataset.Item, styles uiStyles) string if parent.Archived { note = " (archived)" } - lines = append(lines, styles.status.Render("parent ")+styles.answer.Render(parent.ID)+styles.thought.Render(note)) + rows = append(rows, map[string]any{"s": "parent ", "v": parent.ID, "t": note}) } } } @@ -769,24 +881,15 @@ func (panel *dataPanel) renderLineage(item dataset.Item, styles uiStyles) string if siblings, ok := childrenResult.Value.([]dataset.Item); ok { for _, candidate := range siblings { if candidate.ParentItemID == item.ID { - lines = append(lines, styles.status.Render("derived ")+styles.answer.Render(candidate.ID)) + rows = append(rows, map[string]any{"s": "derived ", "v": candidate.ID, "t": ""}) } } } } - if len(lines) == 0 { - return styles.status.Render("○ no lineage — an original item") - } - return core.Join("\n", lines...) + "\n" -} - -func dataDetailRow(builder *core.Builder, styles uiStyles, label, value string) { - if value == "" { - return + if len(rows) == 0 { + empty = append(empty, map[string]any{}) } - builder.WriteString(styles.status.Render(label + " ")) - builder.WriteString(styles.answer.Render(value)) - builder.WriteString("\n") + return rows, empty } // currentReviewer identifies the human reviewer a manual Approve/Reject/ diff --git a/cli/tui/inspector.ctml b/cli/tui/inspector.ctml new file mode 100644 index 000000000..90bb2ab4f --- /dev/null +++ b/cli/tui/inspector.ctml @@ -0,0 +1,54 @@ + + +
+

INSPECTOR

+

SESSION ● active

MODEL {{body.model}}

GENERATION {{body.generation}}

+

SETTINGS

{{row.cursor}}{{row.label}} ‹ {{row.value}} ›

+

MODE

{{row.cursor}}{{row.label}} ‹ {{row.value}} ›

+

TOOLS

{{row.cursor}}{{row.label}} ‹ {{row.value}} ›

+

KNOWLEDGE

{{row.text}}

+

● unsaved · ctrl+s

+

WORK DETAIL

{{row.text}}

+

RUNTIME

{{row.text}}

+

AGENT CAPABILITY

{{row.text}}

+

{{row.text}}

+

MODEL DETAIL

{{row.text}}

+

LOADED {{body.loaded}}

+

ADDRESS

{{body.addr}}

+

REQUESTS {{body.requests}}

+

STATE {{body.state}}

+
diff --git a/cli/tui/inspector.go b/cli/tui/inspector.go index 077665675..900dc5e12 100644 --- a/cli/tui/inspector.go +++ b/cli/tui/inspector.go @@ -2,7 +2,15 @@ package tui -import core "dappco.re/go" +import ( + _ "embed" + + "github.com/charmbracelet/lipgloss" + + core "dappco.re/go" + "dappco.re/go/html" + "dappco.re/go/html/ctml" +) type inspectorControl uint8 @@ -124,66 +132,234 @@ func (inspector *inspectorState) Save(target *app) core.Result { return core.Ok(nil) } +// inspectorCTML is the inspector's markup — see inspector.ctml for the +// seams it exposes (the four body gates, the knob/line sequences, class +// tokens). +// +//go:embed inspector.ctml +var inspectorCTML []byte + +// View renders the contextual inspector through inspector.ctml: exactly one +// of the four panel-shaped bodies is gated in by the active panel, and the +// render is fitted to the pane as before. func (inspector inspectorState) View(target app, width, height int) string { if width <= 0 || height <= 0 { return "" } - var builder core.Builder - builder.WriteString(target.styles.title.Render("INSPECTOR") + "\n\n") + tree, err := ctml.Parse(inspectorCTML, inspector.bindings(target)) + if err != nil { + // inspector.ctml is embedded and static, so a parse failure is a + // build defect; TestInspector_Good pins the markup as parseable. + return "" + } + rendered := html.RenderTerm(tree, html.NewContext(), html.TermOptions{Width: width, Theme: inspectorTheme(target.styles)}) + return fitPane(rendered, width, height, target.styles.inspector) +} + +// inspectorTheme maps the inspector markup's class tokens onto the existing +// palette, so the .ctml render reuses uiStyles paint exactly — no colours +// of its own. Unclassed text (the bound status values) takes the pane's own +// inspector style, exactly as the raw builder text previously inherited it +// from fitPane. +func inspectorTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Text = styles.inspector + theme.Classes = map[string]lipgloss.Style{ + "inspector-title": styles.title, + "label": styles.accent, + "control-active": styles.accent, + "control-idle": styles.status, + "control-value": styles.title, + "c-title": styles.title, + "c-status": styles.status, + "c-thought": styles.thought, + "c-attention": styles.attention, + "c-success": styles.success, + "c-answer": styles.answer, + } + return theme +} + +// bindings assembles the inspector's sequences for the active panel: the +// panel's body gate holds one row, every other gate stays empty, so the +// document renders exactly one body. +func (inspector inspectorState) bindings(target app) ctml.Bindings { + sequences := map[string][]map[string]any{ + "chatBody": {}, "chatSettings": {}, "chatMode": {}, "chatTools": {}, + "chatKnowledge": {}, "chatDirty": {}, + "workBody": {}, "workHead": {}, "workRuntime": {}, "workCap": {}, "workFeatures": {}, + "modelsBody": {}, "modelsHead": {}, + "serviceBody": {}, + } switch target.activePanel { case panelWork: - inspector.renderWork(&builder, target) + inspector.workBindings(target, sequences) case panelModels: - builder.WriteString(target.styles.accent.Render("MODEL DETAIL") + "\n") + modelsHead := sequences["modelsHead"] if selected, ok := target.picker.SelectedItem().(modelItem); ok { - builder.WriteString(target.styles.title.Render(selected.name) + "\n") - builder.WriteString(target.styles.status.Render(selected.modelType) + "\n") - builder.WriteString(target.styles.thought.Render(selected.path) + "\n\n") + modelsHead = append(modelsHead, + map[string]any{"class": "c-title", "text": selected.name}, + map[string]any{"class": "c-status", "text": selected.modelType}, + map[string]any{"class": "c-thought", "text": selected.path}, + ) } else { - builder.WriteString(target.styles.status.Render("○ select a discovered model") + "\n\n") + modelsHead = append(modelsHead, map[string]any{"class": "c-status", "text": "○ select a discovered model"}) } - builder.WriteString(target.styles.accent.Render("LOADED") + " ") - if target.modelName == "" { - builder.WriteString(target.styles.status.Render("○ none")) - } else { - builder.WriteString(target.styles.success.Render("● " + target.modelName)) + sequences["modelsHead"] = modelsHead + loadedClass, loaded := "c-status", "○ none" + if target.modelName != "" { + loadedClass, loaded = "c-success", "● "+target.modelName } + sequences["modelsBody"] = append(sequences["modelsBody"], map[string]any{"loadedClass": loadedClass, "loaded": loaded}) case panelService: - builder.WriteString(target.styles.accent.Render("ADDRESS") + "\n") - builder.WriteString(target.styles.title.Render(target.svc.addr()) + "\n\n") - builder.WriteString(target.styles.accent.Render("REQUESTS") + " ") - builder.WriteString(target.styles.title.Render(core.Sprintf("%d", target.svc.requests.Load())) + "\n\n") - builder.WriteString(target.styles.accent.Render("STATE") + " ") + stateClass, state := "c-status", "○ stopped" if target.svc.running { - builder.WriteString(target.styles.success.Render("● serving")) - } else { - builder.WriteString(target.styles.status.Render("○ stopped")) + stateClass, state = "c-success", "● serving" } + sequences["serviceBody"] = append(sequences["serviceBody"], map[string]any{ + "addr": target.svc.addr(), + "requests": core.Sprintf("%d", target.svc.requests.Load()), + "stateClass": stateClass, + "state": state, + }) + default: + inspector.chatBindings(target, sequences) + } + return ctml.Bindings{Sequences: sequences} +} + +// controlRow is one knob row: the cursor and selection class ride the row +// (class="{{row.state}}"), so a single sequence serves every knob. +func (inspector inspectorState) controlRow(control inspectorControl, label, value string) map[string]any { + state, cursor := "control-idle", " " + if inspector.cursor == int(control) { + state, cursor = "control-active", "› " + } + return map[string]any{"state": state, "cursor": cursor, "label": label, "value": value} +} + +// chatBindings fills the Chat body: the status head, the knob rows, and the +// flattened knowledge lines. +func (inspector inspectorState) chatBindings(target app, sequences map[string][]map[string]any) { + model := "○ none" + if target.modelName != "" { + model = "● " + target.modelName + } + generation := "○ idle" + if target.generating { + generation = "◉ generating" + } + sequences["chatBody"] = append(sequences["chatBody"], map[string]any{"model": model, "generation": generation}) + sequences["chatSettings"] = append(sequences["chatSettings"], + inspector.controlRow(inspectorControlContext, "context", core.Sprintf("%d", target.cfg.contextLen())), + inspector.controlRow(inspectorControlMaxTokens, "max tokens", core.Sprintf("%d", target.cfg.maxTokens())), + inspector.controlRow(inspectorControlThinking, "thinking", thinkNames[target.cfg.thinkIdx]), + inspector.controlRow(inspectorControlTheme, "theme", inspector.Theme()), + ) + sequences["chatMode"] = append(sequences["chatMode"], + inspector.controlRow(inspectorControlMode, "sampling", target.modes.current().name)) + tools := "○ disabled" + if target.tools.enabled { + tools = "● enabled" + } + sequences["chatTools"] = append(sequences["chatTools"], + inspector.controlRow(inspectorControlTools, "function calls", tools)) + + knowledge := sequences["chatKnowledge"] + line := func(class, text string) { + knowledge = append(knowledge, map[string]any{"class": class, "text": text}) + } + switch { + case !inspector.knowledge.ready: + line("c-status", " ○ discovery pending") + case len(inspector.knowledge.documents) == 0: + line("c-status", " ○ no local documents") default: - inspector.renderChat(&builder, target) + line("c-success", core.Sprintf(" ● %d local documents", len(inspector.knowledge.documents))) + for _, document := range inspector.knowledge.documents { + line("c-status", " "+document.Title+" "+document.Path) + } + } + if len(target.attachments) > 0 { + line("c-title", core.Sprintf(" %d attached snapshots", len(target.attachments))) + } + for _, warning := range inspector.knowledge.warnings { + label := warning.Path + if label == "" { + label = warning.Mount + } + line("c-attention", " ! "+label) + line("c-thought", " "+warning.Reason) + } + sequences["chatKnowledge"] = knowledge + + if inspector.dirty { + sequences["chatDirty"] = append(sequences["chatDirty"], map[string]any{}) } - return fitPane(builder.String(), width, height, target.styles.inspector) } -func (inspector inspectorState) renderWork(builder *core.Builder, target app) { - builder.WriteString(target.styles.accent.Render("WORK DETAIL") + "\n") +// workBindings fills the Work body: the selection head, the runtime lines, +// the capability summary, and the flattened feature-group lines (group +// headings, feature rows, and the blank separators between groups all ride +// one sequence, each line choosing its own palette class). +func (inspector inspectorState) workBindings(target app, sequences map[string][]map[string]any) { + sequences["workBody"] = append(sequences["workBody"], map[string]any{}) + + head := sequences["workHead"] if target.work == nil { - builder.WriteString(target.styles.status.Render("○ no work item selected") + "\n\n") + head = append(head, map[string]any{"class": "c-status", "text": "○ no work item selected"}) } else if selected, ok := target.work.Selected(); ok { - builder.WriteString(target.styles.title.Render(selected.Title) + "\n") - builder.WriteString(target.styles.status.Render(workGlyph(selected.Status)+" "+core.Upper(selected.Status)) + "\n") + head = append(head, + map[string]any{"class": "c-title", "text": selected.Title}, + map[string]any{"class": "c-status", "text": workGlyph(selected.Status) + " " + core.Upper(selected.Status)}, + ) if selected.Repo != "" { - builder.WriteString(target.styles.thought.Render(selected.Repo+" "+selected.Branch) + "\n") + head = append(head, map[string]any{"class": "c-thought", "text": selected.Repo + " " + selected.Branch}) } if selected.Question != "" { - builder.WriteString(target.styles.attention.Render("? "+selected.Question) + "\n") + head = append(head, map[string]any{"class": "c-attention", "text": "? " + selected.Question}) } - builder.WriteString("\n") } else { - builder.WriteString(target.styles.status.Render("○ no work item selected") + "\n\n") + head = append(head, map[string]any{"class": "c-status", "text": "○ no work item selected"}) } + sequences["workHead"] = head - inspector.renderRuntime(builder, target) + runtime := sequences["workRuntime"] + if target.work != nil { + if selected, ok := target.work.Selected(); ok && selected.Runtime != "" { + runtime = append(runtime, map[string]any{"class": "c-title", "text": "assigned " + selected.Runtime}) + } + } + switch { + case !inspector.runtime.ready: + runtime = append(runtime, map[string]any{"class": "c-status", "text": "○ detection pending"}) + case inspector.runtime.reason != "": + runtime = append(runtime, + map[string]any{"class": "c-attention", "text": "○ unavailable"}, + map[string]any{"class": "c-thought", "text": inspector.runtime.reason}, + ) + case len(inspector.runtime.capabilities) == 0: + runtime = append(runtime, map[string]any{"class": "c-status", "text": "○ none available"}) + default: + for index, capability := range inspector.runtime.capabilities { + marker := "○" + if index == 0 { + marker = "●" + } + name := capability.Name + if capability.Version != "" { + name = core.Concat(name, " ", capability.Version) + } + runtime = append(runtime, map[string]any{"class": "c-success", "text": marker + " " + name}) + if features := runtimeFeatureLabels(capability); len(features) > 0 { + runtime = append(runtime, map[string]any{"class": "c-thought", "text": " " + core.Join(" · ", features...)}) + } + if capability.Path != "" { + runtime = append(runtime, map[string]any{"class": "c-thought", "text": " " + capability.Path}) + } + } + } + sequences["workRuntime"] = runtime capabilities := []agentCapability{} if target.work != nil { @@ -193,32 +369,36 @@ func (inspector inspectorState) renderWork(builder *core.Builder, target app) { } byFeature := make(map[agentFeature]agentCapability, len(capabilities)) reason := defaultAgentUnavailableReason + available := 0 for _, capability := range capabilities { byFeature[capability.Feature] = capability if !capability.Available && capability.Reason != "" { reason = capability.Reason } - } - builder.WriteString(target.styles.accent.Render("AGENT CAPABILITY") + "\n") - available := 0 - for _, capability := range capabilities { if capability.Available { available++ } } if available == 0 { - builder.WriteString(target.styles.attention.Render("○ not installed") + "\n") - builder.WriteString(target.styles.thought.Render(reason) + "\n\n") + sequences["workCap"] = append(sequences["workCap"], + map[string]any{"class": "c-attention", "text": "○ not installed"}, + map[string]any{"class": "c-thought", "text": reason}, + ) } else { - builder.WriteString(target.styles.success.Render(core.Sprintf("● %d actions available", available)) + "\n\n") + sequences["workCap"] = append(sequences["workCap"], + map[string]any{"class": "c-success", "text": core.Sprintf("● %d actions available", available)}) } selectedFeature := agentFeature("") if target.work != nil { selectedFeature = target.work.SelectedAction().Feature } - for _, group := range agentFeatureGroups { - builder.WriteString(target.styles.accent.Render(group.Title) + "\n") + features := sequences["workFeatures"] + for groupIndex, group := range agentFeatureGroups { + if groupIndex > 0 { + features = append(features, map[string]any{"class": "", "text": ""}) + } + features = append(features, map[string]any{"class": "label", "text": group.Title}) for _, feature := range group.Features { capability, exists := byFeature[feature] if !exists { @@ -228,54 +408,14 @@ func (inspector inspectorState) renderWork(builder *core.Builder, target app) { if feature == selectedFeature { cursor = "› " } - glyph := "○" - style := target.styles.status + glyph, class := "○", "c-status" if capability.Available { - glyph = "●" - style = target.styles.success - } - builder.WriteString(cursor + style.Render(glyph+" "+agentFeatureTitle(feature)) + "\n") - } - builder.WriteString("\n") - } -} - -func (inspector inspectorState) renderRuntime(builder *core.Builder, target app) { - builder.WriteString(target.styles.accent.Render("RUNTIME") + "\n") - if target.work != nil { - if selected, ok := target.work.Selected(); ok && selected.Runtime != "" { - builder.WriteString(target.styles.title.Render("assigned "+selected.Runtime) + "\n") - } - } - switch { - case !inspector.runtime.ready: - builder.WriteString(target.styles.status.Render("○ detection pending") + "\n\n") - case inspector.runtime.reason != "": - builder.WriteString(target.styles.attention.Render("○ unavailable") + "\n") - builder.WriteString(target.styles.thought.Render(inspector.runtime.reason) + "\n\n") - case len(inspector.runtime.capabilities) == 0: - builder.WriteString(target.styles.status.Render("○ none available") + "\n\n") - default: - for index, capability := range inspector.runtime.capabilities { - marker := "○" - if index == 0 { - marker = "●" - } - name := capability.Name - if capability.Version != "" { - name = core.Concat(name, " ", capability.Version) - } - builder.WriteString(target.styles.success.Render(marker+" "+name) + "\n") - features := runtimeFeatureLabels(capability) - if len(features) > 0 { - builder.WriteString(target.styles.thought.Render(" "+core.Join(" · ", features...)) + "\n") - } - if capability.Path != "" { - builder.WriteString(target.styles.thought.Render(" "+capability.Path) + "\n") + glyph, class = "●", "c-success" } + features = append(features, map[string]any{"class": class, "text": cursor + glyph + " " + agentFeatureTitle(feature)}) } - builder.WriteString("\n") } + sequences["workFeatures"] = features } func runtimeFeatureLabels(capability runtimeCapability) []string { @@ -301,69 +441,6 @@ func runtimeFeatureLabels(capability runtimeCapability) []string { return features } -func (inspector inspectorState) renderChat(builder *core.Builder, target app) { - model := "○ none" - if target.modelName != "" { - model = "● " + target.modelName - } - generation := "○ idle" - if target.generating { - generation = "◉ generating" - } - builder.WriteString(target.styles.accent.Render("SESSION") + " ● active\n") - builder.WriteString(target.styles.accent.Render("MODEL") + " " + model + "\n") - builder.WriteString(target.styles.accent.Render("GENERATION") + " " + generation + "\n\n") - builder.WriteString(target.styles.accent.Render("SETTINGS") + "\n") - inspector.renderControl(builder, target, inspectorControlContext, "context", core.Sprintf("%d", target.cfg.contextLen())) - inspector.renderControl(builder, target, inspectorControlMaxTokens, "max tokens", core.Sprintf("%d", target.cfg.maxTokens())) - inspector.renderControl(builder, target, inspectorControlThinking, "thinking", thinkNames[target.cfg.thinkIdx]) - inspector.renderControl(builder, target, inspectorControlTheme, "theme", inspector.Theme()) - builder.WriteString("\n" + target.styles.accent.Render("MODE") + "\n") - inspector.renderControl(builder, target, inspectorControlMode, "sampling", target.modes.current().name) - builder.WriteString("\n" + target.styles.accent.Render("TOOLS") + "\n") - tools := "○ disabled" - if target.tools.enabled { - tools = "● enabled" - } - inspector.renderControl(builder, target, inspectorControlTools, "function calls", tools) - builder.WriteString("\n" + target.styles.accent.Render("KNOWLEDGE") + "\n") - switch { - case !inspector.knowledge.ready: - builder.WriteString(target.styles.status.Render(" ○ discovery pending") + "\n") - case len(inspector.knowledge.documents) == 0: - builder.WriteString(target.styles.status.Render(" ○ no local documents") + "\n") - default: - builder.WriteString(target.styles.success.Render(core.Sprintf(" ● %d local documents", len(inspector.knowledge.documents))) + "\n") - for _, document := range inspector.knowledge.documents { - builder.WriteString(target.styles.status.Render(" "+document.Title+" "+document.Path) + "\n") - } - } - if len(target.attachments) > 0 { - builder.WriteString(target.styles.title.Render(core.Sprintf(" %d attached snapshots", len(target.attachments))) + "\n") - } - for _, warning := range inspector.knowledge.warnings { - label := warning.Path - if label == "" { - label = warning.Mount - } - builder.WriteString(target.styles.attention.Render(" ! "+label) + "\n") - builder.WriteString(target.styles.thought.Render(" "+warning.Reason) + "\n") - } - if inspector.dirty { - builder.WriteString("\n" + target.styles.attention.Render("● unsaved · ctrl+s")) - } -} - -func (inspector inspectorState) renderControl(builder *core.Builder, target app, control inspectorControl, label, value string) { - cursor := " " - style := target.styles.status - if inspector.cursor == int(control) { - cursor = "› " - style = target.styles.accent - } - builder.WriteString(cursor + style.Render(label) + " " + target.styles.title.Render("‹ "+value+" ›") + "\n") -} - func wrapIndex(index, delta, length int) int { if length <= 0 { return 0 diff --git a/cli/tui/overlayframe.go b/cli/tui/overlayframe.go index 33a7ac5b3..ea04ac0c2 100644 --- a/cli/tui/overlayframe.go +++ b/cli/tui/overlayframe.go @@ -24,34 +24,49 @@ import ( // exactly as the transcript composes Glamour output around // ctml-rendered chrome. // -// renderOverlayFrame is the HF idiom's seam: it renders the layout once -// through RenderTermBoxes and splits the output at the H slot's own -// recorded box height — the renderer's receipt for where the header band -// ends — so the host never re-measures rendered chrome. +// renderOverlayFrame is the HF idiom's seam for overlays: renderBandFrame +// under the overlay theme. func renderOverlayFrame(src []byte, width int, styles uiStyles, bindings ...ctml.Bindings) (head, foot string) { + return renderBandFrame(src, width, overlayFrameTheme(styles), bindings...) +} + +// renderOverlayLayout is the HCF idiom's seam for overlays: renderBandLayout +// under the overlay theme. +func renderOverlayLayout(src []byte, width int, styles uiStyles, bindings ...ctml.Bindings) string { + return renderBandLayout(src, width, overlayFrameTheme(styles), bindings...) +} + +// renderBandFrame is the HF idiom's theme-agnostic core: it renders the +// layout once through RenderTermBoxes and splits the output at the H slot's +// own recorded box height — the renderer's receipt for where the header band +// ends — so the host never re-measures rendered chrome. Overlays call it +// through renderOverlayFrame; a primary panel with the same chrome+widget +// shape (the Data list around its live filter input) calls it with its own +// band theme. +func renderBandFrame(src []byte, width int, theme *html.TermTheme, bindings ...ctml.Bindings) (head, foot string) { layout, err := ctml.ParseLayout(src, bindings...) if err != nil { - // Overlay markup is embedded and static, so a parse failure is a - // build defect; the TestRender_Good tests pin each file + // Band markup is embedded and static, so a parse failure is a + // build defect; the TestRender_Good tests pin each file // as parseable. return "", "" } - rendered, boxes := layout.RenderTermBoxes(html.NewContext(), html.TermOptions{Width: width, Theme: overlayFrameTheme(styles)}) + rendered, boxes := layout.RenderTermBoxes(html.NewContext(), html.TermOptions{Width: width, Theme: theme}) lines := core.Split(rendered, "\n") split := min(boxes["H"].Height, len(lines)) return core.Join("\n", lines[:split]...), core.Join("\n", lines[split:]...) } -// renderOverlayLayout is the HCF idiom's seam: one RenderTerm call for an -// overlay whose every region is text. -func renderOverlayLayout(src []byte, width int, styles uiStyles, bindings ...ctml.Bindings) string { +// renderBandLayout is the HCF idiom's theme-agnostic core: one RenderTerm +// call for a surface whose every region is text. +func renderBandLayout(src []byte, width int, theme *html.TermTheme, bindings ...ctml.Bindings) string { layout, err := ctml.ParseLayout(src, bindings...) if err != nil { // Embedded static markup — a parse failure is a build defect (see - // renderOverlayFrame). + // renderBandFrame). return "" } - return layout.RenderTerm(html.NewContext(), html.TermOptions{Width: width, Theme: overlayFrameTheme(styles)}) + return layout.RenderTerm(html.NewContext(), html.TermOptions{Width: width, Theme: theme}) } // overlayFrameTheme maps overlay markup onto the existing palette, so the From 7ea35a0527d0269c3b0d9badeb5597a24dbde839 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 22 Jul 2026 13:34:25 +0100 Subject: [PATCH 10/17] refactor(tui): retire the sequence split and the tab-strip box bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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 --- cli/tui/README.md | 95 +++++++++++++++++------ cli/tui/app.go | 2 +- cli/tui/app_test.go | 13 ++-- cli/tui/picker.ctml | 46 +++++------ cli/tui/picker.go | 51 ++++++------ cli/tui/settings.ctml | 31 ++++---- cli/tui/settings.go | 32 ++++---- cli/tui/tabs.ctml | 41 ++++++---- cli/tui/tabs.go | 177 ++++++++++++++++++++++++------------------ cli/tui/tabs_test.go | 114 +++++++++++++++++++-------- 10 files changed, 362 insertions(+), 240 deletions(-) diff --git a/cli/tui/README.md b/cli/tui/README.md index 17623bc7f..0dd517a32 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -61,8 +61,7 @@ by go-html's terminal renderer (`dappco.re/go/html`). The tab strip 1. **Markup file** — the screen's structure lives in a `.ctml` file embedded beside its Go file (`//go:embed`). Text content doubles as its own i18n - key; `class` attributes are static strings; comments record the host - seams the file exposes. + key; comments record the host seams the file exposes. 2. **Bindings** — dynamic rows enter at parse time through `ctml.Bindings` `Sequences` and ``, bound in text as `{{row.field}}` — literal text and binds mix freely in one run @@ -71,22 +70,39 @@ by go-html's terminal renderer (`dappco.re/go/html`). The tab strip row scope wins inside one). A `Values` miss renders empty while the literal text around it remains, so a value whose absence must hide a whole line still rides a zero-or-one-row sequence. Re-parse and - re-bind on every state change — screens this size make that free. A - per-row style variation cannot ride a class attribute (they are - static), so the host splits rows into one sequence per style (see - `panelBarBindings`: `tabsBefore` / `tabsActive` / `tabsAfter`). + re-bind on every state change — screens this size make that free. + `{{path}}` also interpolates inside any attribute value, resolved per + row at construction (go-html v0.13.0): selection styling rides ONE + sequence with a row-scoped class (`class="{{row.state}}"` plus a bound + marker glyph — see `settingsFormBindings`), and a row can carry its + own box id (`id="knob-{{row.name}}"` — the binding author owns id + uniqueness). The before/active/after sequence split earlier slices + used for selection styling is retired. A zero-or-one-row sequence is + still the idiom for choosing between alternate STATIC texts (the + overlays' create/edit titles and gate prompts): text content is markup + copy, and moving it into a binding would strip its i18n key. 3. **Theme** — `html.TermTheme.Classes` maps the markup's class tokens onto the existing `uiStyles` palette (`panelBarTheme`). The markup carries no colours of its own; the palette in `style.go` stays the single source of - visual truth. + visual truth. A screen whose lines each pick their own paint binds the + class token per row (`class="{{row.class}}"`) over a flattened + `{class, text}` sequence — the inspector's capability groups, runtime + lines, and inter-group blank separators all ride one sequence this way. 4. **Boxes and mouse** — render through `html.RenderTermBoxes` to receive the `html.BoxMap` of every id'd block, then resolve mouse coordinates with `teabox.Resolve` inside the app's `tea.MouseMsg` handling (`onMouse`). Screen cells map to frame-inner cells by subtracting `frameInsetRows` / - `frameInsetCols` (the outer border). The renderer boxes block-level - elements only, so a single-row strip derives its per-item boxes from the - render itself (`mergePanelTabBoxes`) and merges them into the same map — - teabox's smallest-box rule then prefers the item over the strip. + `frameInsetCols` (the outer border). The tab strip is a + `` rendered with `TermOptions.FitSlots` (v0.13.0): + the brand and tab slots size to their own content, pack edge-to-edge on + one row, and record native slot boxes ("L", "C") that tile the strip — + the renderer's own receipt, replacing the retired ANSI-segment + derivation (`mergePanelTabBoxes`). `panelBarTheme` keeps the L slot's + fixed 4-column fit chrome one row tall (space-glyph left/right border), + and `panelBarHit` maps a C-slot hit to a tab by walking the same + `panelBarCells` the bindings rendered — one cell source, so the render + and the resolution can never disagree; the inter-tab gap cells stay + non-hits. A left click on a tab switches panels through exactly the same path as `Tab`/`Shift+Tab` (`selectPanel`); the wheel keeps scrolling the transcript. @@ -100,20 +116,19 @@ row-binding idiom form-shaped screens copy: `‹ value ›`), `
` the hint, which the terminal renderer draws as exactly the row shape a form needs: value line, indented hint line, blank separator. -- **Rows flow through three sequences** (`rowsBefore` / `rowsActive` / - `rowsAfter`) split around the cursor, exactly like the tab strip: class - attributes are static, so selection styling is carried by which sequence - a row lands in (`settingsFormBindings`). +- **Rows flow through ONE sequence** — selection styling rides the + row-scoped class bind (`class="{{row.state}}"`) and the marker glyph + rides the row (`settingsFormBindings`), re-bound on every cursor move. - **Selection gutters are marker glyphs** (`›` active, `○` idle): the parser drops whitespace-only runs between siblings, so a plain two-space gutter cannot be expressed in markup. - **Rows are block-shaped**, so the `.ctml` file indents freely; only the single-row constructs inside a row (the `
` line, the footer) stay on one source line. -- **No per-row boxes** — an `` row cannot vary its `id` any more than - its `class`, so form rows record nothing in a box map; a screen that - needs row clicks either enumerates its rows statically or derives boxes - from the render as the tab strip does. +- **Per-row boxes ride row-scoped ids** — `id="knob-{{row.name}}"` + resolves per row, so each block-shaped row records its own box under a + box-recording render (inert under `RenderTerm`, ready for a mouse + consumer); the binding author owns id uniqueness. `preferences.go` is the preference *store* (config load/set/commit), not a screen — it has no rendering to migrate; the preference-editing UI is the @@ -126,7 +141,9 @@ The Models panel (`picker.go` + `picker.ctml`) and the Tools tab - **A Bubbles-list screen keeps its state in `list.Model`** (items, cursor, fuzzy filter, pagination through `Update`) and derives its row bindings - from it — the current page split before/active/after; the host truncates + from it — the current page as one sequence, each row carrying its + selection class, marker, and box id (`id="model-{{row.id}}"`, keyed by + the model path discovery already de-duplicates on); the host truncates each field to the row budget because the page math requires exactly one line per `
` (a `
` wraps to the render width, and a wrapped row would overflow the page the list delegate sized). @@ -183,11 +200,43 @@ overlay and noted in each `.ctml` header: snapshots, requests, the provider interface and its unavailable stub), not a view — it composes nothing and has no rendering to migrate. +### Panels around live widgets, and pre-styled content + +The Data panel (`datapanel.go` + `datalist.ctml` + `datadetail.ctml`) and +the inspector (`inspector.go` + `inspector.ctml`) close the panel +migration with three idioms: + +- **A panel with chrome+widget shape reuses the overlays' HF band seam**: + `renderBandFrame` (the theme-agnostic core `renderOverlayFrame` wraps) + renders `datalist.ctml`'s header and row bands, and the live Bubbles + filter input composes between them; the list's own band theme keeps + both bands plain — no footer top padding — so with no widget present + the bands join back into the contiguous list. +- **Pre-styled ANSI rides ``**: the detail pane's + Glamour-rendered CONTENT and SCORES bodies pass through byte-for-byte. + A verbatim value must exist at parse time, so the host always supplies + both (empty when nothing is selected), and each carries one trailing + newline — the blank separating a body from the next heading travels in + the caller-owned bytes. A verbatim is block-level: its section heading + sits as its own `

` above it and gains the renderer's paragraph + blank beneath, where a heading over ordinary rows keeps them adjacent + inside its own `

`. +- **A dense pane of single-line rows is `

`-per-section with `
` + rows** (the launch-review body idiom): consecutive `

` blocks + separate with exactly one blank line, so the inspector reproduces its + height-budgeted vertical rhythm without `

` paragraph spacing. + Mixed-paint lines flatten to `{class, text}` rows; only a `

`'s + first line loses a leading gutter, so interior rows keep their + two-space cursors and indents, and a row that can open a band leads + with a marker glyph instead. + ### Tables and stores -`records.go`, `migrations.go`, and `datasetmigrations.go` are tables in -the database sense only: the ORM record schemas, the `lem.duckdb` -migration runner, and the `datasets.duckdb` migration runner. None +`records.go`, `migrations.go`, `datasetmigrations.go`, and `export.go` +are data plumbing only: the ORM record schemas, the `lem.duckdb` +migration runner, the `datasets.duckdb` migration runner, and the +session exporter (Markdown/JSON files written through `coreio`, palette +mirrored — its "render" builds the export document, not a screen). None composes a screen — like `preferences.go`, `markdown.go`, and `agentcap.go` before them, they have no rendering to migrate. diff --git a/cli/tui/app.go b/cli/tui/app.go index 2879b21f3..4438f3203 100644 --- a/cli/tui/app.go +++ b/cli/tui/app.go @@ -1424,7 +1424,7 @@ func (a app) onMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } metrics := measureFrame(a.width, a.height, a.inspectorOpen) _, boxes := renderPanelBarBoxes(a.activePanel, metrics.innerWidth, metrics.kind, a.styles) - panel, ok := panelBarHit(boxes, msg.X-frameInsetCols, msg.Y-frameInsetRows) + panel, ok := panelBarHit(boxes, msg.X-frameInsetCols, msg.Y-frameInsetRows, a.activePanel, metrics.kind) if !ok { return a.route(msg) } diff --git a/cli/tui/app_test.go b/cli/tui/app_test.go index 8377a3322..dd686ea6c 100644 --- a/cli/tui/app_test.go +++ b/cli/tui/app_test.go @@ -2819,19 +2819,20 @@ func TestSelectPanel_Ugly(t *testing.T) { } } -// tabClick builds the left-press message for the centre of one derived tab -// box, offset from strip-local to screen cells by the frame insets. +// tabClick builds the left-press message for the centre of one tab's hit +// span — the native tabs-slot box plus the cell walk panelBarHit itself +// uses — offset from strip-local to screen cells by the frame insets. func tabClick(t *testing.T, a app, panel panelID) tea.MouseMsg { t.Helper() metrics := measureFrame(a.width, a.height, a.inspectorOpen) _, boxes := renderPanelBarBoxes(a.activePanel, metrics.innerWidth, metrics.kind, a.styles) - box, ok := boxes[panelTabBlockID(panel)] + start, end, ok := panelTabSpan(boxes, a.activePanel, metrics.kind, panel) if !ok { - t.Fatalf("box map missing %s", panelTabBlockID(panel)) + t.Fatalf("no hit span for panel %v", panel) } return tea.MouseMsg{ - X: frameInsetCols + box.Col + box.Width/2, - Y: frameInsetRows + box.Row, + X: frameInsetCols + (start+end)/2, + Y: frameInsetRows + boxes[tabsSlotID].Row, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, } diff --git a/cli/tui/picker.ctml b/cli/tui/picker.ctml index 37054750a..7cedd3039 100644 --- a/cli/tui/picker.ctml +++ b/cli/tui/picker.ctml @@ -4,21 +4,23 @@ model, with filter, empty, and page state). Host seams (picker.go): - - Sequences rowsBefore / rowsActive / rowsAfter : the current PAGE of - the Bubbles list model's visible items, split around its cursor. - Three sequences because a class attribute is a static string in - .ctml (an row cannot vary its own class), so selection - styling is carried by WHICH sequence a row lands in; the host - re-binds the split on every update. State stays in list.Model — - this file only draws it. - - Sequences filter / empty / page : zero-or-one-row conditional + - Sequence rows : the current PAGE of the Bubbles list model's + visible items, ONE row per model {state, marker, name, detail, id} + — selection styling rides the row-scoped class bind + class="{{row.state}}" (go-html v0.13.0) and the marker glyph rides + the row (› active, ○ idle), so the before/active/after sequence + split this file previously needed is retired; the host re-binds on + every update. State stays in list.Model — this file only draws it. + - Sequences filter / empty / page : zero-or-one-row conditional sections — the typed filter while filtering, the "No items." text when nothing is discovered, and "page x/y" when the list paginates. - - Classes row-idle / row-active / row-hint / filter-prompt / + - Classes row-idle / row-active / row-hint / filter-prompt / filter-value / picker-empty / picker-page / picker-keys : mapped onto the existing uiStyles palette by modelPickerTheme. - - id="model-picker" : the block a box-recording render would identify; - inert under RenderTerm today, ready for a mouse consumer. + - id="model-{{row.id}}" : each row records its own box under a + box-recording render, keyed by the model's path (unique — discovery + de-duplicates on it); inert under RenderTerm today, ready for a + mouse consumer. id="model-picker" is the enclosing block. Row shape:

is the marker + model name,
the "type path" hint the renderer indents beneath it, and each
closes with the blank @@ -27,25 +29,17 @@ always fits the pane. The host truncates name and hint to the row budget because the page math requires exactly one line per
— a
wraps to the render width, and a wrapped row would overflow the - page the delegate sized. Selection gutters are marker - glyphs (› active, ○ idle): the parser drops whitespace-only runs - between siblings, so a plain gutter cannot be expressed in markup. - Single-row constructs stay on one source line so a text run's edge - whitespace ("Filter: ") survives. + page the delegate sized. Selection gutters are marker glyphs + (› active, ○ idle): the parser drops whitespace-only runs between + siblings, so a plain gutter cannot be expressed in markup. Single-row + constructs stay on one source line so a text run's edge whitespace + ("Filter: ") survives. -->

Models

Filter: {{row.value}}

-
-
○ {{row.name}}
-
{{row.detail}}
-
-
-
› {{row.name}}
-
{{row.detail}}
-
-
-
○ {{row.name}}
+
+
{{row.marker}} {{row.name}}
{{row.detail}}

{{row.text}}

diff --git a/cli/tui/picker.go b/cli/tui/picker.go index 447bba1d7..ccef5bb55 100644 --- a/cli/tui/picker.go +++ b/cli/tui/picker.go @@ -100,22 +100,22 @@ func newPicker() list.Model { var pickerCTML []byte // modelPickerBindings derives the panel's rows from the list model's own -// state: the current page of visible items split around the cursor -// (before / active / after, because .ctml class attributes are static -// strings — an row cannot vary its own class), plus the -// zero-or-one-row conditional sections for the typed filter, the empty -// state, and the page position. Name and hint are truncated host-side to -// the row budget because the page math budgets exactly three cells per -// row — a
now wraps to the render width, and a wrapped row would -// overflow the page the list delegate sized. +// state: the current page of visible items as ONE sequence — selection +// styling rides the row-scoped class bind (class="{{row.state}}", go-html +// v0.13.0) and the marker glyph rides the row, so no before/active/after +// split is needed — plus the zero-or-one-row conditional sections for the +// typed filter, the empty state, and the page position. Each row also +// binds its box id (the model path — unique, discovery de-duplicates on +// it). Name and hint are truncated host-side to the row budget because the +// page math budgets exactly three cells per row — a
wraps to the +// render width, and a wrapped row would overflow the page the list +// delegate sized. func modelPickerBindings(picker list.Model, width int) ctml.Bindings { sequences := map[string][]map[string]any{ - "filter": {}, - "rowsBefore": {}, - "rowsActive": {}, - "rowsAfter": {}, - "empty": {}, - "page": {}, + "filter": {}, + "rows": {}, + "empty": {}, + "page": {}, } if picker.FilterState() == list.Filtering { sequences["filter"] = append(sequences["filter"], map[string]any{"value": picker.FilterValue()}) @@ -129,18 +129,21 @@ func modelPickerBindings(picker list.Model, width int) ctml.Bindings { if !ok { continue } - row := map[string]any{ - "name": ansi.Truncate(entry.Title(), budget, "…"), - "detail": ansi.Truncate(entry.Description(), budget, "…"), + state, marker := "row-idle", "○" + if index == active { + state, marker = "row-active", "›" } - switch { - case index < active: - sequences["rowsBefore"] = append(sequences["rowsBefore"], row) - case index == active: - sequences["rowsActive"] = append(sequences["rowsActive"], row) - default: - sequences["rowsAfter"] = append(sequences["rowsAfter"], row) + id := entry.Title() + if model, ok := item.(modelItem); ok { + id = model.path } + sequences["rows"] = append(sequences["rows"], map[string]any{ + "state": state, + "marker": marker, + "id": id, + "name": ansi.Truncate(entry.Title(), budget, "…"), + "detail": ansi.Truncate(entry.Description(), budget, "…"), + }) } if len(visible) == 0 && picker.FilterState() != list.Filtering { sequences["empty"] = append(sequences["empty"], map[string]any{"text": "No items."}) diff --git a/cli/tui/settings.ctml b/cli/tui/settings.ctml index ba57090c2..4c046bfe1 100644 --- a/cli/tui/settings.ctml +++ b/cli/tui/settings.ctml @@ -3,15 +3,18 @@ settings.ctml : the Settings form (one
row per knob + the key footer). Host seams (settings.go): - - Sequences rowsBefore / rowsActive / rowsAfter : the knob rows in - cursor order, split around the selected row. Three sequences because a - class attribute is a static string in .ctml (an row cannot vary - its own class), so selection styling is carried by WHICH sequence a - row lands in; the host re-binds the split on every cursor move. - - Classes row-idle / row-active / row-value / row-hint / form-keys : + - Sequence rows : ONE row per knob {state, marker, name, value, hint} + in cursor order — selection styling rides the row-scoped class bind + class="{{row.state}}" (go-html v0.13.0) and the marker glyph rides + the row (› active, ○ idle), so the before/active/after sequence + split this file previously needed is retired; the host re-binds on + every cursor move. + - Classes row-idle / row-active / row-value / row-hint / form-keys : mapped onto the existing uiStyles palette by settingsFormTheme. - - id="settings-form" : the block a box-recording render would identify; - inert under RenderTerm today, ready for a mouse consumer. + - id="knob-{{row.name}}" : each row records its own box under a + box-recording render (the binding author owns id uniqueness — knob + names are unique); inert under RenderTerm today, ready for a mouse + consumer. id="settings-form" is the enclosing block. Row shape:
is the value line (marker + name + ‹ value ›),
the hint the renderer indents beneath it, and each
closes with the blank @@ -24,16 +27,8 @@ -->

settings

-
-
○ {{row.name}} ‹ {{row.value}} ›
-
{{row.hint}}
-
-
-
› {{row.name}} ‹ {{row.value}} ›
-
{{row.hint}}
-
-
-
○ {{row.name}} ‹ {{row.value}} ›
+
+
{{row.marker}} {{row.name}} ‹ {{row.value}} ›
{{row.hint}}

↑/↓ select · ←/→ change · ctrl+s saves · esc closes

diff --git a/cli/tui/settings.go b/cli/tui/settings.go index 0dd64c6b7..0d1d03fb0 100644 --- a/cli/tui/settings.go +++ b/cli/tui/settings.go @@ -117,29 +117,23 @@ func (s settings) move(delta int) settings { //go:embed settings.ctml var settingsCTML []byte -// settingsFormBindings splits the knob rows around the cursor. Three -// sequences (before / active / after) because .ctml class attributes are -// static strings — an row cannot vary its own class — so selection -// styling is carried by which sequence a row lands in. A form this size -// re-binds on every change for free. +// settingsFormBindings binds ONE row per knob — selection styling rides the +// row-scoped class bind (class="{{row.state}}", go-html v0.13.0) and the +// marker glyph rides the row, so no before/active/after sequence split is +// needed. A form this size re-binds on every change for free. func settingsFormBindings(form settings) ctml.Bindings { - sequences := map[string][]map[string]any{ - "rowsBefore": {}, - "rowsActive": {}, - "rowsAfter": {}, - } + rows := make([]map[string]any, 0, len(form.rows())) for index, row := range form.rows() { - entry := map[string]any{"name": row.name, "value": row.value, "hint": row.hint} - switch { - case index < form.cursor: - sequences["rowsBefore"] = append(sequences["rowsBefore"], entry) - case index == form.cursor: - sequences["rowsActive"] = append(sequences["rowsActive"], entry) - default: - sequences["rowsAfter"] = append(sequences["rowsAfter"], entry) + state, marker := "row-idle", "○" + if index == form.cursor { + state, marker = "row-active", "›" } + rows = append(rows, map[string]any{ + "state": state, "marker": marker, + "name": row.name, "value": row.value, "hint": row.hint, + }) } - return ctml.Bindings{Sequences: sequences} + return ctml.Bindings{Sequences: map[string][]map[string]any{"rows": rows}} } // settingsFormTheme maps the markup's class tokens onto the existing palette, diff --git a/cli/tui/tabs.ctml b/cli/tui/tabs.ctml index 6f61afcc8..f17ad25d0 100644 --- a/cli/tui/tabs.ctml +++ b/cli/tui/tabs.ctml @@ -2,20 +2,31 @@ - + +LEM +{{tab.cell}} + diff --git a/cli/tui/tabs.go b/cli/tui/tabs.go index bd93fbea2..81b7afab8 100644 --- a/cli/tui/tabs.go +++ b/cli/tui/tabs.go @@ -6,7 +6,6 @@ import ( _ "embed" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" core "dappco.re/go" "dappco.re/go/html" @@ -32,17 +31,18 @@ func (panel panelID) next() panelID { return (panel + 1) % panelCount } func (panel panelID) prev() panelID { return (panel + panelCount - 1) % panelCount } // tabsCTML is the tab strip's markup — see tabs.ctml for the seams it -// exposes (row sequences, class tokens, the panel-bar box id). +// exposes (the tabs sequence, class tokens, the native slot boxes). // //go:embed tabs.ctml var tabsCTML []byte -// panelBarBlockID is the id attribute on the strip's