| name | tui-building |
| description | Building terminal UI tools with Bubble Tea and the shared trenthaines.dev/tui package. Use when creating TUI pickers, popups, or any interactive terminal tools — covers the shared package, layout patterns, testing, chezmoi integration, and known gotchas. |
TUI Building
Shared Package: trenthaines.dev/tui
Source: ~/.config/tui/ (chezmoi-managed)
Import: ui "trenthaines.dev/tui"
Add to any new tool's go.mod:
require trenthaines.dev/tui v0.0.0
replace trenthaines.dev/tui => /Users/trenthaines/.config/tui
Colors (TokyoNight Moon + tmux)
ui.BG, ui.FG, ui.FGDim, ui.FGBold
ui.Yellow, ui.Orange, ui.Red
ui.Green, ui.Teal, ui.Cyan, ui.Blue
ui.Purple, ui.Violet
ui.Comment, ui.Border, ui.Inactive
ui.Highlight
ui.TmuxPink
Semantic aliases
ui.ColorActive
ui.ColorDone
ui.ColorSelected
ui.ColorMarked
Pre-built styles
ui.SNormal, ui.SBright, ui.SDim
ui.SSelected, ui.SMarked, ui.SSelMark, ui.SActive, ui.SDone
ui.SInfo, ui.SSubtle
ui.STitle
ui.SFooter
ui.SBorder
ui.SPreview
Component functions
ui.SolidTitle(width int) lipgloss.Style
ui.PreviewBox(content string, width, maxLines int) string
ui.PreviewPanel(content string, width, height, maxLines int) string
ui.Footer(hints ...string) string
ui.FillHeight(s string, h int) string
ui.Caret()
ui.ActiveMark()
ui.MarkedBullet()
ui.NewList(items, delegate, width, height) list.Model
ui.NewSolidTitleList(title, items, delegate, width, height) list.Model
ui.NewSearchInput(placeholder string) textinput.Model
ui.SearchPrompt(ti textinput.Model) string
ui.PanePreviewCmd(paneID string, lines int) tea.Cmd
Embeddable base model
type model struct {
ui.Base
list list.Model
}
New Tool Checklist
- Create source dir — put source under
~/.config/ or ~/.claude/scripts/
- go.mod — init + add
trenthaines.dev/tui replace directive
- Binary — build to
~/bin/<name>
- chezmoi —
chezmoi add <source-dir>/
- run_onchange — create
~/.local/share/chezmoi/run_onchange_build-<name>.sh.tmpl:
#!/bin/bash
/opt/homebrew/bin/go build -o ~/bin/<name> ~/.../source/
- Popup binding — in
tmux.conf:
bind-key X display-popup -E -w 70% -h 50% -b rounded -S "fg=#ffffff" \
-e "TMUX_CLIENT_WIDTH=#{client_width}" \
-e "TMUX_CLIENT_HEIGHT=#{client_height}" \
"/bin/bash $HOME/.../pick.sh"
Shell wrapper writes result to temp file:
RESULT=$(mktemp)
~/bin/<name> "$RESULT"
[[ -s "$RESULT" ]] && do-something "$(cat $RESULT)"
rm -f "$RESULT"
Layout Patterns
Standard side-by-side (list left, preview right)
func (m model) View() string {
if m.quitting || m.Width == 0 { return "" }
listW := m.Width * 58 / 100
prevW := m.Width - listW
listH := m.Height - 2
title := ui.SolidTitle(m.Width).Render("My Tool")
preview := ui.PreviewPanel(m.preview, prevW, listH, 0)
body := lipgloss.JoinHorizontal(lipgloss.Top, m.list.View(), preview)
footer := ui.Footer("enter:select", "q:quit")
return ui.FillHeight(title+"\n"+body+"\n"+footer, m.Height)
}
WindowSizeMsg handler
case tea.WindowSizeMsg:
m.Width, m.Height = msg.Width, msg.Height
listW := msg.Width * 58 / 100
listH := msg.Height - 2
m.list.SetWidth(listW)
m.list.SetHeight(listH)
m.list.SetDelegate(myDelegate{c: makeCols(listW)})
m.list.Styles.Title = ui.SolidTitle(listW)
fzf-style search (manual filter, no bubbles/list filter)
type model struct {
input textinput.Model
list list.Model
allItems []Item
}
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
filtered := filterItems(m.allItems, m.input.Value())
m.list.SetItems(toListItems(filtered))
return m, cmd
Known Gotchas
NEVER use tea.WithAltScreen() in tmux popups
There is a confirmed Bubble Tea bug where AltScreen takes a second to kick in inside tmux, causing visual glitches. Just omit it — the popup provides a clean terminal already.
tea.NewProgram(model, tea.WithAltScreen())
tea.NewProgram(model)
Always guard View() until WindowSizeMsg fires
func (m model) View() string {
if m.quitting || m.Width == 0 { return "" }
}
Initialize width, height := 0, 0 in main(). The first render outputs nothing, WindowSizeMsg fires immediately and sets real dimensions, then full render happens correctly.
Always use FillHeight
Without AltScreen, Bubble Tea uses cursor movement math to redraw. If View() outputs different line counts between frames (e.g., preview changes height), the cursor drifts and layout shifts. FillHeight pads/trims to always output exactly m.Height lines.
Column widths use listW not full width
listW := width * 58 / 100
c := makeCols(listW)
l := list.New(items, delegate{c: c}, listW, listH)
Popup height includes border rows
display-popup -h 50% allocates 50% of client height INCLUDING the popup's top/bottom border (2 rows). The terminal inside gets slightly fewer rows. Trust WindowSizeMsg.Height as the authoritative value.
Binary not in chezmoi — source is
Compile the binary to ~/bin/ (which is .chezmoiignored). Only commit the source. The run_onchange_ script rebuilds on any machine after chezmoi apply.
Testing Without Affecting Terminal
Use a detached tmux window + capture-pane:
WIN=$(tmux new-window -d -n "tui-test" -P -F "#{window_id}" \
"env TMUX_CLIENT_WIDTH=$(tmux display-message -p '#{client_width}') \
TMUX_CLIENT_HEIGHT=$(tmux display-message -p '#{client_height}') \
~/bin/my-tool /tmp/test-result.txt")
sleep 1.5
tmux capture-pane -t "$WIN" -p | head -30
tmux kill-window -t "$WIN"
This is a static snapshot — good for verifying initial layout and content. Can't test interactive scrolling/keypress behavior. For that, use charmbracelet/x/exp/teatest.
Existing TUI Tools
| Tool | Source | Binary | Popup |
|---|
| Agent Notifications | ~/.claude/scripts/notify-picker/ | ~/bin/agent-notify-picker | prefix+e via claude-popup.sh |
| All Panes | ~/.config/tmux/pane-picker/ | ~/bin/pane-picker | prefix+E |
Both use trenthaines.dev/tui, side-by-side layout, FillHeight, no AltScreen.