Skip to content
All posts

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.