Add the reference terminal shell (TUI 0.1) - #11
Conversation
Introduce the two-layer color system the rest of the shell is built on: a raw Palette of ten neutrals plus semantic hues, resolved into Tokens that name roles (Background, TextSubtle, BorderActive, Divider) rather than values. Callers ask for a role; the active theme decides what it looks like. Both ramps are deliberately neutral — max channel spread 17 — because the first attempt tinted the greys blue and a divider at 1.2:1 against its background was invisible. Divider is its own token at 2.79:1 rather than reusing a border color. Mix interpolates in Oklab, not sRGB. A green-to-red gauge blended in sRGB passes through a muddy olive with a visibly dark midpoint; Oklab keeps perceived lightness constant across the ramp, which is what makes the context meter read as a smooth scale. Adds the Charm v2 dependencies. Note the module path is charm.land/..., not github.com/charmbracelet/... — v2 moved.
The component layer: a chainable Style builder, Panel, StatusLine,
Fields, and the fill meters. Every component returns exact dimensions
so callers stack them without measuring.
Three constraints are load-bearing rather than stylistic:
Content styles carry a foreground and never a background. Lip Gloss
terminates every styled run with a full SGR reset, so a container that
painted its own background lost it at the first styled run inside it —
which is what produced the color banding. The application surface is
set once on the Bubble Tea View instead.
ExpandTabs runs at every text leaf. lipgloss.Width("\t") is 0 but a
terminal advances to the next tab stop, so an unexpanded tab paints
wider than it measures and corrupts every row to its right.
Overlay splices rows by hand because Lip Gloss cannot do it:
Canvas.Compose draws each layer at full canvas bounds and Layer.Draw
ignores its own X/Y, so composing a pane over a frame erases the frame.
StatusLine sheds segments from the right one at a time. Dropping the
right group wholesale made the line lurch on resize — a filling
segment would gain the whole group's width across a single column of
terminal, taking the meter from too-small-to-draw to enormous.
Holds what each producer contributed to each of the seven regions, and resolves the ordering the frontend protocol specifies: ranked first, then priority, then arrival sequence. Backed by a fixed-length array rather than a map so iteration order is positional and cannot vary run to run — the same determinism rule the kernel's event path follows.
Turns a protocol RenderTree into styled terminal rows, and walks the same tree to collect activation targets so a frontend can dispatch an ActionNode without a second traversal. Pure: it takes a tree and a width and returns strings. No I/O, no state, no clock — which is what lets the shell's frame tests assert against exact painted output. Unknown node kinds fall back to FallbackText rather than rendering nothing. A frontend built against an older protocol revision must degrade to showing something, not silently drop a producer's content.
The frontend itself: layout solver, focus ring, keymap layers, agent switcher, composer, status bar, and the overlay that carries a plan/apply decision. Layout arithmetic lives in exactly one place. Layout.ComposerTop is the single source for where the bands stack, because a duplicate of it in the cursor path already drifted once — the header grew from a line into a bordered box, the frame accounted for it and the cursor did not, and the caret sat two rows above its text. The frame tests assert against painted output rather than recomputed arithmetic so that class of drift fails loudly. Full-screen takeover is entirely View properties in Bubble Tea v2 — AltScreen, BackgroundColor, WindowTitle, Cursor, MouseMode — and there are no matching program options. MouseMode is what makes the wheel scroll the transcript; without it the wheel silently scrolls the terminal's own buffer behind the alt screen, which looks like it works while doing something else. An overlay captures the keyboard entirely, and esc explicitly does not resolve a pending decision: turning "go away" into an allow or a deny on the operator's behalf would be indefensible. demo.go is a fixture with an expiry date. It exists only because the kernel-side attach path does not yet; delete it when the real bridge lands rather than growing it into a second implementation.
Thin wiring: flags, a stderr slog handler, the terminal, the program. It opens /dev/tty (CONOUT$ on Windows) and passes it as both input and output rather than using the process stdio. As a go-plugin subprocess the shell's stdout carries the handshake and is piped into the host's logger, so painting there corrupts the handshake and reading stdin competes with the plugin transport. The build-tagged openTTY is the whole reason this binary is not three lines. Currently driven by the scripted demo source, since no kernel-side frontend-attach path exists to drive it for real yet.
Adds docs/first-party/frontends/tui.md — process shape and TTY ownership, the design system, screen layout, where session data lives and why, keymap layers, and what is deliberately deferred. It is descriptive, not normative. The frontend spec leaves focus, keybindings, resize, and scrollback to each frontend on purpose, and this documents one implementation's choices rather than adding requirements to the protocol. Registers it in the mkdocs nav and llmstxt sections, and notes the shell's current state in the project CLAUDE.md.
Dependency ReviewThe following issues were found:
License Issuesgo.mod
OpenSSF ScorecardScorecard details
Scanned Files
|
There was a problem hiding this comment.
Pull request overview
Adds the reference terminal shell (TUI 0.1) as a first-party frontend implementation, including its UI/token system, render-tree painter, region placement store, Bubble Tea shell model, and a cmd/tui binary to run it (currently against a scripted demo source). It also extends the first-party docs and MkDocs nav to document the reference TUI’s design and behavior.
Changes:
- Introduces new
internal/tui/{theme,ui,paint,region,shell}packages (with tests) implementing tokens/components, RenderTree painting + focus targets, region placement ordering, and the Bubble Tea model. - Adds
cmd/tuiwiring to run the shell with direct TTY I/O (safe under go-plugin) and a demo EventSource. - Registers new first-party frontend documentation in MkDocs navigation and updates first-party catalog index.
Reviewed changes
Copilot reviewed 54 out of 55 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| mkdocs.yml | Adds frontends section to docs nav and include globs. |
| docs/first-party/index.md | Updates first-party catalog intro + adds frontends link. |
| docs/first-party/frontends/README.md | Adds index page for frontend implementation docs. |
| docs/first-party/frontends/tui.md | Documents the reference TUI shell design (descriptive). |
| go.mod | Adds Bubble Tea v2, Lip Gloss v2, and ANSI helper deps. |
| go.sum | Records new module checksums for added dependencies. |
| CLAUDE.md | Updates repo “Current state” to mention the new cmd/tui binary and design doc. |
| cmd/tui/main.go | Runs the reference TUI against the demo source; opens TTY; sets up logging/emitter. |
| cmd/tui/tty_unix.go | Opens /dev/tty for direct terminal I/O on non-Windows. |
| cmd/tui/tty_windows.go | Opens CONOUT$ for direct console output on Windows. |
| internal/tui/theme/theme.go | Implements palette/tokens, Oklab mixing, ramps, and derived styles. |
| internal/tui/theme/theme_test.go | Tests token/style invariants, contrast, mixing/ramp behavior, and mappings. |
| internal/tui/theme/export_test.go | Exposes Oklab conversion to external tests. |
| internal/tui/theme/doc.go | Package docs for the token/design system layer. |
| internal/tui/theme/README.md | Explains palette/tokens split, ramps, “one surface” rule, and invariants. |
| internal/tui/theme/CLAUDE.md | Package-specific agent notes and invariants for themes/tokens. |
| internal/tui/ui/style.go | Utility style builder + ANSI-aware sizing helpers (Fit/Clip/Overlay/ExpandTabs). |
| internal/tui/ui/panel.go | Panel component with exact-dimension rendering + title/caption embedding. |
| internal/tui/ui/status.go | StatusLine rendering with left/right groups, fill segment, meters, and fitting logic. |
| internal/tui/ui/fields.go | Label/value field list renderer with aligned columns and wide-mode fallback. |
| internal/tui/ui/doc.go | Package docs for UI utilities/components. |
| internal/tui/ui/README.md | Overview of UI utilities/components and accuracy rules. |
| internal/tui/ui/CLAUDE.md | Package-specific agent notes (no literals, exact dimensions, tab expansion, etc.). |
| internal/tui/ui/ui_test.go | Unit tests for Fit/FitBlock/Overlay/Panel behavior and invariants. |
| internal/tui/paint/paint.go | Pure RenderTree painter with per-node treatments and graceful fallback. |
| internal/tui/paint/walk.go | Collects keyboard-reachable targets (actions/collapsibles) mirroring paint traversal. |
| internal/tui/paint/paint_test.go | Painter behavior tests across node types, fallback, width clamping, focus styling. |
| internal/tui/paint/walk_test.go | Tests target enumeration order, pathing, and collapsed/expanded behavior. |
| internal/tui/paint/doc.go | Package docs for the painter’s protocol obligations and purity. |
| internal/tui/paint/README.md | High-level explanation of painter responsibilities and node treatments. |
| internal/tui/paint/CLAUDE.md | Package-specific agent notes (default branch load-bearing, ANSI stripping, etc.). |
| internal/tui/region/region.go | Region placement store with deterministic ordering + streaming text buffers. |
| internal/tui/region/region_test.go | Tests replace scoping, ordering/determinism, streams, and reset semantics. |
| internal/tui/region/doc.go | Package docs for placement coexistence + determinism guarantees. |
| internal/tui/region/README.md | Explains store semantics, ordering model, and determinism rationale. |
| internal/tui/region/CLAUDE.md | Package-specific agent notes (no map iteration ordering, replace scoping, etc.). |
| internal/tui/shell/layout.go | Layout solver with drop order/breakpoints and inner-size helpers. |
| internal/tui/shell/layout_test.go | Tests layout breakpoints, degradation order, and geometry invariants. |
| internal/tui/shell/focus.go | Focus ring and cycling rules. |
| internal/tui/shell/focus_test.go | Tests focus naming, ring membership, wraparound, and recovery behavior. |
| internal/tui/shell/keymap.go | Keymap layers, default bindings, and hint-line generation. |
| internal/tui/shell/keymap_test.go | Tests binding matching, conflicts, layer hints, and truncation behavior. |
| internal/tui/shell/input.go | Pure in-memory composer buffer (rune-based), history, cursor position. |
| internal/tui/shell/input_test.go | Tests editing, history, line predicates, placeholder rendering, cursor pos. |
| internal/tui/shell/format.go | Formatting helpers (tokens, USD, duration) + usage/edit summaries. |
| internal/tui/shell/format_test.go | Tests for token/cost/duration/percent formatting and usage nil-safety. |
| internal/tui/shell/event.go | Defines shell message vocabulary and emitted Action types. |
| internal/tui/shell/agent.go | Agent roster + cycling ring for profile selection. |
| internal/tui/shell/agent_test.go | Tests agent roster behavior and shift+tab cycling semantics. |
| internal/tui/shell/model.go | Bubble Tea model: message handling, key routing, frame composition, overlay, cursor placement. |
| internal/tui/shell/model_test.go | Model-level tests for interaction, modality, streaming behavior, and frame properties. |
| internal/tui/shell/demo.go | Scripted demo EventSource to exercise the shell without kernel attach bridge. |
| internal/tui/shell/doc.go | Package docs for shell responsibilities and design constraints. |
| internal/tui/shell/README.md | High-level shell overview, gaps it fills, and testability notes. |
| internal/tui/shell/CLAUDE.md | Package-specific agent notes (TTY ownership, Bubble Tea v2 specifics, invariants). |
| // With workspace detail out of shell chrome, the git widget is the only | ||
| // source of VCS state — which is the point: one truth, contributed by | ||
| // the plugin that owns it. | ||
| place(gitWidget, 6, renderv1.Region_REGION_SIDEBAR, true, new(int32(10)), render.Tree(render.Group( |
There was a problem hiding this comment.
Not a defect — this compiles today. Go 1.26 extended new to accept an expression, allocating and initializing in one step, and go.mod declares go 1.26 (CI runs 1.26.5). Verified directly on this toolchain:
p := new(int32(10)) // *int32, *p == 10The whole package builds, vets and lints clean, which it could not do under the pre-1.26 rule you are describing. Keeping it: it is exactly what the func ptr[T any](v T) *T helper existed to work around, and the helper is now dead weight.
| // A second widget of a different kind: the shell already reports context | ||
| // and cost itself, so a fixture that repeated them would demonstrate | ||
| // duplication rather than what widgets are for. | ||
| place(jobsWidget, 7, renderv1.Region_REGION_SIDEBAR, true, new(int32(20)), render.Tree(render.Group( |
There was a problem hiding this comment.
Same as the sibling comment on line 112 — new(expr) is valid Go 1.26 and this file compiles. No change.
| r, g, b, _ := th.Tone(tone).RGBA() | ||
| key := r<<16 | g<<8 | b | ||
|
|
There was a problem hiding this comment.
Correct, and a real bug — fixed in c5c5fd6.
RGBA() returns 16-bit channels, so r<<16 | g<<8 | b overlapped green into red's bits and blue into green's. Two distinct tones could land on one key, which would make this test report a duplicate that does not exist or miss one that does.
Rather than shifting down to 8 bits I keyed on the channels directly (map[[3]uint32]string) — it cannot collide by construction and discards no precision, so there is no packing argument left to get wrong later.
actions/setup-go derives its cache key from platform, Go version, and a hash of go.sum — and nothing else. Every Go job on the same runner OS therefore competed for one key, and since actions/cache never overwrites an existing key, the first job to finish won and every other job on that OS restored whatever that job happened to have compiled. The winner is the job that does the least work. Here it was gofmt: it compiles nothing, finished in ten seconds, and was writing an empty GOCACHE that Build, Test and golangci-lint then restored before recompiling the world. The cache list shows it plainly — six of eight Linux entries were ~7.5 KB against 150 MB for the two runs where a real job won the race, while macOS and Windows sat at ~270 MB because only one job ever runs there. That asymmetry is the tell: it is a key collision, not a Go problem. Adds .github/actions/setup-go-cached, which installs Go with setup-go's own cache disabled and restores GOCACHE + GOMODCACHE under a key scoped to the calling job. Wires it into build, test, proto and lint. The gofmt job now caches nothing at all, which is both correct — it neither resolves modules nor compiles — and removes the poisoner outright. The key stays frozen on go.sum rather than rolling per run: compiled dependencies dominate a cold build and are exactly what does not change between commits, so a rolling key would re-upload hundreds of MB per run and churn the repository's 10 GB cache budget for little gain. Dependabot needs the composite action's directory listed explicitly; "/" only covers .github/workflows, and the SHAs pinned inside a composite action would otherwise go stale unnoticed.
securego/gosec is a Docker action, and a container cannot see the runner's GOCACHE or GOMODCACHE. It was re-downloading the whole module graph and recompiling it on every run: 2m27s, the slowest job outside the test matrix, almost entirely work the cache already holds. gosec type-checks the program through go/packages, so it wants exactly the compiled export data every other Go job here already has. Installing the pinned binary and running it on the runner lets it share that cache. The trade is supply-chain shape rather than trust: a SHA-pinned Docker digest becomes a version-pinned module install verified through the Go checksum database. The version matches what the action ran, and it is noted as a manual bump since Dependabot does not track it. The job name is unchanged — branch protection matches required checks by name, so renaming it would leave merges waiting on a check that no longer reports.
It only compiles the protoc plugins from go.mod's tool directives — about eight seconds of work — and restoring the 26 MB that produces costs more than it saves. Measured across the two runs: 15s uncached against 20s cached. A job has to be big enough to profit from a cache. This one is not, and carrying one here is complexity that buys nothing.
Two findings from review. The tone-uniqueness test packed color channels as r<<16|g<<8|b, but RGBA returns 16-bit channels, so green overlapped red's bits and blue overlapped green's. Two distinct tones could collide on one key — reporting a duplicate that does not exist, or missing one that does. Keyed on the channels themselves now, which cannot collide by construction and throws away no precision. The ui package docs still described Bar, which was removed when StatusLine superseded it, and listed Panel twice in the symbol table. A README naming a symbol the package does not export is worse than one that omits it: it sends a reader looking for something that was deliberately deleted. Removed across README, CLAUDE.md, doc.go and the design doc, and folded the duplicate Panel rows into one that mentions the caption.
What & why
Adds the reference terminal shell — the first-party frontend provider — as five
internal/tui/packages plus acmd/tuibinary, and documents its design indocs/first-party/frontends/tui.md.This is TUI 0.1: the shell and its visual language, so the remaining frontend work has something concrete to build against. It establishes where a widget's panel goes, what a plugin's
RenderTreelooks like once painted, how an overlay carries a plan/apply decision, and what the design tokens are named — the affordances a plugin author actually designs against.The layering is
theme → ui → paint → shell → cmd/tui, withregionindependent, and the commits follow that order so each one is reviewable on its own.internal/tui/themeinternal/tui/uiStylebuilder,Panel,StatusLine,Fields, meters,Overlayinternal/tui/regioninternal/tui/paintRenderTree→ styled rows, plus activation-target collectioninternal/tui/shellcmd/tui/dev/tty, the programChecklist
go mod tidyis a no-op,go build ./...,go vet ./...,gofmt -l -s .prints nothing,go test -race -covermode=atomic ./...,golangci-lint rundocs/specifications/document in this same PRpkg/*/proto/v1/—.protochanges made inapi/and regenerated withbuf generateinternal/packages includeREADME.md+CLAUDE.mdin the same commitbin/On the second box: this PR changes no protocol surface and touches no
.proto, so nothing indocs/specifications/needed updating. The new design doc lives underdocs/first-party/and is explicitly descriptive, not normative — the frontend spec leaves focus, keybindings, resize, and scrollback to each frontend on purpose, and this documents one implementation's choices rather than adding requirements.mkdocs build --strictpasses with it registered in the nav.Also ran, beyond the checklist:
buf lint,buf format --diff --exit-code,govulncheck(no vulnerabilities),gosec -exclude-generated(no issues).Coverage on the new packages:
region100%,theme98.7%,ui96.1%,paint95.8%,shell92.1%.Notes for reviewers
demo.gois a fixture with an expiry date. No kernel-side frontend-attach path exists yet, so the shell is currently driven by a scripted demo source. It should be deleted when the real bridge lands rather than grown into a second implementation — theCLAUDE.mdin that package says so too.Three protocol gaps surfaced while building this, none of them addressed here, all worth a decision before the bridge is written:
ClientEventcarries an agent-profile selection, and a session's profile is fixed at creation — so the agent switcher keeps its selection as local state and emits an action the bridge has to interpret. Most plausibly it means "the profile for the next session", but that's a protocol question.cmd/tuisupplies them.Four things are worth reading closely, because they are constraints rather than choices, and each one is documented where it bites:
View.ui.Overlaysplices rows by hand because Lip Gloss cannot do it.Canvas.Composedraws each layer at full canvas bounds andLayer.Drawignores its own X/Y, so composing a pane over a frame erases the frame.ExpandTabsat every text leaf is a correctness fix.lipgloss.Width("\t")is 0 but a terminal advances to the next tab stop, so an unexpanded tab paints wider than it measures and corrupts every row to its right.View.MouseModeis what makes the wheel scroll the transcript. Without it the wheel silently scrolls the terminal's own buffer behind the alt screen — the gesture appears to work while doing something else entirely.Bubble Tea v2 differs from v1 in ways that will trip up a reviewer reading it as v1: the module path is
charm.land/bubbletea/v2,Model.View()returns atea.Viewrather than a string, and alt-screen is a property of thatView— there is notea.WithAltScreen()program option. Full-screen takeover is entirelyViewproperties.Layout arithmetic is deliberately centralized in
Layout, because a duplicate of it already drifted once: the header grew from a single line into a bordered box,frame()accounted for it and the cursor did not, and the caret sat two rows above the text it belonged to. The frame tests assert against painted output — finding the row containing the prompt — rather than against recomputed arithmetic, so that class of mistake fails loudly.Deferred on purpose: syntax highlighting for
CodeBlockNode, and thee(corrected-input editor) binding is advertised in the hints but not yet wired.