Go · Projects
Notes on writing a terminal UI in Go
What I learned building the Shoreline dashboard for the terminal, from the update loop to keeping a 60 row table smooth over SSH.
1 min read
Shoreline started as a web dashboard, but most people who run it live in a terminal. So I built a second interface for them: a full screen terminal UI that shows every monitor, its last 30 checks and any open incident. These are the notes I wish I had at the start.
The model, update, view loop
Most Go terminal UI libraries follow the same pattern as Elm. You keep all state in one model, handle every event in one update function, and render the whole screen from the model in a view function.
type model struct {
monitors []Monitor
cursor int
width int
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
case checkResult:
m.monitors = apply(m.monitors, msg)
}
return m, nil
}
It feels restrictive for a day and then it feels like a relief. Every bug I had was in exactly one place.
Rendering is cheaper than you think
My first instinct was to redraw only the rows that changed. That was unnecessary. Rendering a 60 row table to a string takes about 300 microseconds, and the library only sends the lines that differ to the terminal. Over SSH from Lisbon to a server in Frankfurt, the dashboard still updates in under one frame.
What was expensive was measuring text. Monitor names with emoji or accented characters broke the column alignment until I measured display width instead of byte length.
Keyboard first, mouse second
- Every action has a single key, shown in a footer that never scrolls away.
/filters the list, which matters more than any other feature once you have 40 monitors.- Mouse support is on, but nothing requires it.
What I would do differently
I would write the snapshot tests on day one. A terminal UI is just a function from state to text, which makes it one of the easiest interfaces to test:
func TestViewWithIncident(t *testing.T) {
m := model{monitors: fixtures.WithIncident(), width: 100}
golden.Assert(t, m.View(), "incident.txt")
}
I added these after the third alignment regression. They have caught 11 more since.