필사 모드: Terminal UI Development Guide — Ink, OpenTUI, Bubble Tea, ratatui, Textual: What to Use When
English- Introduction — The shadcn Approach Arrives in the Terminal
- Three Rendering Models
- A Map of the Frameworks
- Terminals Don't Announce Their Own Capabilities
- Resizing and Flicker
- A Small Example — Building a Scrolling Log Viewer with Ink
- CLI or Full-Screen App?
- Conclusion — The Terminal Is a Protocol, Not a Screen
- References
Introduction — The shadcn Approach Arrives in the Terminal
termcn showed up on GeekNews. Its pitch fits in one line — shadcn/ui for terminal apps. It's a collection of React components you copy into your own codebase and modify freely, except the rendering backend isn't the browser — it's Ink and OpenTUI.
The news itself is minor. But it neatly sums up what's happening across the TUI ecosystem right now. Web UI paradigms — components, flexbox layout, theme tokens, copy-paste distribution — are moving straight into the terminal. OpenTUI pairs a native core written in Zig with TypeScript bindings, bundles the Yoga flexbox layout engine and tree-sitter syntax highlighting, and ships both React and Solid bindings. Building a terminal app is starting to feel a lot like building a web app.
But there's a catch. A browser tells you what it supports; a terminal does not. And the rendering model differs fundamentally from one framework to the next. This post organizes TUI development around those two facts. Tool-by-tool introductions and cultural background were already covered in TUI Renaissance 2026, so here we focus on "what to choose and what to watch out for."
Three Rendering Models
Sorting frameworks by language doesn't help you choose. Split them by how they refresh the screen instead, and there are three.
The Elm architecture — Bubble Tea is the prime example. An app is three pieces: Model (state), Update (takes a message, returns new state), and View (renders the state as a string). State changes happen only through messages, and Update is a pure function. Side effects are expressed as Cmd values and handed off to the runtime.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
}
return m, nil
}
The advantage is that all state flow lives in one place — reading the single Update function tells you exactly which key changes what. The downside is that as the app grows, Update turns into one giant switch, and nesting components means writing more plumbing code to delegate messages down to children. For reference, Bubble Tea shipped v2 in February 2026, its first breaking change, and the renderer was replaced with reportedly large performance gains.
Immediate mode — ratatui is the prime example. There is no persisted widget tree. Every loop iteration draws widgets onto a Frame, and the library diffs against the previous buffer and writes only the changed cells to the terminal.
terminal.draw(|frame| {
let area = frame.area();
let block = Block::default().title("logs").borders(Borders::ALL);
frame.render_widget(List::new(items).block(block), area);
})?;
The advantage is that there are extremely few concepts to hold in your head. Widgets carry no state; the whole model is "look at the current state, draw the current screen." Sync bugs between rendering and state simply can't arise structurally. The downside is that you have to build the event loop, focus management, and scroll position entirely by hand. It's freeing, and it costs you accordingly in effort.
React-style reconciliation — Ink, OpenTUI, and, with a different character, Textual all belong here. You build a component tree, and a reconciler diffs it against the previous tree and redraws only what changed. Ink computes layout with Yoga's flexbox, while Textual separates styling out into CSS-like syntax.
The advantage is that a web developer's existing knowledge transfers directly — useState, useEffect, component composition, and list keys all behave exactly the same way. The fact that Claude Code and Gemini CLI both run on top of Ink is real-world validation of this model. The downside is that the abstraction layer is thick enough to make performance problems hard to trace, and it's hard to intuit how much a re-render actually translates into real terminal writes.
A Map of the Frameworks
| Framework | Language / Runtime | Model | Layout | Where it fits |
|---|---|---|---|---|
| Bubble Tea | Go | Elm architecture | Lip Gloss style-string composition | Dev tools where single-binary distribution matters |
| ratatui | Rust | Immediate mode | Constraint-based layout splitting | Full-screen apps that need performance and control |
| Textual | Python | Reconciliation + CSS stylesheets | CSS-like syntax | Python ecosystem, data tools, simultaneous web deployment |
| Ink | Node.js / React | Reconciliation | Yoga flexbox | Adding a UI to a CLI you already built in Node |
| OpenTUI | Zig core + TS bindings | Reconciliation (React, Solid) | Yoga flexbox | TypeScript TUIs that need performance |
| termcn | Component collection on top of Ink/OpenTUI | Follows the two above | Follows the two above | When you need to put a screen together fast |
| blessed family | Node (widget library) / Python (capability wrapper) | Retained widget tree | Absolute / percentage coordinates | Legacy maintenance, low-level capability queries |
A quick note on blessed, since it's commonly misunderstood. Node.js's blessed is an old widget library whose original is effectively unmaintained, with forks like neo-blessed carrying it forward. Python's blessed, on the other hand, is a completely different thing — not a widget library, but a low-level library for terminal capability queries and input handling — and it's still actively maintained. Its latest docs even include kitty keyboard protocol support. Sharing a name doesn't mean sharing a lineage.
Let me be honest about version numbers. ratatui is confirmed to be in the 0.30.x line, with reports that v2 work is underway, but this looks different depending on when you check, so verifying directly on the releases page is more reliable than trusting a snapshot in a blog post. Textual's release cadence is fast enough that quoting a specific version number is nearly pointless. Bubble Tea v2 and Ink 6 each completed their major transitions in 2026.
Terminals Don't Announce Their Own Capabilities
Browsers have feature-detection APIs. Terminals don't. What you get instead is a handful of conventional environment variables, plus escape-sequence queries that may or may not answer back. In TUI development, the cause of "why are weird characters showing up on my screen" is almost always rooted here.
In practice, there are four things you need to detect.
Color. TERM doesn't reliably capture truecolor support, and terminfo's RGB capability isn't filled in consistently either. In practice, the most trustworthy signal is the COLORTERM environment variable — if it's truecolor or 24bit, you can safely emit 24-bit SGR sequences. kitty, WezTerm, Ghostty, foot, iTerm2, Alacritty, and Windows Terminal all set it automatically. Going the other direction, if NO_COLOR is set, convention says to turn color off entirely.
Is the output a TTY? This is the check people skip most often. If standard output is a pipe or a file, you must turn off color, spinners, and cursor movement entirely. Otherwise control characters end up baked into your log files, and you can't filter them with grep anymore.
Terminal size. You get this from ioctl(TIOCGWINSZ), not an environment variable, and changes arrive as a SIGWINCH signal. In Node, process.stdout.columns and stdout's resize event are the wrapper around that.
Advanced features. Synchronized output, the kitty keyboard protocol, and mouse reporting can all be probed with a DECRQM query. But some terminals never respond, so you must always set a timeout. A TUI that hangs forever waiting for a response is a common bug.
# check what the terminal currently claims to be
printf 'TERM=%s COLORTERM=%s TERM_PROGRAM=%s\n' \
"$TERM" "$COLORTERM" "$TERM_PROGRAM"
# query support for synchronized output (DEC private mode 2026) — DECRQM
# response format: CSI ? 2026 ; <status> $ y (status 0 means unsupported)
printf '\033[?2026$p'; sleep 0.2; echo
# query primary device attributes — a response means the terminal is at least alive
printf '\033[c'; sleep 0.2; echo
# eyeball-check truecolor
printf '\033[38;2;255;100;0mtruecolor\033[0m\n'
One more thing: character-width calculation. Korean, CJK ideographs, and emoji each occupy two cells, not one, and an emoji sequence can bundle several code points into a single glyph. If you compute width as string length, borders go crooked the moment Korean text enters the mix. Frameworks usually handle this for you, but if you're writing width-measuring code yourself, you need a wcwidth-family function or grapheme-cluster-based calculation. If you're building a TUI that displays Korean, this isn't optional.
Resizing and Flicker
These are the two flaws users notice first in a TUI.
Resizing has to be handled as an event. Re-reading the size on every render means that if the size changes mid-frame, you get a half-drawn screen. The correct order is: receive the resize signal, update state, and let that state update trigger the render. Bubble Tea expresses this as tea.WindowSizeMsg, Ink as stdout's resize event, and Textual as on_resize.
One thing people often forget when handling resize is clamping the scroll position. If the window shrinks, the currently visible range can run past the end of the content, so you have to re-clamp the position into a valid range every time.
Flicker and tearing share a single cause: while a frame is being sent out as multiple separate writes, the terminal draws that intermediate state onto the screen. There are three countermeasures.
- One write per frame. Assemble the entire string first, then write it all at once.
- Redraw only the changed cells. Clearing everything and redrawing (clear-then-redraw) is guaranteed to flicker. Diffing against the previous frame is the standard approach.
- Use synchronized output. Toggling DEC private mode 2026 on and off makes the terminal treat that span atomically. Ink 6.7+ and Bubble Tea v2 are reported to have adopted this protocol.
# the shape of synchronized output — announces the start and end of a frame
printf '\033[?2026h' # begin synchronized update
# ... output the entire frame here ...
printf '\033[?2026l' # end synchronized update
And if you're building a full-screen app, you need to use the alternate screen buffer. Send CSI ? 1049 h on entry and CSI ? 1049 l on exit, and once the app ends, the user's shell screen and scrollback come back exactly as they were. Skip this, and the user's terminal history gets permanently overwritten by your app's screen — it's the most basic form of TUI etiquette. Exit handling matters just as much. Even when the process dies from a panic or a signal, you still need to leave the alternate screen, make the cursor visible again, and release raw mode. Otherwise the user is left in a shell where their own input doesn't show up.
A Small Example — Building a Scrolling Log Viewer with Ink
A minimal example that covers resizing, key input, range clamping, and exit handling all together. It runs as-is.
mkdir tui-demo && cd tui-demo
npm init -y && npm pkg set type=module
npm i ink react
npm i -D tsx typescript @types/react
// viewer.tsx — run: npx tsx viewer.tsx
import React, { useEffect, useState } from 'react'
import { render, Box, Text, useApp, useInput, useStdout } from 'ink'
const LINES = Array.from(
{ length: 500 },
(_, i) => `[${String(i).padStart(4, '0')}] worker-${i % 4} processed batch ${i}`
)
function Viewer({ lines }: { lines: string[] }) {
const { stdout } = useStdout()
const { exit } = useApp()
const [size, setSize] = useState({
cols: stdout.columns ?? 80,
rows: stdout.rows ?? 24,
})
const [top, setTop] = useState(0)
// don't read the size during render — receive it as an event and put it in state
useEffect(() => {
const onResize = () =>
setSize({ cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 })
stdout.on('resize', onResize)
return () => {
stdout.off('resize', onResize)
}
}, [stdout])
const body = Math.max(size.rows - 2, 1)
const maxTop = Math.max(lines.length - body, 0)
// if the window shrinks, the current position can exceed the range — reclamp it every time
useEffect(() => {
setTop((t) => Math.min(t, maxTop))
}, [maxTop])
useInput((input, key) => {
if (input === 'q') exit()
if (input === 'j' || key.downArrow) setTop((t) => Math.min(t + 1, maxTop))
if (input === 'k' || key.upArrow) setTop((t) => Math.max(t - 1, 0))
if (key.pageDown) setTop((t) => Math.min(t + body, maxTop))
if (key.pageUp) setTop((t) => Math.max(t - body, 0))
})
const view = lines.slice(top, top + body)
return (
<Box flexDirection="column" width={size.cols}>
<Box borderStyle="round" borderColor="cyan" paddingX={1}>
<Text color="cyan">
{`${top + 1}-${top + view.length} / ${lines.length}`}
</Text>
<Text dimColor>{' j·k move PgUp·PgDn page q quit'}</Text>
</Box>
{view.map((line, i) => (
<Text key={top + i} wrap="truncate-end">
{line}
</Text>
))}
</Box>
)
}
// if stdout isn't a TTY, skip the UI and just stream plain text
if (!process.stdout.isTTY) {
for (const line of LINES) console.log(line)
} else {
render(<Viewer lines={LINES} />)
}
These forty-odd lines contain every rule mentioned above. Size arrives as an event, the scroll position gets clamped to a valid range, wrap="truncate-end" keeps a long line from breaking the layout, and if it's not a TTY, the UI gives up on itself entirely. That last condition matters most — this one line is what makes node viewer.tsx | grep worker-2 work.
By default, Ink draws to the normal screen buffer. To make it a full-screen app, you have to wire up entering and leaving the alternate screen yourself.
// enter and leave must be managed as a pair to run full-screen
const enter = () => process.stdout.write('[?1049h')
const leave = () => process.stdout.write('[?1049l[?25h')
enter()
process.on('exit', leave)
process.on('SIGINT', () => {
leave()
process.exit(130)
})
You need process.on('exit', leave) so that even when the process dies from an exception, the user's screen still gets restored. A surprising number of TUIs skip this.
CLI or Full-Screen App?
This final decision is actually the one you should make first. The two are different things entirely.
A streaming CLI writes to standard output line by line; when it finishes, the result stays in the scrollback, and it can be piped. Even with a progress bar or spinner, the essence is the same. Here are the signals that tell you to choose this path.
- There's a chance the result gets piped to another command or saved to a file
- It runs in CI
- The flow is: the user runs the command, reads the result, and leaves
- A single run finishes within a few seconds
A full-screen app occupies the alternate screen and monopolizes input, and leaves nothing behind when it exits. Here are the signals for this path.
- The user moves between multiple tasks on the same screen (navigate, filter, select, execute)
- State keeps updating continuously (log tailing, resource monitors)
- You need a range of keyboard shortcuts
- Sessions last minutes or longer
When in doubt, start with a streaming CLI. A full-screen app is hard to walk back from, and choosing one means giving up accessibility, automation, and pipe compatibility all at once. In practice, a lot of well-built tools offer both modes side by side — pass an argument and it prints once and exits; run it bare and it launches an interactive screen.
Framework choice comes after that. If distribution needs to be a single binary, go with Go or Rust (Bubble Tea, ratatui); if you already have a Node CLI, use Ink; for a Python data tool, use Textual; if you're writing TypeScript but need performance, use OpenTUI. termcn isn't a framework — it's a component collection built on top of those — so it's meant to save you screen-building time once you've already chosen Ink or OpenTUI.
Conclusion — The Terminal Is a Protocol, Not a Screen
TUI frameworks have gotten easy. You lay things out with flexbox, swap colors with theme tokens, copy-paste components into place. Which makes it that much easier to forget what's underneath.
- Pick the rendering model first. Elm gathers state flow into one place, immediate mode cuts down the concept count, and reconciliation lets you reuse web knowledge. This choice shapes your code's structure more than the language does.
- Detect capabilities; don't guess at them.
COLORTERM,NO_COLOR, and TTY status are the bare minimum three, and always put a timeout on a DECRQM query. - Receive resize as an event, and re-clamp the scroll position to a valid range every single time.
- Send one frame as one write, and do it inside a synchronized-output span whenever you can.
- Once you've entered the alternate screen, get out of it no matter how the process dies. Restoring the cursor and releasing raw mode are part of the same set.
- If you're displaying Korean, be sure to verify your character-width calculation. String length is not width.
A terminal isn't just a black screen — it's decades of escape-sequence conventions, layered like sediment. Frameworks just add one more pretty layer on top; the underlying conventions are still very much alive.
References
- termcn — Ink/OpenTUI-based terminal UI components
- termcn docs
- OpenTUI — Zig core + TypeScript bindings
- Ink — React for CLIs
- Bubble Tea — Go's Elm-architecture TUI framework
- ratatui — Rust immediate-mode TUI
- Textual — Python TUI with CSS styling
- blessed (Python) — terminal capability queries and the kitty keyboard protocol
- kitty — comprehensive keyboard protocol specification
- termstandard/colors — truecolor and COLORTERM conventions
- NO_COLOR — the convention for disabling color output
- TUI Renaissance 2026 — a deep tool-by-tool comparison (related post)
현재 단락 (1/158)
[termcn](https://github.com/shadcn-labs/termcn) showed up on GeekNews. Its pitch fits in one line — ...