A TUI, short for terminal user interface or text user interface, is an interactive program that draws its interface out of text inside a terminal. Instead of printing output and exiting like a normal command, it stays open, draws panels, lists, prompts and menus, often over the whole window, and reacts to every key you press. htop, vim, lazygit and tmux are TUIs, and so are AI coding agents such as Claude Code and Codex CLI.
CLI, TUI or GUI?
| CLI | TUI | GUI | |
|---|---|---|---|
| Runs in | A terminal | A terminal | Its own window |
| How you use it | Type a command, read the output | Move around a screen with keys | Mouse, keyboard, touch |
| Screen | Scrolls line by line | Redrawn in place | Pixels |
| Example | git log | lazygit | GitHub Desktop |
| Works over SSH | Yes | Yes | Not without extra setup |
A CLI is a conversation: you ask, it answers, it exits. A TUI is an application: it stays open and keeps the screen up to date while you work.
What it looks like
A TUI is still just characters in a grid, drawn so that it reads like an interface:
+- Branches ---------++- Files --------------------------+
|> main || M src/checkout/total.ts |
| fix/discount-tax || A src/checkout/total.test.ts |
| docs/readme || |
+--------------------++----------------------------------+
up/down move enter open c commit q quit
Nothing here is graphics. Real TUIs draw the boxes with box-drawing characters such as ┌ and ─, the highlight is a color code, and the whole screen is repainted whenever something changes.
How does a TUI work?
Three terminal features make it possible:
- Escape sequences. Special byte sequences that start with the escape character tell the terminal to move the cursor, change colors or clear part of the screen.
ESC[31mturns text red;ESC[Hmoves the cursor to the top left. A TUI writes text and these codes, and the terminal paints the result. - Raw mode. Normally a terminal buffers what you type until you press Enter. A TUI switches it to raw mode, so every key press, arrow keys included, reaches the program at once.
- The alternate screen. A TUI switches to a second, empty screen buffer when it starts (
ESC[?1049h) and back when it exits. That is why your scrollback comes back untouched after you quitvim.
On top of that, every TUI runs the same loop: wait for an event (a key, a resize, a timer, data arriving), update its state, redraw the screen.
The trap: if a TUI crashes without restoring the terminal, you are left in raw mode with no echo. Type
resetand press Enter, even if you can’t see what you type.
Why are AI coding agents TUIs?
A coding agent works in your project folder, runs commands in your shell and streams its progress as it goes. The terminal is already where all of that happens, so a TUI is the shortest path:
- It lives next to your tools. The agent runs in the same shell as your tests, git and build commands.
- It can show live progress and ask questions in place: a spinner while the model thinks, a diff, an “allow this command?” prompt you answer with a key. A plain CLI can’t do that without scrolling everything away.
- It runs anywhere a shell runs, including over SSH and inside containers.
More on the agents themselves in terminal coding agents.
Which framework should I use to build one?
| Framework | Language | Model | Known for |
|---|---|---|---|
| Bubble Tea | Go | The Elm Architecture: a model, Update, View | gh-dash, Glow, CockroachDB and AWS tools |
| Ratatui | Rust | Immediate mode: redraw the whole frame each tick | Codex CLI’s interface |
| Ink | JavaScript, TypeScript | React components rendered to the terminal | Lists Claude Code among apps built with it |
| Textual | Python | Widgets and CSS-like styling | Made by the authors of Rich, Python’s terminal formatting library |
| ncurses | C | Low-level screen control | The classic: decades of Unix tools |
Pick the one in the language you already write. They all handle the hard parts: raw mode, the alternate screen, resizing and redrawing only what changed.
A small TUI in Go
An “allow this command?” prompt, the kind coding agents show, in Bubble Tea v2. Create a folder and run go mod init demo, then go get charm.land/bubbletea/v2, and save this as main.go:
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
type model struct {
command string
options []string
cursor int
choice string
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.options)-1 {
m.cursor++
}
case "enter":
m.choice = m.options[m.cursor]
return m, tea.Quit
case "ctrl+c", "q":
m.choice = "Deny"
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() tea.View {
s := fmt.Sprintf("The agent wants to run:\n\n %s\n\n", m.command)
for i, option := range m.options {
cursor := " "
if i == m.cursor {
cursor = ">"
}
s += fmt.Sprintf("%s %s\n", cursor, option)
}
s += "\nUse the arrow keys, Enter to choose.\n"
return tea.NewView(s)
}
func main() {
start := model{
command: "npm install left-pad",
options: []string{"Allow", "Deny"},
}
final, err := tea.NewProgram(start).Run()
if err != nil {
fmt.Println("error:", err)
os.Exit(1)
}
fmt.Println("You chose:", final.(model).choice)
}
Run it with go run .. The whole program is three methods: Init starts it, Update turns a key press into a new state, and View turns the state into text. Bubble Tea handles raw mode, redrawing and putting the terminal back when you quit.
Note what the safe default is: quitting with q or ctrl+c counts as Deny. A prompt that guards an action should fail closed.
Most Bubble Tea tutorials online are written for v1, so a copied example often won’t compile against v2. In v2 the import path is charm.land/bubbletea/v2, key presses arrive as tea.KeyPressMsg, and View returns a tea.View instead of a string. Mixing the two is the most common reason a copied example won’t compile. This example builds against v2.1.1.
Why does Esc feel slow in a TUI?
Arrow keys and function keys reach a program as escape sequences: the Esc character followed by a few more bytes. The up arrow is Esc [ A. So when a TUI receives a lone Esc, it can’t know yet whether you pressed Esc or whether the rest of an arrow key is still on its way. It waits a moment to see.
Most of the time that wait is a few milliseconds and you never notice. Inside tmux you might: tmux adds its own wait on top. Before tmux 3.5 the default escape-time was 500 milliseconds, so pressing Esc to stop a coding agent or leave a menu took half a second to register. tmux 3.5 lowered the default to 10 milliseconds. On an older tmux, set it yourself in ~/.tmux.conf:
set -sg escape-time 10
Then run tmux source-file ~/.tmux.conf, or restart tmux.
Related guides
- Terminal coding agents: Claude Code, Codex CLI and OpenCode, and when to use them
- What is an AI coding agent?: the loop behind the interface
- AI agent permissions: what that “allow this command?” prompt should guard