Skip to content

Terminal User Interfaces (TUI)

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/tui.md
DescriptionNot 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

LibraryUse whenNotes
BubbleteaDefault choice for any new TUIElm architecture (Model-Update-View); testable without a terminal
BubblesYou need a list, text input, viewport, table, spinner, or progress barPrebuilt tea.Models you compose into your own
LipglossAny styling: color, borders, padding, layoutReplaces raw ANSI escape codes entirely
tviewA widget-heavy, form-and-tree, mouse-driven admin tool, shipped fastRetained-mode; less testable than Bubbletea’s Update loop
tcellBubbletea’s or tview’s abstraction doesn’t fit — a custom rendererLow-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.

PatternUse whenGo implementation notes
Persistent multi-panelRelated state must stay visible while focus moves between panesTrack a focused pane enum and keep pane bounds stable across focus changes
Miller columnsThe user moves through a hierarchy, such as files, resources, or JSONCollapse to one pane on narrow terminals; h ascends and l descends
Drill-down stackThe app has many resource types and a natural back stackKeep a navigation stack in the model; Esc backs out
Widget dashboardIndependent widgets refresh or update on their own cadenceGive each widget its own model and compose commands with tea.Batch
IDE three-panelThe workflow has navigation, main content, and details or outputSidebar, main pane, and detail pane keep fixed positions
Overlay or popupThe app is summoned for one choice and then exitsUse the alternate screen; return the chosen value through stdout after exit
Tabbed or moded panelOne surface has several screens but the global frame should not changeModel 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 test
type InvoiceFilter struct{ status domain.Status }
func (f InvoiceFilter) Apply(invoices []domain.Invoice) []domain.Invoice { ... }
// model — thin, only wires messages to the domain type
func (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 now

Model 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:

ContentChanges as cursor moves?Rule
List row, table cellYes — height must stay fixedTruncate; Enter reveals the full value
Card value, detail field, status, link, version, pathNo — static per snapshotWrap; 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:

KeyAction
qQuit
?Help
/Search
n / NNext / previous match
EscCancel or go back
EnterConfirm or drill in
SpaceToggle or mark for multi-select
:Command mode
tCycle theme
gg / GTop / bottom
Tab / Shift+TabSwitch focus
rRefresh
1-9Jump to a numbered pane or tab
hjkl and arrowsMove

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.ExecProcess and 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; t cycles 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.Cmd or Program.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/tcell only for a stated reason.
  • Update is pure — every side effect returns as a tea.Cmd, none run inline.
  • Business rules live in plain Go types with no bubbletea import, unit-tested directly.
  • No raw go inside a Cmd; fan-out uses conc; blocking calls take a context.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 ThemeStyles set, never raw ANSI; themes are registered, cycled with a key, and resolved from env with a NO_COLOR fallback.
  • 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.WindowSizeMsg and 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 Update and asserting on raw View() text plus small getters; a teatest smoke test covers the full program; domain logic has ordinary table-driven tests.