Terminal User Interfaces (TUI)
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/backend/references/tui.md |
| Description | Not specified |
Source Content
Terminal User Interfaces (TUI)
Building an interactive terminal app in Go follows the same discipline as a service: thin edges, plain-Go business logic, no raw goroutines, and tests that don’t need a real terminal.
The terminal is a constrained design medium: fixed-width cells, limited color, and small windows. Treat those constraints as a design aid. Strong TUIs win through stable layout, keyboard-first interaction, semantic color, and careful use of whitespace.
Four invariants separate a TUI that feels designed from one that feels assembled, and the rest of this page builds on them:
- The frame never changes height. Every region reserves its rows, so no keystroke reflows the screen.
- The frame width is clamped to a comfortable reading range. A maximum as well as a minimum — never stretched to fill an ultrawide terminal.
- Color is a theme, not scattered choices. One structural base, one accent, status reserved for green and red — swappable at runtime and stripped cleanly for
NO_COLOR. - Everything works from the keyboard alone. Mouse and color are enhancements, never the only path.
Choosing a library
| Library | Use when | Notes |
|---|---|---|
| Bubbletea | Default choice for any new TUI | Elm architecture (Model-Update-View); testable without a terminal |
| Bubbles | You need a list, text input, viewport, table, spinner, or progress bar | Prebuilt tea.Models you compose into your own |
| Lipgloss | Any styling: color, borders, padding, layout | Replaces raw ANSI escape codes entirely |
| tview | A widget-heavy, form-and-tree, mouse-driven admin tool, shipped fast | Retained-mode; less testable than Bubbletea’s Update loop |
| tcell | Bubbletea’s or tview’s abstraction doesn’t fit — a custom renderer | Low-level cell buffer and event source; reach for this last |
Default to Bubbletea + Bubbles + Lipgloss. Reach for tview only when the app is dominated by standard widgets (forms, trees) and Elm-style testability matters less than delivery speed.
Layout patterns
Choose the layout by workflow shape before writing code. The universal rule: panels never move unless the user explicitly changes the layout.
| Pattern | Use when | Go implementation notes |
|---|---|---|
| Persistent multi-panel | Related state must stay visible while focus moves between panes | Track a focused pane enum and keep pane bounds stable across focus changes |
| Miller columns | The user moves through a hierarchy, such as files, resources, or JSON | Collapse to one pane on narrow terminals; h ascends and l descends |
| Drill-down stack | The app has many resource types and a natural back stack | Keep a navigation stack in the model; Esc backs out |
| Widget dashboard | Independent widgets refresh or update on their own cadence | Give each widget its own model and compose commands with tea.Batch |
| IDE three-panel | The workflow has navigation, main content, and details or output | Sidebar, main pane, and detail pane keep fixed positions |
| Overlay or popup | The app is summoned for one choice and then exits | Use the alternate screen; return the chosen value through stdout after exit |
| Tabbed or moded panel | One surface has several screens but the global frame should not change | Model each screen as a mode and route input per mode |
Pressure-test every layout at 80x24 and in a 60-column tmux split. Define what collapses, hides, truncates, or becomes a single-pane view. Below the minimum usable size, render a clear terminal-too-small message instead of a broken layout.
The Elm architecture (Model-Update-View)
Bubbletea’s loop is Init() tea.Cmd, Update(tea.Msg) (tea.Model, tea.Cmd), View() string. Update is a pure function of the current model and an incoming message — no I/O inside it. Side effects (network calls, timers, file reads) are returned as a tea.Cmd, a func() tea.Msg that Bubbletea runs and feeds back through Update as a message.
type model struct { list list.Model invoices []domain.Invoice err error}
func (m model) Init() tea.Cmd { return loadInvoices(m.client)}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case invoicesLoadedMsg: m.invoices = msg.invoices return m, nil case errMsg: m.err = msg.err return m, nil case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit } } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd}Keep the model thin — business logic stays plain Go
Same layering rule as a service: the model owns presentation state (cursor position, which pane is focused, scroll offset, the active theme); it does not own business logic. A domain/service type with no bubbletea import holds the real state and rules, gets constructed and unit-tested exactly like any other Go type, and the model calls into it.
// domain logic — no tea import, tested with a plain table-driven testtype InvoiceFilter struct{ status domain.Status }
func (f InvoiceFilter) Apply(invoices []domain.Invoice) []domain.Invoice { ... }
// model — thin, only wires messages to the domain typefunc (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "f" { m.filtered = m.filter.Apply(m.invoices) } } return m, nil}This is what makes a TUI testable without spinning up a terminal: test InvoiceFilter.Apply directly, and only smoke-test the Update wiring.
Async work: tea.Cmd, not goroutines
A tea.Cmd runs on its own goroutine already — never wrap one in another go statement. Fan-out inside a single Cmd still uses sourcegraph/conc (see references/concurrency.md), and a blocking call inside a Cmd still takes a context.Context.
func loadInvoices(client *api.Client) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
invoices, err := client.ListInvoices(ctx) if err != nil { return errMsg{fmt.Errorf("load invoices: %w", err)} } return invoicesLoadedMsg{invoices} }}For a long-running background process (a live log tail, a websocket) that needs to push messages outside the request/response Cmd cycle, hold a *tea.Program reference and call p.Send(msg) from the goroutine that conc.WaitGroup supervises — never write to the model directly from another goroutine.
A TUI over a CLI: shell out, don’t reimplement
When the TUI fronts an existing CLI, it orchestrates and presents — it does not reimplement the commands. Suspend the TUI, hand the terminal to the real binary with tea.ExecProcess, and resume when it returns. The subprocess owns its own output and prompts, so behavior is identical to running it in a shell, and there is exactly one implementation to maintain.
// runCommand suspends the TUI, runs the real CLI so its output and prompts// behave exactly as in a shell, then resumes. The TUI never reimplements// command behavior.func runCommand(cmd Command) tea.Cmd { child := exec.Command(exe, strings.Fields(cmd.Path)...) return tea.ExecProcess(child, func(err error) tea.Msg { return execFinishedMsg{label: cmd.Path, err: err} })}Gate anything that changes state behind an explicit confirmation. Read-only actions run immediately; a command that writes to a cluster or repository routes through a confirm step first, so the destructive path always takes a deliberate keystroke.
if selected.WriteRisk == WriteRiskChangesState { m.pendingRun = &selected m.mode = modeConfirm // y/n gate before the terminal is handed over return m, nil}return m, runCommand(selected) // read-only: run nowModel distinct screens as a mode enum and route input per mode, so each screen’s key handling stays small and testable.
type mode string
const ( modePipeline mode = "pipeline" modeCommands mode = "commands" modeConfirm mode = "confirm")
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch m.mode { case modeConfirm: return m.updateConfirm(key) case modeCommands: return m.updateCommandPicker(key) } } // ...pipeline-mode handling return m, nil}Theming and color
Color is a system, not a set of one-off choices, so make it a value you can swap. A Theme is a named palette; a Styles set is derived from it. Because every View reads from the active Styles, switching the theme reskins the whole app without touching a single render call.
Anchor each theme on one structural color, one accent, and a reserved pair for status. Restraint is what makes a TUI read as designed rather than decorated: the base carries bars, borders, and selection; the single accent marks the one thing the eye should land on; green and red mean only healthy and broken.
// A Theme is a named palette with one job per color. Truecolor hexes degrade to// 256/16-color terminals through lipgloss' colorprofile, and are stripped// entirely when output is not a TTY — so tests assert on raw text.type Theme struct { Name string Base lipgloss.TerminalColor // structure: bars, borders, selection Accent lipgloss.TerminalColor // the single accent: wordmark, keys, titles Success lipgloss.TerminalColor // status only: healthy Alert lipgloss.TerminalColor // status only: broken / destructive Ink lipgloss.TerminalColor // strong text on the base Text lipgloss.TerminalColor // body text Muted lipgloss.TerminalColor // secondary text Faint lipgloss.TerminalColor // hints, separators Border lipgloss.TerminalColor // subtle card border}Register the themes by name and keep a stable order so a key can cycle through them. Ship at least a colored default, an alternate, and a high-contrast monochrome fallback for NO_COLOR and TERM=dumb.
var ( // USWDS-inspired civic palette: federal navy structure, one warm gold accent. civicTheme = Theme{ Name: "civic", Base: hex("#1a4480"), Accent: hex("#ffbe2e"), Success: hex("#2fb344"), Alert: hex("#e6483b"), Ink: hex("#f4f6fb"), Text: hex("#d0d6e2"), Muted: hex("#9aa4b2"), Faint: hex("#6b7280"), Border: hex("#3d4551"), } midnightTheme = Theme{Name: "midnight", Base: hex("#3b0764"), Accent: hex("#c4b5fd"), /* ... */ }
// mono leans on glyph + reverse video, not hue, so it survives NO_COLOR. monoTheme = Theme{ Name: "mono", Base: ansi(15), Accent: ansi(15), Success: ansi(15), Alert: ansi(15), Ink: ansi(15), Text: ansi(7), Muted: ansi(8), Faint: ansi(8), Border: ansi(8), })
var themeOrder = []string{"civic", "midnight", "mono"}var themes = map[string]Theme{"civic": civicTheme, "midnight": midnightTheme, "mono": monoTheme}
func hex(s string) lipgloss.TerminalColor { return lipgloss.Color(s) }func ansi(n int) lipgloss.TerminalColor { return lipgloss.Color(strconv.Itoa(n)) }Derive a role-named Styles set from the active theme, and build it once when the theme changes — never style inline at a call site. A reader (and a later maintainer) reasons about Section, CardTitle, Selected, Success — never about “the gold one”.
type Styles struct { Frame, Section, CardTitle, CardBorder lipgloss.Style Selected, Success, Alert lipgloss.Style Muted, Faint, Keycap lipgloss.Style}
func NewStyles(t Theme) Styles { return Styles{ Frame: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Base).Padding(0, 1), Section: lipgloss.NewStyle().Foreground(t.Accent).Bold(true), CardTitle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true), CardBorder: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1), // Selection is reverse video — the one marker that survives every theme // and NO_COLOR. The row's accent comes from the marker glyph, not a fill. Selected: lipgloss.NewStyle().Reverse(true).Bold(true), Success: lipgloss.NewStyle().Foreground(t.Success).Bold(true), Alert: lipgloss.NewStyle().Foreground(t.Alert).Bold(true), Muted: lipgloss.NewStyle().Foreground(t.Muted), Faint: lipgloss.NewStyle().Foreground(t.Faint), Keycap: lipgloss.NewStyle().Foreground(t.Accent).Bold(true), }}The model holds the active theme name and its derived styles; a key cycles them and rebuilds the set. Everything drawn afterward picks up the new look for free.
case "t": m.themeName = nextTheme(m.themeName) m.styles = NewStyles(themes[m.themeName])Resolve the initial theme from the environment so the choice is persistable and NO_COLOR always wins. Precedence: an explicit NO_COLOR forces mono, then an env override, then the configured default.
func pickTheme(configured string) string { if _, noColor := os.LookupEnv("NO_COLOR"); noColor { return "mono" } if name := strings.TrimSpace(os.Getenv("APP_THEME")); name != "" { if _, ok := themes[name]; ok { return name } } if _, ok := themes[configured]; ok { return configured } return themeOrder[0]}When a single theme must read well on both light and dark terminal backgrounds, build its tokens from lipgloss.AdaptiveColor{Light, Dark} instead of a single hex; Lipgloss picks per detected background. Either way, never hardcode raw ANSI escape sequences — Lipgloss degrades correctly on reduced-color terminals; hand-written escapes do not.
Use color as meaning, never as decoration, and never alone. Pair it with a distinct glyph per state, so the signal survives NO_COLOR, color-blindness, and the mono theme where every hue collapses to white. A status marker is the cleanest version of this — the color and the shape both change.
// status pairs a hue with a distinct glyph, so healthy and broken stay// distinguishable even when every color renders the same (mono / NO_COLOR).func (s Styles) status(value string) string { switch classifyStatus(value) { case statusHealthy: return s.Success.Render("●") + " " + s.Muted.Render(value) case statusBroken: return s.Alert.Render("✗") + " " + s.Muted.Render(value) default: return s.Faint.Render("▸") + " " + s.Muted.Render(value) }}Use borders only when they carry structure or focus. One rounded frame around the app and one subtle border per card is enough — one border owner per local stack, never a nested border stack. Provide an ASCII fallback for legacy SSH or TERM=dumb.
The stable frame
The strongest design decision in a TUI is the one users never notice: the frame does not move. Panels keep fixed positions, and every region renders at a fixed height, so moving the cursor, filtering a list, or refreshing a card never changes the total height of the screen. A layout that reflows on every keystroke feels broken even when it is technically correct.
Reserve height explicitly. Give each region a constant row count and pad short content up to it, rather than letting the content decide the height.
const ( stageDetailRows = 5 // fixed rows for the detail panel pickerVisibleRows = 9 // fixed rows for the command list)
// padLines pads (or clips) rendered lines to exactly n rows, so a region's// height is set by the layout, not by how much content it happens to hold.func padLines(lines []string, n int) []string { for len(lines) < n { lines = append(lines, "") } return lines[:n]}Compose the screen from these fixed-height regions with lipgloss.JoinVertical, so View reads as a flat description of the layout.
func (m model) View() string { body := lipgloss.JoinVertical(lipgloss.Left, m.headerView(width), "", m.dashboardView(width), "", m.pipelineView(width), "", m.footerView(width), ) return m.styles.Frame.Render(body)}Group related read-only state into titled cards and lay them out in a grid with lipgloss.JoinHorizontal and an explicit gap. Card values are pre-wrapped (see Wrap static content above), so a long value grows the card’s height instead of losing its tail; pad both cards in a row to the taller one’s line count so the pair stays aligned.
// card renders a titled card whose body is padded to a fixed height, so two// cards side by side always align and the row never changes height.func (s Styles) card(title string, lines []string, width, bodyRows int) string { rendered := []string{s.CardTitle.Render(title)} for _, line := range lines { rendered = append(rendered, truncateVisual(line, width-4)) } for len(rendered) < bodyRows+1 { rendered = append(rendered, "") } return s.CardBorder.Width(width - 2).Render(strings.Join(rendered, "\n"))}Measure on display width, not string length — CJK characters and emoji occupy two cells, so measure with lipgloss.Width (rune-width aware, like github.com/mattn/go-runewidth). Truncation with an ellipsis is the right tool for one case only: cursor-navigable rows — list items and table cells whose height must stay fixed as the selection moves, and only when Enter reveals the full value. Cut those on display width:
func truncateVisual(value string, width int) string { if lipgloss.Width(value) <= width { return value } runes := []rune(value) for len(runes) > 0 && lipgloss.Width(string(runes)+"…") > width { runes = runes[:len(runes)-1] } return string(runes) + "…"}Wrap static content — never ellipsize a value the user must read or act on
An ellipsis destroys information. When it lands on a URL, an identifier, a version, or a path, the user can no longer click, copy, or even read it — the tail is simply gone. A link that ends in … is a broken link. That is a bug, not a layout choice.
So the rule inverts for static, read-only content — dashboard cards, detail panels, status values, anything holding a value a user reads or acts on: wrap it, never truncate it. Wrapping is safe here because the content is static: it does not change as the cursor moves, so a taller card never reflows on a keystroke. The stable-frame invariant is “no reflow on interaction,” not “never wrap” — wrapping fixed content honors it. Decide by the content, not by habit:
| Content | Changes as cursor moves? | Rule |
|---|---|---|
| List row, table cell | Yes — height must stay fixed | Truncate; Enter reveals the full value |
| Card value, detail field, status, link, version, path | No — static per snapshot | Wrap; the full value always survives |
Wrap with Lipgloss, which keeps ANSI styling intact across the break and hard-breaks any token too long to fit, so the full text always survives:
// wrapVisual word-wraps a possibly-styled value to width, one line per entry.// The full text always survives — this is the wrap-don't-ellipsize primitive// for content that holds a value a user must read or act on.func wrapVisual(value string, width int) []string { if width < 1 { width = 1 } if strings.TrimSpace(value) == "" { return []string{""} } return strings.Split(lipgloss.NewStyle().Width(width).Render(value), "\n")}For a key value card row, hang-indent the continuation lines under the value so the wrap reads as one field, not two:
func cardRow(key, value string, textWidth int) []string { const keyCol = 9 keyCell := keyStyle.Render(fmt.Sprintf("%-*s", keyCol, key)) wrapped := wrapVisual(value, textWidth-keyCol-1) indent := strings.Repeat(" ", keyCol+1) out := make([]string, 0, len(wrapped)) for i, line := range wrapped { if i == 0 { out = append(out, keyCell+" "+line) continue } out = append(out, indent+line) } return out}Give a link its own full-width line so it stays on one line and clickable when it fits, and wraps only when it must. When two side-by-side cards wrap to different heights, pad both to the taller one’s line count so the row still aligns — the pair height is set by content that does not change on interaction, so the frame stays stable.
Render keyboard hints through one helper so the footer, a picker, and a help view all read identically — keycap accented, description muted, a faint separator between. The key and its description are never split by the separator, so each hint stays contiguous for readers and for tests.
func (s Styles) hints(pairs [][2]string) string { parts := make([]string, 0, len(pairs)) for _, p := range pairs { parts = append(parts, s.Keycap.Render(p[0])+" "+s.Muted.Render(p[1])) } return strings.Join(parts, s.Faint.Render(" · "))}Responsive layout
Handle tea.WindowSizeMsg and recompute every width from it; never assume a fixed terminal size.
case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.list.SetSize(msg.Width, msg.Height-headerHeight)Wider is not always better. A frame stretched across an ultrawide monitor is as hard to read as a wall of unwrapped text on the web. Clamp the frame to a comfortable reading range, the same way a web layout caps line length with a max-width.
const ( minWidth = 72 maxWidth = 98 defaultWidth = 88)
// contentWidth is the usable width inside the frame's border and padding.func (m model) contentWidth() int { w := m.width if w <= 0 { w = defaultWidth } w = min(max(w, minWidth), maxWidth) return w - 4 // 2 border columns + 2 padding columns}Set a minimum usable width and degrade to a single column below it, the same way a responsive web layout collapses at a breakpoint. Below the minimum usable size, render a clear terminal-too-small message instead of a broken layout.
Interaction and discoverability
Keyboard access is primary; mouse support is an enhancement. Every action must be reachable from the keyboard, including actions that also support clicking or scrolling with a mouse.
Use the common key conventions unless the app has a strong reason not to:
| Key | Action |
|---|---|
q | Quit |
? | Help |
/ | Search |
n / N | Next / previous match |
Esc | Cancel or go back |
Enter | Confirm or drill in |
Space | Toggle or mark for multi-select |
: | Command mode |
t | Cycle theme |
gg / G | Top / bottom |
Tab / Shift+Tab | Switch focus |
r | Refresh |
1-9 | Jump to a numbered pane or tab |
hjkl and arrows | Move |
Never take over terminal-reserved keys such as Ctrl+C, Ctrl+Z, Ctrl+\, Ctrl+S, or Ctrl+Q. Let Ctrl+C quit cleanly and let suspend restore the terminal state before yielding.
Discoverability is layered:
- Show 3-5 high-value shortcuts in a footer hint bar built from
Styles.hints. - Put every binding behind
?. - Add a command palette (
/) when the app has many actions, filtering across the command’s path, help, and verb/noun/action. - Keep documentation as the last resort, not the first place users must look.
For tables and lists, align numbers right and text left. Truncate cells here — these are cursor-navigable rows whose height must stay fixed as the selection moves — show filtered counts, mark the sorted column, and make Enter reveal the full row details (never leave a value reachable only through an ellipsis). Static values outside navigable rows wrap instead; see Wrap static content. Virtualize lists that can grow beyond a few hundred items.
Terminal hygiene
Full-screen TUIs use the alternate screen so they do not pollute shell scrollback. Always restore terminal state on exit, including panic paths: leave the alternate screen, disable raw mode, and restore the cursor before printing an error.
Handle resize and suspend as first-class paths. Bubbletea sends tea.WindowSizeMsg for resize; use it to recompute layout every time. On suspend, restore the terminal before yielding and force a full redraw after resume. tea.ExecProcess already handles this handoff when you shell out to a subprocess.
Do not block the UI loop on network, disk, or subprocess work. All slow work returns through tea.Cmd, and long-running streams send messages back through Program.Send. Do not redraw on a fixed timer unless the app is animating; event-driven redraw keeps idle apps quiet.
Logs must not write to stdout while the TUI owns the terminal. Use tea.LogToFile, an in-app log pane, or a separate debug console.
Testing
Unit-test the domain logic as plain Go — no bubbletea import, table-driven, exactly as references/testing.md describes.
For the Update/View wiring, you rarely need a real terminal. Because Lipgloss strips styling when the output is not a TTY, View() returns plain text you can assert on directly. Drive the model by sending tea.Msg values through Update, then check model state through small exported getters and the rendered text through strings.Contains.
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})model = updated.(LaunchpadModel)if model.Cursor() != wantDeployIndex { t.Fatalf("cursor after 'd' = %d, want %d", model.Cursor(), wantDeployIndex)}if !strings.Contains(model.View(), "deploy site deploy") { t.Fatalf("deploy command not rendered:\n%s", model.View())}Expose just enough state for assertions — Cursor(), FilterValue(), SelectedCommand(), FilteredCommands() — and keep the fields unexported. This exercises the real reducer and the real view without a terminal, a tea.Program, or golden files.
When you need to test a full running program — alternate screen, tea.ExecProcess, timers — reach for x/exp/teatest: it drives a real tea.Program against a t.Test sink and lets you send messages and wait on output.
tm := teatest.NewTestModel(t, initialModel(), teatest.WithInitialTermSize(80, 24))tm.Send(tea.KeyMsg{Type: tea.KeyDown})teatest.WaitFor(t, tm.Output(), func(b []byte) bool { return bytes.Contains(b, []byte("Selected"))})Review checklist
- The layout pattern matches the workflow, and panels stay in fixed positions unless the user explicitly moves them.
- Every region reserves its height; moving the cursor, filtering, or refreshing never changes the frame height.
- The frame width is clamped to a comfortable range — a maximum as well as a minimum — not stretched to fill the terminal.
- The 80x24 and 60-column paths are defined, including the minimum-size message.
- The clutter audit is clean: no redundant outer frame, no nested border stack, no duplicate state markers, and no glyph repeated on every row without meaning.
- Color is a theme (one base, one accent, status-only green/red), styles are bound to roles, and themes are swappable at runtime.
- Status is shown by color and a distinct glyph, so it survives
NO_COLOR, color-blindness, and the mono theme. - A CLI-backed TUI shells out via
tea.ExecProcessand never reimplements command behavior. - State-changing actions pass through an explicit confirmation; read-only actions run immediately.
- Footer hints show the most useful keys;
?exposes the full keymap;tcycles the theme. - Every mouse action has a keyboard equivalent.
- Tables use display width, truncation, sort markers, counts, and detail-on-Enter.
- Truncation is confined to cursor-navigable rows; static content (cards, detail values, links, versions, paths) wraps, so no value a user must read or act on is ever cut off with an ellipsis.
- The app uses the alternate screen and restores terminal state on exit, panic, resize, and suspend.
- Slow I/O returns through
tea.CmdorProgram.Send; the UI loop never blocks on it. - Logs go to a file, pane, or debug console, not stdout.
Self-rubric
- Bubbletea + Bubbles + Lipgloss is the default;
tview/tcellonly for a stated reason. -
Updateis pure — every side effect returns as atea.Cmd, none run inline. - Business rules live in plain Go types with no
bubbleteaimport, unit-tested directly. - No raw
goinside aCmd; fan-out usesconc; blocking calls take acontext.Context. - Screens are a mode enum with per-mode
Update; state changes are confirmed; CLI work is shelled out, not reimplemented. - Color goes through a
Theme→Stylesset, never raw ANSI; themes are registered, cycled with a key, and resolved from env with aNO_COLORfallback. - Fixed-height regions and a clamped width keep the frame stable and readable — no reflow on keystroke, no full-width stretch.
- Truncation is reserved for navigable rows; static values (links, versions, paths, identifiers) wrap so the full value always survives.
- Layout responds to
tea.WindowSizeMsgand narrow layouts are designed down to the minimum-size message. - Keyboard conventions, footer hints, and
?help are present. - Alternate screen, terminal restoration, resize, suspend, and logging paths are handled.
- Update/View wiring is tested by driving
Updateand asserting on rawView()text plus small getters; ateatestsmoke test covers the full program; domain logic has ordinary table-driven tests.