diff --git a/cli/go.mod b/cli/go.mod index 1e9963be..a8683cc1 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.15.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 6b93e6f4..2830a783 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -22,6 +22,16 @@ 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/html v0.13.0 h1:EgMdKzZ2mX++2tlNSVk2VrexKM74SmwAAtlwSuG0cSA= +dappco.re/go/html v0.13.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= +dappco.re/go/html v0.13.1 h1:ILgEjxaiof8eOqp44791Gtij+t5PfvT6u7E2K0Wbw2s= +dappco.re/go/html v0.13.1/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= +dappco.re/go/html v0.14.0 h1:vUDiFe4b5IXF2xFCFPSPwwlXwWW5Cf+OhDQq0gnt/iY= +dappco.re/go/html v0.14.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= +dappco.re/go/html v0.15.0 h1:XMFA7fKY07F3gskoF/9+NYo1WlLaNmoGEFmOofWvnRw= +dappco.re/go/html v0.15.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= diff --git a/cli/tui/README.md b/cli/tui/README.md index 9a07ae26..83b97f05 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -61,31 +61,333 @@ 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}}`. 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. + `{{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. +### 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 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. +- **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 +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 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). +- **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**; 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). + +`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. + +### 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. + +### 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`, `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. + +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. + +### The app shell + +`layout.go` (`renderFrame` + `shell.ctml`/`shellregion.ctml`/`shellwide.ctml`) +closes the migration at the outermost layer: the permanent frame every +primary panel renders inside — tab strip, session strip, the active +panel/overlay body, footer key hints, all inside one rounded border. + +- **The header/footer bands are a `` band-split** in + all three shell files alike. The header (tab strip + session strip, + already joined and each already fitted to the frame's inner width) and + the footer (status + key hints, likewise pre-fitted) ride `` in + the H and F slots. +- **The region joins the same declarative surface as H/F for every shape + but one**, since go-html v0.14.0 shipped a themeable `Content` style + (`TermTheme.Content`, matching the pre-existing themeable `Aside`) and + row-scoped verbatim: a zero-chrome C (and R) now passes pre-fitted + content through byte-exact at the slot's full width, closing the gap the + previous round left open (docs/ctml.md S:15.2/S:15.5). Wide layouts + (`shellwide.ctml`, ``) bind the main panel to C + and the inspector to R, one `RenderTerm` call (`renderWideLayout`) in + place of `lipgloss.JoinHorizontal` and a manual `│` separator column. + Every single-pane shape — Narrow either way, Overlay with the inspector + closed — binds the one active pane to C (`shellregion.ctml`, + ``), replacing a bare `fitPane` call. The ONE shape + that still cannot join header+region+footer into a single call is + Overlay with the inspector open: `shell.ctml`'s H/F-only layout, split by + `renderBandFrame`, stays host-joined (`lipgloss.JoinVertical`) with the + region rendered between the bands — the same HF+host-composition idiom a + widget-carrying overlay uses for a live Bubbles widget between its own + bands. What changed as of go-html v0.15.0 (docs/ctml.md S:15.7) is that + the REGION itself no longer hand-stacks: two independently-sized panes + stacked vertically with a rule between is a documented idiom — an `HC` + layout, H the upper pane with its own bottom border as the divider, C the + lower pane — not a missing construct, since only the *middle* band's + L/C/R packing is width-gated (side by side ≥80 columns, stacked below); + H/middle/F themselves stack vertically at any width. `renderInspectorStack` + (`layout.go`) now renders `shellinspectorpair.ctml`'s `` through its own `RenderTerm` call, replacing the old + `lipgloss.JoinVertical(inspector, separator, main)` plus a manually + `core.Repeat("─", …)` rule line. It stays a SEPARATE call from + `shell.ctml`'s H/F band, rather than nesting fully into one page-wide + call: `TermTheme` is one flat struct threaded through every nested + `Layout` in a single render, so a unified call would force the page + header's `H` (needing zero chrome, pre-fitted byte-exact like every + sibling shell) and the pair's own `H` (needing its natural bordered + chrome, since that border IS the rule) to share one `Header` style — a + throwaway probe during this slice confirmed zeroing `Header` for the page + header silently zeroed the pair's divider too. `shellinspectorpair.ctml`'s + own header comment has the full account. +- **The Wide inspector is 32 columns again, not 28.** go-html v0.15.0 adds + `TermOptions.AsideWidth` (S:15.1): a width *request* that overrides the + non-FitSlots middle band's fixed R budget (the unexported + `termAsideWidth`, still 28 by default) per render, with C absorbing the + difference. `renderWideLayout` (`layout.go`) is the one render path that + sets it, requesting `wideInspectorWidth` (32) — restoring both the + pre-.ctml value and name. A zero-chrome C still does not collapse the C/R + junction: go-html always inserts its own single-column gutter before R, + but that gutter is now paintable — `TermTheme.GutterRule` (S:15.6) sets + the glyph rendered there, the full band height, in the theme's `Rule` + style; `shellWideTheme` sets `GutterRule = "│"` (repointed at + `styles.separator`'s own colour) so the historic visible rule returns. + `wideInspectorWidth` documents the REQUEST rather than mirroring + go-html's own unrequested default — go-html exports no accessor for that + default (S:15.5) — and `TestWideInspectorWidth_MatchesRequest` pins the + request against a live `RenderTermBoxes` call on the real + `shellwide.ctml` render path (the box map is the render-time source of + truth, S:15.5) so upstream regression in honouring `AsideWidth` fails + loudly rather than silently reflowing the frame back to 28. The main + panel gets its historic width back too (`measureFrame`'s Wide case); both + the width and the rule are real, visible deltas, tied entirely to these + two doctrine clauses. +- **Mouse hit-testing needed no re-plumbing, this slice included.** + `onMouse` resolves against its own independent `renderPanelBarBoxes` + call, keyed off `frameInsetRows`/`frameInsetCols` and the tab strip's + row-0 position — both unchanged since the tab strip's own render path + (`renderPanelBar`, `tabs.ctml`) is untouched. No region content (chat + transcript, Work/Data/Models/Service panels, the inspector) resolves a + mouse coordinate against its own composition today, so there was no + "old composition maths" to re-plumb onto boxes for this slice either. +- **The chat transcript's row LIST is declarative; each row's own + STRUCTURE stays host-decided.** `transcript.ctml` is a zero-or-one-row + "notice" sequence (the session status banner) followed by one row per + turn, each row's verbatim `body` the turn's complete pre-formatted + output — row-scoped verbatim (``, + materialised per row against the enclosing ``, S:6.5) replaced the + old hand-grown `core.Builder` accumulation loop. What did NOT convert: + a turn's SHAPE (an inline "you "+text line for `user`, a label+result + pair for `tool`, or an optional thinking line + label + Glamour/streaming + body + tool calls + a live spinner for `assistant`) still branches in Go + (`renderTranscriptTurn`), because ``/``/`` compile to + a `func(*Context) bool/string` closure resolved once per RENDER against + `Context.Data` (S:7) — the same context for every row an `` + produces, not a per-row scope the way a `{{path}}` bind is. A row can + vary its VALUES; it cannot vary its STRUCTURE. This is the documented + closed-vocabulary boundary (S:1.1: a named lookup, never an expression), + not a workaround. + +### The command palette + +The command palette (`palette.go` + `palette.ctml`, `Ctrl+K`) closes the +screen-by-screen migration: the overlays' widget-carrying idiom +(`workeditor.ctml`, `agentanswer.ctml`) extended onto a selectable row +list for the first time. + +- **A ``, exactly `datalist.ctml`'s own borrowing of + the overlay idiom, lent back to an actual overlay.** The live Bubbles + `FilterInput` emits pre-styled ANSI, so `renderBandFrame` renders the + header and footer bands and the host composes the filter input between + them while filtering (`list.Model.SettingFilter()`) — an ADDITIONAL + line, not a swap of the header the way Bubbles' own `titleView` used + to. The header stays fixed because the palette is filtering for + effectively its whole visible lifetime (`Open()` always arms + `Filtering`, and Enter/Esc close the overlay before either ever + reaches `list.Model`), so a header that vanished the instant the + overlay opened would rarely be seen. +- **Rows are Picker's `
` idiom, not the Data list's dense `

`+`
` + rows**: each command is a two-line block (marker + title, indented + description), matching Picker's value-line/hint-line shape rather than + Data's one-line-per-item density. Selection styling rides the + row-scoped class bind (`class="{{row.state}}"`) with the marker glyph + on the row; an unavailable command's "— unavailable: reason" suffix + already lives in its description text (`commandListItem.Description`), + so it paints identically to an available row — the original + `list.DefaultDelegate` never styled the two states apart, and this + conversion does not invent a distinction that was not there. +- **`ctml.SubcommandList` (go-html's `CoreCommand`-derived list + generator, `docs/ctml.md` S:16) does not fit.** It walks a + `*core.Core` command registry and renders a bare `

    ` of `I18nKey()` + labels — no selection state, no filtering, no availability/reason — + and the palette's `workspaceCommand` catalogue has no relationship to + `core.Command`/`CommandAction` at all. Adapting one to the other would + mean building a registry bridge that does not exist, for a + rendering-only slice; the palette instead composes the same generic + row-list idiom `picker.ctml` and `datalist.ctml` already established. +- Bubbles' own chrome — the auto-generated help line, the dot pagination + bar, the title-vs-filter swap — is replaced wholesale with host-owned + markup, the same trade every earlier screen made: a literal "page x/y" + line once the command count needs more than one page (`picker.ctml`'s + own convention), and a static, accurate key-hint footer instead of + Bubbles' generic `KeyMap`-derived one. + ## Keys | Key | Scope | Action | @@ -99,7 +401,10 @@ A left click on a tab switches panels through exactly the same path as | `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/agentanswer.ctml b/cli/tui/agentanswer.ctml new file mode 100644 index 00000000..12612807 --- /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 4817ff11..ae3bd8b6 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 977e2b14..99dfc629 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 00000000..db0f2bb7 --- /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/app.go b/cli/tui/app.go index edf3e30d..743b080f 100644 --- a/cli/tui/app.go +++ b/cli/tui/app.go @@ -4,6 +4,7 @@ package tui import ( "context" + _ "embed" "sync" "time" @@ -16,11 +17,21 @@ import ( "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/dataset" "dappco.re/go/inference/decode/parser" ) +// transcriptCTML is the chat panel's turn-list markup -- see transcript.ctml +// for the row-scoped verbatim idiom it exposes and why per-turn STRUCTURE +// stays host-decided (renderTranscriptTurn) while the row LIST itself is +// declarative. +// +//go:embed transcript.ctml +var transcriptCTML []byte + // turn is one rendered element in the transcript. type turn struct { id string @@ -402,7 +413,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,10 +1185,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 - } if a.switcher != nil { a.switcher.list.Styles.Title = a.styles.title } @@ -1190,6 +1197,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 { @@ -1400,7 +1432,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) } @@ -1919,6 +1951,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() @@ -2459,6 +2498,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) @@ -3001,62 +3062,101 @@ func (a app) transcriptHeight() int { return h } +// renderTranscript renders transcript.ctml: a zero-or-one-row "notice" +// sequence (the session status banner) followed by one row per turn, each +// row's verbatim "body" the complete pre-formatted turn (renderTranscriptTurn) +// -- go-html owns the row iteration and the byte-exact verbatim passthrough, +// the host still owns every turn's own content (transcript.ctml's header +// comment has the full account of why). A parse failure never happens +// against this embedded, static markup outside a build defect (the same +// assumption renderBandFrame/renderBandLayout make, overlayframe.go) and +// degrades to an empty transcript rather than panicking. func (a app) renderTranscript() string { - var b core.Builder + tree, err := ctml.Parse(transcriptCTML, a.transcriptBindings()) + if err != nil { + return "" + } + return lipgloss.NewStyle().Width(a.view.Width).Render(html.RenderTerm(tree, html.NewContext())) +} + +// transcriptBindings supplies transcript.ctml's two row sequences. notice is +// zero-or-one row so an absent banner renders nothing (not an empty line); +// every turn row's own leadingBlank folds in the "\n" go-html's own +// block-level join does not supply a second one of, reproducing the +// original "\n\n" turn gap byte-for-byte (transcript.ctml's header comment). +func (a app) transcriptBindings() ctml.Bindings { notice := a.sessionTranscriptNotice() + var noticeRows []map[string]any if notice != "" { - b.WriteString(a.styles.attention.Render(notice)) + noticeRows = []map[string]any{{"text": a.styles.attention.Render(notice)}} } + turnRows := make([]map[string]any, 0, len(a.turns)) for i, t := range a.turns { - if i > 0 || notice != "" { - b.WriteString("\n\n") - } - switch t.role { - case "user": - b.WriteString(a.styles.user.Render("you ") + a.styles.answer.Render(t.text)) - case "tool": - label := "tool result" - if len(t.calls) > 0 { - label = core.Concat("tool · ", t.calls[0]) - } - b.WriteString(a.styles.thought.Render(label)) - if result := visibleToolResult(t.text); result != "" { - b.WriteString("\n" + a.styles.answer.Render(result)) - } - default: - if t.thought != "" { - b.WriteString(a.styles.thought.Render("· thinking · "+core.Trim(t.thought)) + "\n") - } - label := t.model - if label == "" { - label = a.modelName - } - if label == "" { - label = "assistant" - } - b.WriteString(a.styles.assistant.Render(label)) - streaming := a.generating && i == len(a.turns)-1 - if t.text != "" { - b.WriteString("\n") - if streaming { - b.WriteString(a.styles.answer.Render(t.text)) - } else { - turnID := t.id - if turnID == "" { - turnID = core.Sprintf("transcript-%d", i) - } - b.WriteString(a.markdown.Render(turnID, t.text, max(1, a.view.Width-2))) + leadingBlank := i > 0 || notice != "" + turnRows = append(turnRows, map[string]any{"body": a.renderTranscriptTurn(i, t, leadingBlank)}) + } + return ctml.Bindings{Sequences: map[string][]map[string]any{ + "notice": noticeRows, + "turns": turnRows, + }} +} + +// renderTranscriptTurn formats exactly one turn -- byte-for-byte what the +// pre-.ctml renderTranscript loop wrote inline for the same turn, role +// branching included (transcript.ctml's header comment explains why that +// branching stays here rather than in markup). leadingBlank prepends the +// single "\n" this row owes the inter-turn gap; blocks() (go-html) supplies +// the other half of the "\n\n" via its own row-to-row join. +func (a app) renderTranscriptTurn(i int, t turn, leadingBlank bool) string { + var b core.Builder + if leadingBlank { + b.WriteString("\n") + } + switch t.role { + case "user": + b.WriteString(a.styles.user.Render("you ") + a.styles.answer.Render(t.text)) + case "tool": + label := "tool result" + if len(t.calls) > 0 { + label = core.Concat("tool · ", t.calls[0]) + } + b.WriteString(a.styles.thought.Render(label)) + if result := visibleToolResult(t.text); result != "" { + b.WriteString("\n" + a.styles.answer.Render(result)) + } + default: + if t.thought != "" { + b.WriteString(a.styles.thought.Render("· thinking · "+core.Trim(t.thought)) + "\n") + } + label := t.model + if label == "" { + label = a.modelName + } + if label == "" { + label = "assistant" + } + b.WriteString(a.styles.assistant.Render(label)) + streaming := a.generating && i == len(a.turns)-1 + if t.text != "" { + b.WriteString("\n") + if streaming { + b.WriteString(a.styles.answer.Render(t.text)) + } else { + turnID := t.id + if turnID == "" { + turnID = core.Sprintf("transcript-%d", i) } + b.WriteString(a.markdown.Render(turnID, t.text, max(1, a.view.Width-2))) } - for _, c := range t.calls { - b.WriteString("\n" + a.styles.thought.Render("→ "+c)) - } - if t.text == "" && t.thought == "" && a.generating && i == len(a.turns)-1 { - b.WriteString(a.styles.thought.Render(a.spin.View() + " …")) - } + } + for _, c := range t.calls { + b.WriteString("\n" + a.styles.thought.Render("→ "+c)) + } + if t.text == "" && t.thought == "" && a.generating && i == len(a.turns)-1 { + b.WriteString(a.styles.thought.Render(a.spin.View() + " …")) } } - return lipgloss.NewStyle().Width(a.view.Width).Render(b.String()) + return b.String() } func (a app) transcriptTurnOffset(turnID string) int { @@ -3162,7 +3262,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: @@ -3238,7 +3338,7 @@ func (a app) overlayView() string { var body string switch a.activeOverlay { case overlayCommands: - body = a.palette.View(bodyWidth, bodyHeight) + body = a.palette.View(bodyWidth, bodyHeight, a.styles) case overlaySessions: if a.switcher == nil { body = overlayEmpty("Recent sessions", "session workspace is not connected") @@ -3295,6 +3395,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: @@ -3408,9 +3510,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/app_test.go b/cli/tui/app_test.go index 8377a332..dd686ea6 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/changereview.ctml b/cli/tui/changereview.ctml new file mode 100644 index 00000000..023486ed --- /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 00000000..242ba1c5 --- /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/datadetail.ctml b/cli/tui/datadetail.ctml new file mode 100644 index 00000000..30450638 --- /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/dataeditor.ctml b/cli/tui/dataeditor.ctml new file mode 100644 index 00000000..58a2ed88 --- /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 00000000..534716df --- /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/datalist.ctml b/cli/tui/datalist.ctml new file mode 100644 index 00000000..696323af --- /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/datanote.ctml b/cli/tui/datanote.ctml new file mode 100644 index 00000000..3685afc8 --- /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 81b3d275..ac90646e 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 837d8d0f..571b4fe9 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/datapanel.go b/cli/tui/datapanel.go index 72c085da..d6483bd2 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 00000000..90bb2ab4 --- /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 07766567..900dc5e1 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/keymap.go b/cli/tui/keymap.go index 93291ad0..7858e442 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 b076c86a..fa701d60 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/launchreview.ctml b/cli/tui/launchreview.ctml new file mode 100644 index 00000000..3cdb4e3f --- /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/layout.go b/cli/tui/layout.go index 51265059..69ac1616 100644 --- a/cli/tui/layout.go +++ b/cli/tui/layout.go @@ -3,9 +3,13 @@ package tui import ( + _ "embed" + "github.com/charmbracelet/lipgloss" core "dappco.re/go" + "dappco.re/go/html" + "dappco.re/go/html/ctml" ) type layoutKind uint8 @@ -16,6 +20,21 @@ const ( layoutWide ) +// wideInspectorWidth is the width REQUESTED for the R slot (the inspector) +// in the Wide layout kind's side-by-side band, via TermOptions.AsideWidth +// (docs/ctml.md S:15.1, go-html v0.15.0) -- restoring both the pre-.ctml +// value AND name (git history: layout.go's renderWorkspaceRegion, before +// slice 7's shell.ctml conversion dropped to go-html's unrequested default). +// go-html's own default R budget when no request is made is a fixed 28 (the +// unexported termAsideWidth constant, docs/ctml.md S:15.5: "the budgets stay +// unexported"); AsideWidth overrides it per render rather than changing +// what go-html ships, so this constant is the caller's REQUEST, not a +// mirrored upstream budget -- go-html sizes C (the main panel) to absorb +// the difference. TestWideInspectorWidth_MatchesRequest pins the request +// against a live RenderTermBoxes call on the real shellwide.ctml render +// path (the box map is the render-time source of truth, docs/ctml.md +// S:15.5) so any upstream regression in honouring AsideWidth fails loudly +// here instead of silently reflowing the Wide frame back to 28. const wideInspectorWidth = 32 // frameInsetRows and frameInsetCols map screen cells to frame-inner cells: @@ -73,6 +92,8 @@ func measureFrame(width, height int, inspectorOpen bool) frameMetrics { case layoutWide: metrics.inspectorWidth = wideInspectorWidth metrics.inspectorHeight = metrics.regionHeight + // -1: go-html's own C/R gutter column (S:15.1), now painted with the + // GutterRule "│" (S:15.6, shellWideTheme) instead of left blank. metrics.mainWidth = max(1, metrics.innerWidth-wideInspectorWidth-1) case layoutOverlay: if inspectorOpen { @@ -91,8 +112,164 @@ func measureFrame(width, height int, inspectorOpen bool) frameMetrics { return metrics } +// shellCTML is the app shell's H/F-only markup for the ONE shape whose +// header+region+footer still cannot join into a single render call -- +// Overlay layouts with the inspector open -- see shell.ctml for the seams +// it exposes and renderFrame's own doc comment for why that one join stays +// host-side even though the region between the bands (renderInspectorStack) +// no longer hand-composes. +// +//go:embed shell.ctml +var shellCTML []byte + +// shellRegionCTML is the shell shape for a single active region pane +// (Narrow either way; Overlay with the inspector closed) -- see +// shellregion.ctml for the seams it exposes. +// +//go:embed shellregion.ctml +var shellRegionCTML []byte + +// shellWideCTML is the shell shape for the Wide layout kind's side-by-side +// main panel and inspector -- see shellwide.ctml for the seams it exposes +// and the R-slot width contract. +// +//go:embed shellwide.ctml +var shellWideCTML []byte + +// shellFrameTheme maps the shell markup onto the existing palette. The H/F +// bands are stripped of the default theme's border and padding -- the same +// technique overlayFrameTheme and dataListTheme already use -- because both +// slots here carry content the host has already fitted to the exact band +// width (renderPanelBar's own fitLine call and footerLine's, both below); +// any theme chrome would offset that pre-fitted content instead of leaving +// it byte-exact. +func shellFrameTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Header = lipgloss.NewStyle() + theme.Footer = lipgloss.NewStyle() + return theme +} + +// shellRegionTheme extends shellFrameTheme with a zero-chrome Content slot: +// C's content -- the single active pane -- arrives pre-fitted to its exact +// target width by renderFrame, exactly as H/F already do, so TermTheme's +// default (0,1) Content gutter is stripped the same way Header/Footer +// already are (go-html v0.14.0's themeable Content lever, docs/ctml.md +// S:15.2) -- otherwise the pre-fitted content lands one column over budget +// and gets word-wrapped onto a spurious extra row (S:15.5). +func shellRegionTheme(styles uiStyles) *html.TermTheme { + theme := shellFrameTheme(styles) + theme.Content = lipgloss.NewStyle() + return theme +} + +// shellWideTheme extends shellRegionTheme with a zero-chrome Aside (R) slot +// for the Wide layout's side-by-side main+inspector: both C and R arrive +// pre-fitted to their own exact width by renderFrame, so both slots' default +// chrome is stripped (Aside was already themeable pre-v0.14.0; Content +// joined it in round 4, S:15.2). GutterRule repaints the C/R junction column +// go-html always reserves (S:15.1) with the historic "│" divider instead of +// leaving it blank (S:15.6, go-html v0.15.0); Rule is repointed at +// styles.separator (Foreground(t.border)) so the glyph matches the exact +// colour the pre-.ctml renderWorkspaceRegion's own styles.separator pane +// drew (git history), rather than go-html's own similar-but-different +// default border colour. +func shellWideTheme(styles uiStyles) *html.TermTheme { + theme := shellRegionTheme(styles) + theme.Aside = lipgloss.NewStyle() + theme.GutterRule = "│" + theme.Rule = styles.separator + return theme +} + +// shellBindings supplies the shell's two verbatim values. header and footer +// arrive already pre-rendered and pre-fitted to metrics.innerWidth by +// renderFrame, exactly as the pre-.ctml shell computed them. +func shellBindings(header, footer string) ctml.Bindings { + return ctml.Bindings{Values: map[string]any{"header": header, "footer": footer}} +} + +// shellRegionBindings supplies shellregion.ctml's three verbatim values. +// content is whichever pane is active, pre-fitted by renderFrame to +// metrics.innerWidth x metrics.regionHeight. +func shellRegionBindings(header, footer, content string) ctml.Bindings { + return ctml.Bindings{Values: map[string]any{"header": header, "footer": footer, "content": content}} +} + +// shellWideBindings supplies shellwide.ctml's four verbatim values. main and +// inspector are pre-fitted by renderFrame to their own exact target width +// (metrics.mainWidth, wideInspectorWidth) x metrics.regionHeight. +func shellWideBindings(header, footer, main, inspector string) ctml.Bindings { + return ctml.Bindings{Values: map[string]any{ + "header": header, "footer": footer, "main": main, "inspector": inspector, + }} +} + +// renderWideLayout is renderBandLayout's Wide-shape sibling (layout.go only +// -- overlayframe.go's shared renderBandLayout stays untouched, since every +// other caller renders at go-html's default side-slot widths): it +// additionally requests AsideWidth so R renders at wideInspectorWidth +// instead of go-html's unrequested 28-column default (docs/ctml.md S:15.1, +// go-html v0.15.0) -- the one render path in this package that needs a +// non-default side-slot width. +func renderWideLayout(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 + // renderBandFrame, overlayframe.go). + return "" + } + return layout.RenderTerm(html.NewContext(), html.TermOptions{Width: width, Theme: theme, AsideWidth: wideInspectorWidth}) +} + // renderFrame composes the permanent workspace shell. All truncation is done // by Lip Gloss on rendered cell widths; no ANSI string is sliced manually. +// +// The outer rounded border stays hand composed: go-html's TermTheme has no +// whole-page border concept -- Header/Footer/Sidebar/Aside each style their +// own band, nothing wraps H+middle+F together (S:15 has no such style). +// +// The region (the active main panel, plus the wide/toggled inspector) joins +// the shell's own declarative surface for every shape but one, since +// go-html v0.14.0: a zero-chrome Content slot (and the pre-existing +// themeable Aside) now passes pre-fitted content through byte-exact at the +// slot's full width (docs/ctml.md S:15.2/S:15.5 -- shellregion.ctml's and +// shellwide.ctml's own header comments have the width contract in full), so +// Wide's main+inspector and every single-pane shape render through ONE +// go-html RenderTerm call (renderBandLayout/renderWideLayout) instead of +// lipgloss.JoinHorizontal/fitPane host composition. Wide's R additionally +// rides go-html v0.15.0's AsideWidth request (S:15.1, renderWideLayout) for +// its historic 32-column inspector, and its C/R junction repaints go-html's +// always-reserved gutter column with the historic "│" rule (S:15.6, +// shellWideTheme) instead of leaving it blank. +// +// The ONE shape that still cannot join header+region+footer into a single +// call -- Overlay with the inspector open -- stays a host-side +// lipgloss.JoinVertical of three independently rendered pieces: shell.ctml's +// H/F-only layout (split by renderBandFrame, unchanged), and +// renderInspectorStack's own region render sat between them. What changed +// this slice (docs/ctml.md S:15.7, go-html v0.15.0) is that the REGION +// itself is no longer hand-stacked: renderInspectorStack now renders a +// genuine go-html "HC" layout (shellinspectorpair.ctml) -- H the inspector, +// its own bottom border the divider rule, C the main preview -- through its +// OWN RenderTerm call with its own theme. It cannot join the OUTER +// header/footer render into that same call: TermTheme is one flat struct +// threaded through every nested Layout in a single render (term_layout.go's +// termRenderer carries exactly one theme field), so a unified call would +// force the page header's H (needing zero chrome, pre-fitted byte-exact +// like every sibling shell) and the pair's own H (needing its natural +// bordered chrome, since that border IS the rule) to share one Header +// style -- confirmed by a throwaway probe during this slice (not part of +// the shipped diff): zeroing Header for the page header silently zeroed the +// pair's divider too. See shellinspectorpair.ctml's header comment and +// renderInspectorStack's own comment for the full account. +// +// The tab strip and session strip (both already fully styled, single-line, +// pre-fitted to metrics.innerWidth) join as the shell's one "header" value; +// the status/key-hint line is the "footer" value. Verbatim is emitted +// exactly as supplied with no added blank, so the "\n" join building header +// is the only seam deciding header/session adjacency -- matching the +// pre-.ctml JoinVertical's own row order byte-for-byte. func renderFrame(spec frameSpec, styles uiStyles) string { if spec.Width <= 0 || spec.Height <= 0 { return "" @@ -101,11 +278,32 @@ func renderFrame(spec frameSpec, styles uiStyles) string { return minimalFrame(spec.Width, spec.Height) } metrics := measureFrame(spec.Width, spec.Height, spec.InspectorOpen) - header := renderPanelBar(spec.Active, metrics.innerWidth, metrics.kind, styles) + tabstrip := renderPanelBar(spec.Active, metrics.innerWidth, metrics.kind, styles) sessions := fitLine(styles.title.Render("SESSIONS")+" "+styles.session.Render(spec.SessionStrip), metrics.innerWidth, styles.session) - region := renderWorkspaceRegion(spec, metrics, styles) + header := core.Join("\n", tabstrip, sessions) footer := fitLine(spec.Footer, metrics.innerWidth, styles.footer) - inside := lipgloss.JoinVertical(lipgloss.Left, header, sessions, region, footer) + + var inside string + switch { + case metrics.kind == layoutOverlay && spec.InspectorOpen: + region := renderInspectorStack(spec, metrics, styles) + head, foot := renderBandFrame(shellCTML, metrics.innerWidth, shellFrameTheme(styles), shellBindings(header, footer)) + inside = lipgloss.JoinVertical(lipgloss.Left, head, region, foot) + case metrics.kind == layoutWide: + main := fitPane(spec.Main, metrics.mainWidth, metrics.mainHeight, styles.panel) + inspector := fitPane(spec.Inspector, metrics.inspectorWidth, metrics.inspectorHeight, styles.inspector) + inside = renderWideLayout(shellWideCTML, metrics.innerWidth, shellWideTheme(styles), shellWideBindings(header, footer, main, inspector)) + default: // layoutNarrow (either pane) and layoutOverlay with the inspector closed + content, style := spec.Main, styles.panel + width, height := metrics.mainWidth, metrics.mainHeight + if metrics.kind == layoutNarrow && spec.InspectorOpen { + content, style = spec.Inspector, styles.inspector + width, height = metrics.inspectorWidth, metrics.inspectorHeight + } + fitted := fitPane(content, width, height, style) + inside = renderBandLayout(shellRegionCTML, metrics.innerWidth, shellRegionTheme(styles), shellRegionBindings(header, footer, fitted)) + } + return styles.outerFrame. Width(metrics.innerWidth). Height(metrics.innerHeight). @@ -114,26 +312,63 @@ func renderFrame(spec frameSpec, styles uiStyles) string { Render(inside) } -func renderWorkspaceRegion(spec frameSpec, metrics frameMetrics, styles uiStyles) string { - switch metrics.kind { - case layoutWide: - main := fitPane(spec.Main, metrics.mainWidth, metrics.mainHeight, styles.panel) - separator := fitPane(core.Repeat("│\n", max(0, metrics.regionHeight-1))+"│", 1, metrics.regionHeight, styles.separator) - inspector := fitPane(spec.Inspector, metrics.inspectorWidth, metrics.inspectorHeight, styles.inspector) - return lipgloss.JoinHorizontal(lipgloss.Top, main, separator, inspector) - case layoutOverlay: - if spec.InspectorOpen { - inspector := fitPane(spec.Inspector, metrics.inspectorWidth, metrics.inspectorHeight, styles.inspector) - separator := fitLine(core.Repeat("─", metrics.innerWidth), metrics.innerWidth, styles.separator) - main := fitPane(spec.Main, metrics.mainWidth, metrics.mainHeight, styles.panel) - return lipgloss.JoinVertical(lipgloss.Left, inspector, separator, main) - } - case layoutNarrow: - if spec.InspectorOpen { - return fitPane(spec.Inspector, metrics.innerWidth, metrics.regionHeight, styles.inspector) - } - } - return fitPane(spec.Main, metrics.mainWidth, metrics.mainHeight, styles.panel) +// shellInspectorPairCTML is the mid-page vertical pane pair markup for the +// Overlay layout kind (80-119 cols) with the inspector open -- see +// shellinspectorpair.ctml for the seams it exposes and why it renders +// standalone rather than nested inside shell.ctml's own H/F bands. +// +//go:embed shellinspectorpair.ctml +var shellInspectorPairCTML []byte + +// inspectorPairTheme keeps Header's default bottom border -- the S:15.7 +// divider rule -- but strips its bold/violet text styling and (0,1) padding +// so the inspector's own pre-fitted, pre-styled content (fitPane, +// styles.inspector) passes through unmodified except for gaining that one +// extra border row; Content is zeroed the same way shellRegionTheme zeroes +// it, so the main preview passes through byte-exact too. BorderForeground +// is repointed at styles.theme.border (the same colour styles.separator +// itself paints with) so the divider matches the exact shade the +// pre-.ctml renderWorkspaceRegion's own styles.separator pane drew (git +// history), not go-html's own similar-but-different default border colour. +func inspectorPairTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Header = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(styles.theme.border) + theme.Content = lipgloss.NewStyle() + return theme +} + +// inspectorPairBindings supplies shellinspectorpair.ctml's two verbatim +// values. Both arrive pre-fitted by renderInspectorStack to their own exact +// target width x height (metrics.inspectorWidth/Height for inspector, +// metrics.mainWidth/Height for main). +func inspectorPairBindings(inspector, main string) ctml.Bindings { + return ctml.Bindings{Values: map[string]any{"inspector": inspector, "main": main}} +} + +// renderInspectorStack composes the region for Overlay layouts (80-119 +// cols) with the inspector open: the full-width inspector stacked above a +// compact main-panel preview, with a rule between. go-html v0.15.0's +// documented mid-page vertical-pair idiom (docs/ctml.md S:15.7) replaces the +// old lipgloss.JoinVertical plus a manually core.Repeat("─", ...) rule line +// with one go-html "HC" layout render (shellinspectorpair.ctml): H the +// inspector -- its own bottom border the divider -- C the main preview. +// +// This still renders through its OWN RenderTerm call rather than nesting +// inside shell.ctml's H/F bands in one page-wide call: see +// shellinspectorpair.ctml's header comment and renderFrame's own doc +// comment for why a single unified call cannot give the page header's H +// (zero-chrome) and this pair's own H (bordered) different styles from the +// one flat TermTheme a render call threads through every nested Layout. +// renderFrame still joins this function's output between the (unchanged, +// separately rendered) header/footer bands with lipgloss.JoinVertical -- +// that outer join, not this region's own composition, is what remains +// host-side. +func renderInspectorStack(spec frameSpec, metrics frameMetrics, styles uiStyles) string { + inspector := fitPane(spec.Inspector, metrics.inspectorWidth, metrics.inspectorHeight, styles.inspector) + main := fitPane(spec.Main, metrics.mainWidth, metrics.mainHeight, styles.panel) + return renderBandLayout(shellInspectorPairCTML, metrics.innerWidth, inspectorPairTheme(styles), inspectorPairBindings(inspector, main)) } func fitLine(content string, width int, style lipgloss.Style) string { diff --git a/cli/tui/layout_test.go b/cli/tui/layout_test.go index c791c727..7a4dd178 100644 --- a/cli/tui/layout_test.go +++ b/cli/tui/layout_test.go @@ -7,6 +7,9 @@ import ( "testing" "github.com/charmbracelet/lipgloss" + + "dappco.re/go/html" + "dappco.re/go/html/ctml" ) func TestChooseLayout_Good(t *testing.T) { @@ -33,6 +36,30 @@ func TestChooseLayout_Ugly(t *testing.T) { } } +// TestWideInspectorWidth_MatchesRequest pins wideInspectorWidth (layout.go) +// against a live render of the REAL shellwide.ctml shell path: since go-html +// v0.15.0, R's outer width is a REQUEST (TermOptions.AsideWidth, docs/ctml.md +// S:15.1) the caller makes going in, not a fixed upstream constant to mirror +// -- S:15.5's own doctrine is "the box map is the render-time source of +// truth", so this test reads the requested width back from the render's own +// BoxMap rather than asserting a hardcoded upstream default. If go-html ever +// stops honouring the AsideWidth request, this fails loudly here instead of +// shellwide.ctml's frame silently reflowing back to go-html's unrequested +// 28-column default. +func TestWideInspectorWidth_MatchesRequest(t *testing.T) { + styles := newUIStyles(midnightTheme()) + layout, err := ctml.ParseLayout(shellWideCTML, shellWideBindings("HEADER", "FOOTER", "MAIN", "INSPECTOR")) + if err != nil { + t.Fatalf("parse shellwide.ctml: %v", err) + } + _, boxes := layout.RenderTermBoxes(html.NewContext(), html.TermOptions{ + Width: 140, Theme: shellWideTheme(styles), AsideWidth: wideInspectorWidth, + }) + if got := boxes["R"].Width; got != wideInspectorWidth { + t.Fatalf("shellwide.ctml's live R box width = %d, want the requested wideInspectorWidth = %d (ctml.md S:15.1) -- go-html stopped honouring the AsideWidth request", got, wideInspectorWidth) + } +} + func TestPanelID_Good(t *testing.T) { want := []panelID{panelChat, panelWork, panelModels, panelService, panelData} panel := panelChat diff --git a/cli/tui/overlayframe.go b/cli/tui/overlayframe.go new file mode 100644 index 00000000..ea04ac0c --- /dev/null +++ b/cli/tui/overlayframe.go @@ -0,0 +1,92 @@ +// 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 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 { + // 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: theme}) + lines := core.Split(rendered, "\n") + split := min(boxes["H"].Height, len(lines)) + return core.Join("\n", lines[:split]...), core.Join("\n", lines[split:]...) +} + +// 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 + // renderBandFrame). + return "" + } + return layout.RenderTerm(html.NewContext(), html.TermOptions{Width: width, Theme: theme}) +} + +// 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 00000000..536246d1 --- /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/palette.ctml b/cli/tui/palette.ctml new file mode 100644 index 00000000..219ce2d5 --- /dev/null +++ b/cli/tui/palette.ctml @@ -0,0 +1,54 @@ + + + +

    Commands

    + +
    +
    {{row.marker}} {{row.title}}
    +
    {{row.detail}}
    +
    +

    {{row.text}}

    +

    {{row.label}}

    +

    type filters · ↑/↓ select · enter runs · esc closes

    +
    +
    diff --git a/cli/tui/palette.go b/cli/tui/palette.go index 9f8e76be..31c39a08 100644 --- a/cli/tui/palette.go +++ b/cli/tui/palette.go @@ -3,13 +3,18 @@ package tui import ( + _ "embed" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" 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" coreio "dappco.re/go/io" ) @@ -79,7 +84,6 @@ func newCommandPalette(styles uiStyles) *commandPalette { model.Title = "Commands" model.SetShowStatusBar(false) model.SetFilteringEnabled(true) - model.Styles.Title = styles.title model.SetFilterState(list.Filtering) return &commandPalette{commands: commands, byID: byID, list: model} } @@ -152,12 +156,112 @@ func (palette *commandPalette) Update(message tea.Msg) tea.Cmd { return command } -func (palette *commandPalette) View(width, height int) string { +// paletteCTML is the command palette overlay's markup — see palette.ctml +// for the seams it exposes (row/empty/page sequences, class tokens, the +// row box ids). +// +//go:embed palette.ctml +var paletteCTML []byte + +// commandPaletteBindings derives the palette's rows from the list model's +// own state: the current page of visible commands 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, the same idiom +// picker.ctml established — plus the zero-or-one-row conditional sections +// for the empty and page states. detail already carries the +// "— unavailable: reason" suffix (commandListItem.Description), so an +// unavailable command renders with the same row-idle/row-active paint an +// available one gets — matching the original list.DefaultDelegate, which +// never styled the two states apart. Title and detail truncate to the F +// band's own row budget (width-2, docs/ctml.md S:15.5) because a wrapped +// row would overflow the page the list delegate sized. +func commandPaletteBindings(palette *commandPalette, width int) ctml.Bindings { + sequences := map[string][]map[string]any{ + "rows": {}, + "empty": {}, + "page": {}, + } + if palette == nil { + return ctml.Bindings{Sequences: sequences} + } + visible := palette.list.VisibleItems() + start, end := palette.list.Paginator.GetSliceBounds(len(visible)) + active := palette.list.Index() - start + budget := max(1, width-2) + for index, raw := range visible[start:end] { + entry, ok := raw.(commandListItem) + if !ok { + continue + } + state, marker := "row-idle", "○" + if index == active { + state, marker = "row-active", "›" + } + sequences["rows"] = append(sequences["rows"], map[string]any{ + "state": state, + "marker": marker, + "id": string(entry.command.ID), + "title": ansi.Truncate(entry.Title(), budget, "…"), + "detail": ansi.Truncate(entry.Description(), budget, "…"), + }) + } + if len(visible) == 0 && palette.list.FilterState() != list.Filtering { + sequences["empty"] = append(sequences["empty"], map[string]any{"text": "No items."}) + } + if palette.list.Paginator.TotalPages > 1 { + sequences["page"] = append(sequences["page"], map[string]any{ + "label": core.Sprintf("page %d/%d", palette.list.Paginator.Page+1, palette.list.Paginator.TotalPages), + }) + } + return ctml.Bindings{Sequences: sequences} +} + +// commandPaletteTheme maps the markup's class tokens onto the existing +// palette. It carries the same band chrome every overlay uses +// (overlayFrameTheme's blank header / one-row-padded footer, so the +// palette's key-hint line keeps the same gap every overlay draws above its +// footer) plus the row-selection classes picker.ctml established, since +// the palette is the first overlay to carry a selectable row list — a +// plain renderOverlayFrame call has no seam for those extra classes, so +// this theme is built directly and rendered through renderBandFrame, the +// same bypass datalist.ctml uses to borrow the overlay band chrome from a +// primary panel. +func commandPaletteTheme(styles uiStyles) *html.TermTheme { + theme := html.DefaultTermTheme() + theme.Text = styles.answer + theme.Heading = styles.title + theme.Header = lipgloss.NewStyle() + theme.Footer = lipgloss.NewStyle().Padding(1, 0, 0, 0) + theme.Classes = map[string]lipgloss.Style{ + "row-idle": styles.answer, + "row-active": styles.accent, + "row-hint": styles.thought, + "palette-empty": styles.status, + "palette-page": styles.status, + "overlay-keys": styles.status, + } + return theme +} + +// View renders the palette overlay through palette.ctml: the fixed +// "Commands" header, the live Bubbles filter input composed between the +// bands while filtering (the overlays' HF chrome+widget idiom, exactly +// datalist.ctml's own borrowing of it, lent back to an actual overlay), +// and the row/empty/page/key-hint footer beneath it. Cursor movement, +// fuzzy filtering, and pagination stay in list.Model — this is the render +// swap over the old palette.list.View(). +func (palette *commandPalette) View(width, height int, styles uiStyles) string { if palette == nil { return "" } palette.list.SetSize(max(1, width), max(6, height)) - return palette.list.View() + head, foot := renderBandFrame(paletteCTML, width, commandPaletteTheme(styles), commandPaletteBindings(palette, width)) + parts := []string{head} + if palette.list.SettingFilter() { + parts = append(parts, palette.list.FilterInput.View()) + } + parts = append(parts, foot) + return fitPane(core.Join("\n", parts...), width, height, styles.panel) } func (palette *commandPalette) SetAgentCapabilities(capabilities []agentCapability) { @@ -810,6 +914,7 @@ const ( overlayDataNote overlayDataFilter overlayDataBulk + overlaySettings ) type helpOverlay struct { diff --git a/cli/tui/palette_test.go b/cli/tui/palette_test.go index c97e29f1..9560d4a8 100644 --- a/cli/tui/palette_test.go +++ b/cli/tui/palette_test.go @@ -7,12 +7,182 @@ import ( "testing" "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/inference/dataset" ) +// paletteWithCommands builds a commandPalette over an explicit command set — +// the palette.ctml render tests' equivalent of picker_test.go's +// pickerWithItems, giving each test deterministic titles/descriptions +// instead of the full default catalogue. Filter state starts Unfiltered, +// same as newPicker's underlying list.Model, so callers opt into filtering +// explicitly. +func paletteWithCommands(commands ...workspaceCommand) *commandPalette { + items := make([]list.Item, 0, len(commands)) + byID := make(map[commandID]workspaceCommand, len(commands)) + for _, command := range commands { + items = append(items, commandListItem{command: command}) + byID[command.ID] = command + } + model := list.New(items, list.NewDefaultDelegate(), 68, 16) + model.Title = "Commands" + model.SetShowStatusBar(false) + model.SetFilteringEnabled(true) + return &commandPalette{commands: commands, byID: byID, list: model} +} + +func TestRenderCommandPalette_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + palette := newCommandPalette(styles) + palette.list.SetSize(80, 20) + palette.list.SetFilterState(list.Unfiltered) + plain := ansi.Strip(palette.View(80, 20, styles)) + lines := strings.Split(plain, "\n") + + if strings.TrimSpace(lines[0]) != "Commands" { + t.Fatalf("palette must open with its title line: %q", lines[0]) + } + if !strings.Contains(plain, "› New session") { + t.Fatalf("the first command must carry the active marker: %q", plain) + } + if !strings.Contains(plain, "Create and open a blank chat session") { + t.Fatalf("row missing its description hint: %q", plain) + } + if !strings.Contains(plain, "○ ") { + t.Fatalf("idle rows must carry the idle marker: %q", plain) + } + if !strings.Contains(plain, "type filters · ↑/↓ select · enter runs · esc closes") { + t.Fatalf("palette missing the key footer: %q", plain) + } + + row := -1 + for index, line := range lines { + if strings.Contains(line, "› New session") { + 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], "Create and open a blank chat session") { + t.Fatalf("hint must sit directly beneath its title line: %q", lines[row+1]) + } + + 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 TestRenderCommandPalette_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + palette := newCommandPalette(styles) + for _, width := range []int{0, -4} { + if got := palette.View(width, 16, styles); got != "" { + t.Fatalf("View(width=%d) = %q, want empty", width, got) + } + } + if got := (*commandPalette)(nil).View(80, 16, styles); got != "" { + t.Fatalf("View on a nil palette = %q, want empty", got) + } +} + +func TestRenderCommandPalette_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + + // While filtering, the live Bubbles filter widget composes as an + // EXTRA line between the header and the rows — the header stays + // visible throughout, unlike Bubbles' own titleView, which swapped + // the title out for the filter box. + filtered := paletteWithCommands( + workspaceCommand{ID: "cmd.alpha", Title: "Alpha", Description: "First command", Available: true}, + workspaceCommand{ID: "cmd.beta", Title: "Beta", Description: "Second command", Available: true}, + ) + filtered.list.SetSize(80, 20) + filtered.list.SetFilterText("beta") + filtered.list.SetFilterState(list.Filtering) + plain := ansi.Strip(filtered.View(80, 20, styles)) + if !strings.Contains(plain, "Commands") { + t.Fatalf("the header stays visible while filtering: %q", plain) + } + if !strings.Contains(plain, "› Beta") || strings.Contains(plain, "Alpha") { + t.Fatalf("filtering must render matches only, cursor on the first: %q", plain) + } + + // A match-free filter renders no rows and no "No items." mislabel — + // the empty state is reserved for a genuinely unfiltered empty list. + filtered.list.SetFilterText("zzz") + filtered.list.SetFilterState(list.Filtering) + plain = ansi.Strip(filtered.View(80, 20, 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) + } + + // An unavailable command's reason already lives in its description + // text (commandListItem.Description) — the original + // list.DefaultDelegate never painted available/unavailable rows + // apart, so the row carries no separate class for it. + reasoned := paletteWithCommands(workspaceCommand{ + ID: "cmd.gated", Title: "Gated command", Description: "Needs setup", Available: false, Reason: "not connected", + }) + reasoned.list.SetSize(80, 20) + plain = ansi.Strip(reasoned.View(80, 20, styles)) + if !strings.Contains(plain, "Needs setup — unavailable: not connected") { + t.Fatalf("an unavailable row must carry its reason: %q", plain) + } + + // A long title/description truncates to the row budget instead of + // wrapping, so a page of rows keeps the density the list model + // paginates by. + long := paletteWithCommands(workspaceCommand{ + ID: "cmd.long", + Title: "A very long command title that will not fit the row budget at all", + Description: "An equally long description that also needs truncation to fit cleanly", + Available: true, + }) + long.list.SetSize(40, 20) + plain = ansi.Strip(long.View(40, 20, styles)) + if !strings.Contains(plain, "…") { + t.Fatalf("an over-budget row 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 commands than one short pane holds: only the current page + // renders, with the page position stated. + commands := make([]workspaceCommand, 0, 30) + for index := range 30 { + commands = append(commands, workspaceCommand{ + ID: commandID(core.Sprintf("cmd.page%02d", index)), + Title: core.Sprintf("Command %02d", index), + Description: "A paginated command", + Available: true, + }) + } + paged := paletteWithCommands(commands...) + paged.list.SetSize(40, 10) + plain = ansi.Strip(paged.View(40, 10, styles)) + if !strings.Contains(plain, "page 1/") { + t.Fatalf("a paginated list must state its page position: %q", plain) + } + if !strings.Contains(plain, "› Command 00") { + t.Fatalf("the first page must open on the first item: %q", plain) + } + if strings.Contains(plain, "Command 29") { + t.Fatalf("rows beyond the current page must not render: %q", plain) + } +} + func TestCommandPalette_Good(t *testing.T) { styles := newUIStyles(midnightTheme()) palette := newCommandPalette(styles) diff --git a/cli/tui/picker.ctml b/cli/tui/picker.ctml new file mode 100644 index 00000000..7cedd303 --- /dev/null +++ b/cli/tui/picker.ctml @@ -0,0 +1,48 @@ + + +
    +

    Models

    +

    Filter: {{row.value}}

    +
    +
    {{row.marker}} {{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 ef2385b5..ccef5bb5 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,118 @@ 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 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": {}, + "rows": {}, + "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 + } + state, marker := "row-idle", "○" + if index == active { + state, marker = "row-active", "›" + } + 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."}) + } + 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 00000000..6feb73d2 --- /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/settings.ctml b/cli/tui/settings.ctml new file mode 100644 index 00000000..4c046bfe --- /dev/null +++ b/cli/tui/settings.ctml @@ -0,0 +1,35 @@ + + +
    +

    settings

    +
    +
    {{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 599e7ecb..0d1d03fb 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,59 @@ 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 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 { + rows := make([]map[string]any, 0, len(form.rows())) + for index, row := range form.rows() { + state, marker := "row-idle", "○" + if index == form.cursor { + state, marker = "row-active", "›" } - b.WriteString(cursor + name + " " + styles.title.Render("‹ "+row.value+" ›") + "\n") - b.WriteString(" " + styles.thought.Render(row.hint) + "\n\n") + rows = append(rows, map[string]any{ + "state": state, "marker": marker, + "name": row.name, "value": row.value, "hint": row.hint, + }) + } + return ctml.Bindings{Sequences: map[string][]map[string]any{"rows": rows}} +} + +// 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 "" } - b.WriteString(styles.status.Render("↑/↓ select · ←/→ change · values apply as hinted")) - return b.String() + 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 00000000..b36f229d --- /dev/null +++ b/cli/tui/settings_test.go @@ -0,0 +1,189 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + coreio "dappco.re/go/io" + tea "github.com/charmbracelet/bubbletea" + "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 · ctrl+s saves · esc closes") { + 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) + } +} + +// 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) + } +} diff --git a/cli/tui/shell.ctml b/cli/tui/shell.ctml new file mode 100644 index 00000000..f4a9c2b4 --- /dev/null +++ b/cli/tui/shell.ctml @@ -0,0 +1,49 @@ + + + + + + diff --git a/cli/tui/shellinspectorpair.ctml b/cli/tui/shellinspectorpair.ctml new file mode 100644 index 00000000..6f213bc0 --- /dev/null +++ b/cli/tui/shellinspectorpair.ctml @@ -0,0 +1,42 @@ + + + + + + diff --git a/cli/tui/shellregion.ctml b/cli/tui/shellregion.ctml new file mode 100644 index 00000000..108be7c5 --- /dev/null +++ b/cli/tui/shellregion.ctml @@ -0,0 +1,37 @@ + + + + + + + diff --git a/cli/tui/shellwide.ctml b/cli/tui/shellwide.ctml new file mode 100644 index 00000000..e105ebd4 --- /dev/null +++ b/cli/tui/shellwide.ctml @@ -0,0 +1,60 @@ + + + + + + + + diff --git a/cli/tui/tabs.ctml b/cli/tui/tabs.ctml index a1e7b551..f17ad25d 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 bd93fbea..81b7afab 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