cli: add Frame — live compositional AppShell for TUI #15

Closed
opened 2026-02-22 19:28:39 +00:00 by Virgil · 1 comment
Member

Problem

pkg/cli has good individual components (Table, Spinner, ProgressBar, Viewport, InteractiveList) and a static layout system (HLCRF Layout("HCF")). But they can't compose at runtime:

  • Each interactive component calls tea.NewProgram() independently — they can't share the screen
  • Spinner writes directly to stdout with \033[2K\r — can't coexist with a table above it
  • Layout("HCF") renders once to a string — no live updates, no region swapping
  • No persistent header/footer/statusbar while content changes

Every dashboard command (core dev health, core dev work, lem gen distill) wants a persistent shell with independently-updating regions. Today they can't have one.

Proposal: cli.Frame

A live layout frame — like Angular's AppShell but for TUI. One bubbletea.Program owns the terminal, regions update independently, components slot in without managing their own stdout.

Mental Model

Angular AppShell          →  cli.Frame
<app-header>              →  frame.Header()
<router-outlet>           →  frame.Content()  (swappable)
<app-footer>              →  frame.Footer()
<app-sidebar>             →  frame.Left()     (optional)
routerLink                →  frame.Navigate()  (swap content)
Component                 →  cli.Model (existing TUI interface)

API Sketch

// Simple: header + content + footer
frame := cli.NewFrame("HCF")
frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
frame.Content(myTableModel)
frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
frame.Run()

// Swap content (e.g. table → detail view → back)
frame.Navigate(detailViewModel)
frame.Back()

// With sidebar
frame := cli.NewFrame("H[LC]F")
frame.Left(repoTreeModel)
frame.Content(statusTableModel)

// Non-interactive mode (piped output, CI)
// Detects !term.IsTerminal and falls back to static Layout rendering
frame.Run()  // prints once, exits

Frame Responsibilities

  1. Terminal ownership — single tea.Program, manages resize, alt-screen
  2. Region layout — HLCRF regions get allocated width/height from terminal size
  3. Update routing — key events go to focused region, tick events go to all regions
  4. Content swappingNavigate(model) replaces content region, Back() pops
  5. Graceful fallback — non-TTY falls back to static Layout.Render() (existing code)
  6. Component protocol — any cli.Model can slot into any region

Built-in Region Components

Small composable pieces that slot into Frame regions:

  • cli.StatusLine(title string, pairs ...string) — key:value bar for Header/Footer
  • cli.KeyHints(hints ...string) — keybinding help for Footer
  • cli.Breadcrumb(parts ...string) — navigation path for Header
  • cli.TaskTracker() — multi-line parallel task display (N spinners)

How Existing Components Fit

Component Standalone today In Frame
Table t.Render() prints Slots into Content, gets width from frame
Spinner Writes \033[2K\r Slots into Header/Footer as status
Viewport RunTUI(viewport) Slots into Content with scroll
InteractiveList tea.NewProgram Slots into Content or Left
ProgressBar Writes \033[2K\r Slots into Footer

Components need a Resize(width, height int) message so the frame can tell them their allocated space. This is already how bubbletea's WindowSizeMsg works — just needs plumbing.

Variant Reuse

Frame reuses the existing HLCRF variant parser (ParseVariant). The live frame is the runtime version of the static layout:

// Static (existing)
cli.Layout("HCF").H(title).C(body).F(footer).Render()

// Live (new)
cli.NewFrame("HCF").Header(m).Content(m).Footer(m).Run()

Same variant strings, same region model, different execution mode.

Priority

This is the architectural piece that unlocks #14 (rich table, parallel tasks, streaming, tree). Without Frame, each component is standalone. With Frame, they compose.

Constraints

  • Consumed via forge.lthn.ai/core/go/pkg/cli only — no bubbletea imports in domain code
  • Must fall back gracefully when !term.IsTerminal (CI, pipes, redirects)
  • Must support cli.Model interface (already defined in tui.go)
  • Frame should work with zero regions populated — just wraps content
  • Keyboard navigation between regions is optional (focus management)

References

  • Existing: layout.go (HLCRF parser), render.go (static rendering), tui.go (Model/RunTUI)
  • Angular AppShell: persistent nav + router-outlet pattern
  • charmbracelet/lipgloss JoinVertical/JoinHorizontal for region composition
  • Issue #14 for component-level additions that Frame enables
## Problem pkg/cli has good individual components (Table, Spinner, ProgressBar, Viewport, InteractiveList) and a static layout system (HLCRF `Layout("HCF")`). But they can't **compose at runtime**: - Each interactive component calls `tea.NewProgram()` independently — they can't share the screen - Spinner writes directly to stdout with `\033[2K\r` — can't coexist with a table above it - `Layout("HCF")` renders once to a string — no live updates, no region swapping - No persistent header/footer/statusbar while content changes Every dashboard command (`core dev health`, `core dev work`, `lem gen distill`) wants a persistent shell with independently-updating regions. Today they can't have one. ## Proposal: `cli.Frame` A live layout frame — like Angular's AppShell but for TUI. One `bubbletea.Program` owns the terminal, regions update independently, components slot in without managing their own stdout. ### Mental Model ``` Angular AppShell → cli.Frame <app-header> → frame.Header() <router-outlet> → frame.Content() (swappable) <app-footer> → frame.Footer() <app-sidebar> → frame.Left() (optional) routerLink → frame.Navigate() (swap content) Component → cli.Model (existing TUI interface) ``` ### API Sketch ```go // Simple: header + content + footer frame := cli.NewFrame("HCF") frame.Header(cli.StatusLine("core dev", "18 repos", "main")) frame.Content(myTableModel) frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit")) frame.Run() // Swap content (e.g. table → detail view → back) frame.Navigate(detailViewModel) frame.Back() // With sidebar frame := cli.NewFrame("H[LC]F") frame.Left(repoTreeModel) frame.Content(statusTableModel) // Non-interactive mode (piped output, CI) // Detects !term.IsTerminal and falls back to static Layout rendering frame.Run() // prints once, exits ``` ### Frame Responsibilities 1. **Terminal ownership** — single `tea.Program`, manages resize, alt-screen 2. **Region layout** — HLCRF regions get allocated width/height from terminal size 3. **Update routing** — key events go to focused region, tick events go to all regions 4. **Content swapping** — `Navigate(model)` replaces content region, `Back()` pops 5. **Graceful fallback** — non-TTY falls back to static `Layout.Render()` (existing code) 6. **Component protocol** — any `cli.Model` can slot into any region ### Built-in Region Components Small composable pieces that slot into Frame regions: - `cli.StatusLine(title string, pairs ...string)` — key:value bar for Header/Footer - `cli.KeyHints(hints ...string)` — keybinding help for Footer - `cli.Breadcrumb(parts ...string)` — navigation path for Header - `cli.TaskTracker()` — multi-line parallel task display (N spinners) ### How Existing Components Fit | Component | Standalone today | In Frame | |-----------|-----------------|----------| | Table | `t.Render()` prints | Slots into Content, gets width from frame | | Spinner | Writes `\033[2K\r` | Slots into Header/Footer as status | | Viewport | `RunTUI(viewport)` | Slots into Content with scroll | | InteractiveList | `tea.NewProgram` | Slots into Content or Left | | ProgressBar | Writes `\033[2K\r` | Slots into Footer | Components need a `Resize(width, height int)` message so the frame can tell them their allocated space. This is already how bubbletea's `WindowSizeMsg` works — just needs plumbing. ### Variant Reuse Frame reuses the existing HLCRF variant parser (`ParseVariant`). The live frame is the runtime version of the static layout: ```go // Static (existing) cli.Layout("HCF").H(title).C(body).F(footer).Render() // Live (new) cli.NewFrame("HCF").Header(m).Content(m).Footer(m).Run() ``` Same variant strings, same region model, different execution mode. ## Priority This is the architectural piece that unlocks #14 (rich table, parallel tasks, streaming, tree). Without Frame, each component is standalone. With Frame, they compose. ## Constraints - Consumed via `forge.lthn.ai/core/go/pkg/cli` only — no bubbletea imports in domain code - Must fall back gracefully when `!term.IsTerminal` (CI, pipes, redirects) - Must support `cli.Model` interface (already defined in `tui.go`) - Frame should work with zero regions populated — just wraps content - Keyboard navigation between regions is optional (focus management) ## References - Existing: `layout.go` (HLCRF parser), `render.go` (static rendering), `tui.go` (Model/RunTUI) - Angular AppShell: persistent nav + router-outlet pattern - charmbracelet/lipgloss `JoinVertical`/`JoinHorizontal` for region composition - Issue #14 for component-level additions that Frame enables
Author
Member

Completed by Charon in core/cli (e360115). CLI package now lives at forge.lthn.ai/core/cli/pkg/cli, not core/go/pkg/cli.

Completed by Charon in `core/cli` (e360115). CLI package now lives at `forge.lthn.ai/core/cli/pkg/cli`, not `core/go/pkg/cli`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: core/go#15
No description provided.