Dec 18, 2025RustratatuitokioTUI3 min
A terminal that writes Verilog
rune-tui is a 1,000-line Rust TUI for generating RTL and testbenches — streaming, editable, and built around the fact that generated hardware code always needs a human edit before it's useful.
Hardware engineers live in the terminal, and most AI tooling assumes they live in a browser. rune-tui is what happened when I stopped trying to bridge that and just built the thing where the work already happens.
It's about a thousand lines of Rust — ratatui for rendering, crossterm for input, tokio underneath — and it does three things: chat about a design problem, generate RTL, generate a testbench.
Two loops, one channel
The structural decision that made everything else easy: the UI and the network never call each other. They pass messages.
pub async fn run_network_loop(
event_tx: UnboundedSender<AppEvent>,
mut action_rx: UnboundedReceiver<AppAction>
) {
The render loop owns all state and redraws on a tick. The network loop owns the HTTP client and
does nothing else. An AppAction goes out, an AppEvent comes back, and neither side blocks the
other.
This sounds obvious written down. It isn't, in a TUI. The naive version awaits the request inside the key handler, and the entire interface freezes for as long as the model is thinking — which for RTL generation is a long time. With the channel split, tokens stream into the transcript while the UI stays fully responsive, and the throbber actually spins.
Requests are typed, not stringly
Three request shapes, one per thing the backend can do:
struct ChatRequest { session_id: String, problem_description: String }
struct RTLRequest { problem_description: String }
struct TBRequest { problem_description: String, module_name: String }
TBRequest carrying module_name is the whole reason this is separate from chat. A testbench
has to instantiate something by name. Making that a required field in the type means it's
impossible to send a testbench request that the backend can't fulfil — the compiler catches what
would otherwise be a runtime 422 and a confusing error in the transcript.
The edit step is not optional
The feature I use most is AppMode::EditingRtl. Generated RTL is a starting point about as
often as it's an answer, and the gap between them is usually thirty seconds of typing.
So the app has an editing mode that opens the generated code in your $EDITOR — via the
edit crate — and reads it back into the transcript when you save. Not a textarea approximating
vim. The actual editor, with your config and your keybindings.
There's also copy_last_rtl and copy_last_tb bound to single keys, because the real workflow
isn't "accept the AI's output," it's "take this into the project and make it right." Clipboard
via arboard. Unglamorous, used constantly.
Streaming into a scrollback that behaves
Two details that took disproportionate effort:
Markdown in a terminal. Responses come back as markdown; tui-markdown renders it into
ratatui spans, so code blocks are readable instead of a wall of backticks.
Auto-follow that knows when to stop. The transcript sticks to the bottom while tokens stream in — unless you've scrolled up, in which case it leaves you where you are. Getting yanked to the bottom mid-read while the model is still talking is enraging, and the fix is a single boolean that most implementations forget.
What it taught me
The interesting constraint in a TUI isn't rendering, it's latency you can't hide. A web app can throw up a skeleton and paper over two seconds. A terminal can't — the user is staring at a cursor. So the design pressure goes toward streaming everything, showing partial state immediately, and never blocking input.
That pressure produced a better tool than the web version I'd have built instead.
Edition 2024, ratatui 0.29, tokio with full features. Small dependency tree, single binary,
starts instantly.