Danilo Delizia
Back to Blog

Building Terminal User Interfaces with Ink

TUICLITypeScriptDeveloper Tools

Terminal User Interfaces (TUI) are having a moment. Developers are tired of waiting for desktop apps to boot, and tired of SSH-then-web-UI workflows that feel fragmented. A well-built TUI is immediate, keyboard-first, and fits into the flow of the terminal.

I built two of them in the last month — mailflare and dbpilot — both in TypeScript with Ink. One is useful, one is educational, both took days to ship. Here’s what made that possible and what I learned.

Why a TUI and not a web app

The traditional answer is “no browser overhead,” but that misses what actually matters. A TUI is:

  • Alive in the terminal you already have open. Web apps go in a tab. Tabs accumulate. A TUI runs in a pane or a split.
  • Keyboard native. Vim habits work. Shell habits work. No mouse fumbling to find a button.
  • Instant. A Node.js CLI boots faster than Electron and way faster than a browser.
  • Scriptable from the shell. You can pipe into it, compose it with other tools, and put it in a loop.

The tradeoff is real: you get colors and boxes and tables, but not arbitrary layout. If you’re building something that lives in the terminal — mailbox management, database schema browsing, deployment logs — a TUI is right. If you’re building something that needs arbitrary 2D canvas, it’s not.

mailflare and dbpilot

mailflare is a Mailflare SMTP client in the terminal. It connects to your SMTP server, shows live inbox updates, and lets you search, filter, and read messages without leaving the terminal. The use case is obvious: infrastructure teams monitoring alerts and transactional mail from a deploy window.

dbpilot is a database browser and schema explorer. Point it at a PostgreSQL, MySQL, or SQLite database, and you get an interactive navigator — list schemas, tables, columns, run queries, see results formatted as a table. The idea is that dbpilot $DATABASE_URL is faster than opening a GUI tool or shelling into psql and remembering schema queries.

Both are single-purpose tools. mailflare doesn’t try to compose mail. dbpilot doesn’t have a transaction rollback button. That focus is what made them shippable.

Why Ink

Ink is a React renderer for the terminal. You write <Box>, <Text>, <SelectInput> — the same component model as React web apps — and Ink renders it to the terminal and handles the event loop.

The alternative is reaching for blessed, ink’s bigger cousin. Blessed gives you more primitives and more control. It also gives you 5,000 lines of state management to get right. I’ve shipped both. For a focused tool, Ink wins.

Here’s what made Ink the right choice for both projects:

  1. You already know React. If you’ve built anything in JavaScript in the last five years, you know components, hooks, and props. Ink runs that same mental model in the terminal.

  2. Hot reload during development works. You change a component, the TUI updates without restarting. It’s development speed that matters when you’re figuring out what the UI should be.

  3. The primitives match the task. <SelectInput> for navigating a list of databases. <Box> for layout. <Text> with color props for content. You don’t inherit a thousand lines of terminal archaeology.

  4. Testing is normal. You can unit-test Ink components the same way you test React components. snapshots work. That’s not true of blessed.

What you actually ship

A mailflare session looks like this: you open the TUI, a list of mailboxes appears, you navigate down with arrow keys, hit enter to select one, and you get a searchable, scrollable list of messages. Colors indicate read vs unread. Headers are visible. You hit ? to see keybindings.

That’s three Ink components and about 300 lines of glue:

  • A MailboxList component that renders a selectable list of mailbox names, fires a callback when you hit enter.
  • A MessageTable component that renders messages with read/unread formatting, handles the viewport so a 100-message inbox doesn’t try to render 100 rows.
  • A SearchInput component that filters the message list as you type.

The backend is async: connecting to SMTP, authenticating, fetching message headers. Ink is single-threaded and runs in the browser-like event loop, so all the async calls are .then() chains and useEffect. That’s normal.

dbpilot is similar: a DatabaseBrowser component that navigates schemas, a SchemaTable that shows columns, an Editor that lets you type SQL and see results.

The corner cases

Terminal apps hit specific walls that web apps don’t.

Resize handling. The user resizes the terminal mid-scroll. Ink fires a resize event, you re-render. That part works. The hard part is: if you’ve scrolled to row 50 in a 200-item list, and the terminal shrinks from 50 lines tall to 20, where should the viewport be? I kept the anchor (the selected item stays visible) and let the user scroll back to where they were. Doable, annoying.

Async status. Your app is fetching 50 mailboxes from SMTP. The UI needs to show a spinner while that’s happening, then swap in the list. You end up with a top-level component that holds the state: useState('loading' | 'ready' | 'error'), renders a <Spinner> or the real UI based on state, fires a useEffect to kick off the async work. It’s the same pattern as web apps, but you’ll hit it earlier because a terminal app does more blocking I/O.

Keyboard blocking. If your async work is CPU-heavy or network-heavy, the main thread blocks and the terminal freezes. A real app wants the backend in a worker or a child process. mailflare uses a child process for SMTP I/O and communicates back via a queue. That’s overhead. For a hobby project, await in the component is fine. For shipped tools, you want concurrency.

Reaching the finish line faster

Both projects went from nothing to “this is useful” in about three days each, and GitHub-ship in a week. That’s faster than I would have gotten with blessed, and much faster than a web app.

The speed comes from:

  • No styling framework. Color props and dimension props on components, done. No CSS files. No media queries for screens that don’t exist in a terminal.
  • No component library shopping. Ink has what you need. SelectInput, Text, Box. Done. If you needed more, blessed would be there, but you usually don’t.
  • No database abstraction layers. Both projects call the database library directly (mysql2, pg, sql.js) and render results. No ORM. No migrations. No schema sync tool.
  • Keyboard is implicit. Web apps spend weeks on accessibility and keyboard navigation. TUI apps are keyboard-first. You get that for free because of the medium.

Ship the focused version first. Add features when they’re requested, not when you imagine them.

The TUI ecosystem is getting better, and Ink is the reason I’d reach for TypeScript over Rust or Go for this class of tool. If you’re building something that lives in the terminal, you should try it.