Building a terminal UI in Rust with Ratatui
Six thousand lines into a keyboard-driven TUI for CI pipelines, here is what the framework hands you for free, the parts you still have to design yourself, and the bug that only appears once you add a demo mode.
I spent a few weeks building a terminal dashboard for CI pipelines, the kind of tool where you press a key and a build reruns. It is about 6,400 lines of Rust across nine modules now, and the experience taught me a fairly clean division: the drawing is the easy part, and almost everything I got wrong was state.
Immediate mode changes how you think
Ratatui redraws the entire screen every frame from your current state. There is no widget tree to mutate, no components holding their own private data, no diffing. You get a blank buffer, you write the whole interface into it, and you do that again on the next tick.
Coming from React this feels wasteful for about an hour and then it feels like a relief. There is exactly one source of truth, and every rendering bug becomes the same question: what is wrong in my state struct? You never chase a stale prop or a component that failed to re-render, because nothing persists between frames except the data you own.
Immediate mode does not make the hard part disappear. It moves all of it into one place, which is the next best thing.
The tradeoff lands squarely on you. Scroll offsets, which pane has focus, which row is selected, whether a modal is open, what the modal was opened from: none of that is the framework's problem. In a retained-mode UI a list widget quietly remembers where it was scrolled to. Here you hold that integer yourself, and you are the one who has to remember to clamp it when the underlying list gets shorter.
The layout system is genuinely good
Constraint-based splitting is the part I would miss most. You describe a region as a set of constraints, percentages, fixed line counts, minimums, and it divides the space for you. Nested splits compose without arithmetic, and terminal resizes just work because the whole layout is recomputed from the new size on the next frame.
What the framework does not give you is any opinion about which pane should own the keyboard. Focus is a concept you invent. I ended up with an explicit enum for the focused pane and a single match on it in the key handler, and that was the third design I tried. The first two spread focus logic across the widgets themselves and became unreadable within a week.
Blocking calls are the real enemy
A terminal UI has one job it cannot fail at: respond to a keypress immediately. The moment you fetch over the network on the same thread that reads input, the interface freezes, and a frozen TUI feels far more broken than a slow web page. There is no spinner in the browser chrome to reassure anyone, just a dead terminal.
This is where most of my early design effort went, and the lesson generalises past Rust: every remote call needs to leave the input loop free, and every in-flight request needs a visible state in the interface so the user knows the app is working rather than wedged.
The bug that only exists because of demo mode
Late on I added a demo flag so someone could explore the interface with no server and no credentials, just synthetic data. It is the single best thing I did for adoption, because the honest answer to 'what does this look like?' stopped being a screenshot.
It also introduced a bug I did not see coming. Demo mode was reading and writing the real config directory. Someone trying the tool for the first time, with no intention of connecting anything, could have their actual saved configuration touched by a mode that exists purely to be harmless. The fix was small. Noticing it was the work, and it is the kind of thing you only catch by asking what a mode is permitted to reach rather than what it is supposed to do.
A demo mode that writes to real state is not a demo mode. It is the app with the labels changed.
The unglamorous half of shipping a TUI
The interface was maybe sixty percent of the effort. The rest was everything around it, and none of it is interesting until it is missing:
- Config in the right place per platform, rather than a dotfile dropped in the home directory.
- Shell completions and a man page, both generated from the same argument definitions so they cannot drift.
- Link-time optimisation and symbol stripping, which took the release binary down enough to matter for a download.
- Making Windows work at all, which is its own afternoon.
- A dependency and licence scan running weekly in CI, so a vulnerable transitive crate surfaces without me thinking about it.
Generating the completions and the man page from the argument parser is the one I would recommend to anybody. Hand-written completions are wrong within two releases. Generated ones cannot be, because they are built from the same source as the flags themselves.
Would I choose Rust again for this
For a TUI, yes, and not for the reason people usually give. Performance was never in question; a terminal has a few thousand cells and redrawing it is trivial work. The value was that the compiler makes it very hard to render state that does not exist. Every optional field has to be handled at the point you draw it, so the class of bug where a panel renders half-populated because data has not arrived is mostly designed out.
The cost is that iteration is slower than it would be in a scripting language, and for the first few days that is genuinely annoying. It stops mattering around the point the app gets big enough that you would otherwise be afraid to refactor it.
The thing I would tell myself at the start is to build the demo mode first, not last. It forces a clean boundary between the data layer and the interface on day one, which is a boundary you will want anyway, and it means you always have something to show.