Skip to content

Split View: TUI 르네상스 2026 — Ratatui·Bubble Tea·Textual·Ink로 다시 아름다워진 터미널 (심층 가이드)

|

TUI 르네상스 2026 — Ratatui·Bubble Tea·Textual·Ink로 다시 아름다워진 터미널 (심층 가이드)

프롤로그 — 검은 화면 위의 르네상스

2010년대 중반, 누군가에게 "터미널 UI를 만든다"고 하면 두 가지 반응이 돌아왔다. 첫째, "왜?" 둘째, "ncurses?" 그리고 대화는 거기서 끝났다.

2026년의 풍경은 완전히 다르다.

  • lazygit을 처음 본 사람이 "이거 Electron 앱이에요?"라고 묻는다. 아니다. Go로 짠 TUI다.
  • k9s는 쿠버네티스 운영자의 표준 도구가 되었다. 웹 대시보드보다 빠르다.
  • atuin은 셸 히스토리를 SQLite로 저장하고, 동기화하고, TUI로 검색한다. 한 번 쓰면 Ctrl+R로 못 돌아간다.
  • gum은 셸 스크립트에 둥근 모서리 입력 폼을 입힌다. gum choose, gum confirm만으로 install.sh가 예뻐진다.
  • posting은 Postman을 터미널로 옮겨놓았다. JSON 트리, 마크다운 응답, 변수 환경까지.
  • slumber는 HTTP 클라이언트를 TOML 설정 파일 + 키바인딩으로 다시 짰다.

이게 가능했던 건 네 개의 toolkit이 거의 같은 시기에 성숙했기 때문이다.

  • Ratatui (Rust, immediate-mode) — tui-rs에서 포크되어 2023년 정식 출범, 2026년 v0.31. lazygit-ish 도구의 Rust 진영을 사실상 점령.
  • Bubble Tea (Go, Elm Architecture) — Charm사가 만든 The Elm Architecture(TEA)의 Go 포팅. lazygit, gh, gum, soft-serve의 기반.
  • Textual (Python, declarative + CSS-like) — Will McGugan(Rich 저자)이 만든 Python 전용 toolkit. 2026년 5.x, posting의 기반.
  • Ink (Node.js, JSX/React) — Vadim Demedes가 만든 React for CLIs. Claude Code, GitHub Copilot CLI, Gatsby CLI의 기반.

여기에 C++의 FTXUI, Rust의 cursive, C의 notcurses, Node의 blessed/blessed-contrib까지 더하면 "터미널은 죽은 platform이 아니라 부활한 platform"이라는 게 분명해진다.

이 글은 그 르네상스를 정리한다. 왜 다시 터미널인지(1장), 어떤 demo 앱들이 흐름을 바꿨는지(2장), 네 toolkit의 철학 차이(3~6장), 같은 카운터 앱을 네 언어로 짜는 비교(7장), 30분 첫 TUI 경로(8장), 정직한 trade-off(9장).


1장 · 왜 TUI가 다시 떴는가 — 6가지 이유

먼저 정직하게. TUI는 "감성"이나 "레트로"가 아니다. 그 환상으로는 lazygit이 GitHub Desktop을 대체하지 못한다. 진짜 이유는 따로 있다.

1. SSH·컨테이너·CI 환경이 일상이 되었다. 클라우드·쿠버네티스·원격 개발이 보편화되면서, 우리는 자주 "GUI가 없는 곳"에서 일한다. SSH 한 줄로 k9s를 띄울 수 있다는 건 X11 forwarding으로 GUI 앱 띄우는 것과 비교가 안 된다.

2. 키보드 우선 워크플로의 재발견. Vim·Neovim·Emacs 사용자에게 마우스 왕복은 인지 비용이다. lazygit이 Ctrl+e로 fetch, s로 stage, c로 commit으로 끝나는 흐름은 SourceTree보다 빠르다. 측정 가능하게.

3. 터미널 에뮬레이터가 강해졌다. Alacritty·WezTerm·Kitty·Ghostty가 GPU 가속·트루컬러·이미지 프로토콜·undercurl·합자(ligature)·마우스 이벤트까지 지원한다. 1990년대 ncurses 시절의 256색 한계는 옛말이다.

4. Unicode와 폰트가 따라왔다. Nerd Fonts(아이콘), Powerline 글리프, 박스 그리기 문자, 둥근 모서리(╭─╮)가 보편화되면서 "터미널은 텍스트만 그릴 수 있다"는 한계가 사실상 사라졌다.

5. AI 코딩 도구의 기본 UI가 TUI로 갔다. Claude Code, Codex CLI, GitHub Copilot CLI, Aider — 코딩 에이전트의 대표주자들이 모두 터미널 안에 산다. 채팅 UI가 웹이 아닌 곳에 있다는 건 이상한 일이 아니라 정상이 되었다.

6. Charm·Ratatui라는 "마케팅 잘 하는 라이브러리"가 생겼다. Charm사는 lipgloss·glow·gum·soft-serve·VHS 같은 도구로 "TUI도 예쁠 수 있다"는 걸 데모로 보여줬다. Ratatui는 책(2024년 Manning)·튜토리얼·전시(ratatui.rs)로 Rust TUI 신을 견인했다. 도구는 항상 있었다. 마케팅이 없었을 뿐.

기억할 한 줄: "TUI는 GUI보다 못한 게 아니라, 다른 제약 위에서 다른 최적해를 찾는 형식이다."


2장 · TUI를 다시 멋지게 만든 demo 앱들

도구의 성공은 도구 자신이 아니라 그 도구로 만들어진 결과물이 결정한다. 2020년대 TUI 부흥을 끌고 온 대표주자들을 본다.

lazygit (Go · Bubble Tea 이전 세대의 jesseduffield/gocui)

Git의 모든 일상 작업을 키 한두 번에 끝낸다. 패널 전환은 1·2·3·4, stage는 s, commit은 c, push는 P. 깃 GUI의 사실상 대안. 2026년 5월 기준 GitHub star 약 53k.

gh (Go · Bubble Tea 부분 사용)

GitHub의 공식 CLI. gh pr create, gh pr view, gh repo clone 같은 명령어가 익숙하지만, gh pr checkout이나 인터랙티브 모드에서는 Bubble Tea 패턴이 보인다.

gum (Go · Bubble Tea + Lipgloss)

Charm사의 "셸 스크립트용 위젯 라이브러리." gum input, gum confirm, gum choose, gum spin 같은 명령어를 bash에서 직접 호출한다. install 스크립트·dotfiles bootstrap에 사실상 표준.

k9s (Go · tcell + tview)

쿠버네티스 클러스터를 TUI로 운영. 네임스페이스 전환, pod logs, port-forward, edit deployment까지 키보드만으로. 운영팀의 표준 도구.

atuin (Rust · Ratatui)

Magical shell history. SQLite에 명령어 기록을 저장하고, 디바이스 간 동기화하고, Ctrl+R로 fuzzy 검색한다. 한 번 쓰면 기본 Ctrl+R로 못 돌아간다.

posting (Python · Textual)

"터미널의 Postman." HTTP 요청·환경 변수·컬렉션·JSON 트리 보기·마크다운 응답 렌더링까지. Darren Burns가 Textual의 limit를 보여주려고 만든 demo 앱이 정식 도구로 성장.

slumber (Rust · Ratatui)

YAML 설정 + 키바인딩으로 짠 HTTP 클라이언트. Postman 컬렉션을 텍스트로 다루고 싶은 사람을 위한 도구.

gitui (Rust · Ratatui)

lazygit의 Rust 버전. 더 빠르고 메모리 덜 먹는다는 게 셀링 포인트. 2026년 v0.27.

btop (C++ · 자체 렌더링)

top/htop의 후계자. 둥근 모서리 박스, 그래프, 마우스 지원. C++로 짰지만 UI 디자인이 TUI 르네상스의 시각적 기준을 만들었다.

glow (Go · Bubble Tea)

마크다운 뷰어. glow README.md로 터미널에서 마크다운을 예쁘게 본다. Charm 생태계의 출발점 중 하나.

vhs (Go · Bubble Tea + Headless 터미널)

.tape 스크립트로 터미널 GIF/MP4를 자동 생성. README의 데모 GIF가 일관되게 보이는 비결.

nushell + tabiew + others

CSV·Parquet 뷰어 tabiew (Rust/Ratatui), 파일 매니저 yazi (Rust/Ratatui), 검색 도구 television (Rust/Ratatui) 등 Ratatui 기반 도구가 폭증.

여기서 한 가지 패턴이 보인다. Go = Bubble Tea, Rust = Ratatui, Python = Textual, Node = Ink. 각 언어의 사실상 표준 toolkit이 정해졌다.


3장 · Ratatui — Rust의 immediate-mode TUI

출발점

Florian Dehau가 2016년에 시작한 tui-rs가 2023년 메인테이너 부재로 멈췄고, 커뮤니티가 ratatui로 포크하면서 정식 출범했다. 2026년 5월 기준 v0.31.x, GitHub star 약 14k, 의존하는 crate 수가 1,000개를 넘어섰다. Rust 생태계에서 atuin·gitui·yazi·bottom·television 같은 사실상 모든 메이저 TUI가 Ratatui 위에 산다.

핵심 철학 — Immediate-mode

대부분 GUI 프레임워크는 retained-mode다. 위젯 트리를 만들고, 상태가 바뀌면 트리를 업데이트하고, 프레임워크가 다시 그린다. Ratatui는 immediate-mode다. 매 프레임 사용자가 "이번 프레임의 UI는 이거다"를 직접 그린다.

// 매 프레임 호출되는 draw 클로저
terminal.draw(|frame| {
    let area = frame.area();
    let block = Block::default().borders(Borders::ALL).title("Counter");
    let text = Paragraph::new(format!("count: {}", count)).block(block);
    frame.render_widget(text, area);
})?;

이게 왜 좋은가? 상태 동기화 버그가 없다. 트리에 stale node가 남을 일이 없다. 게임 엔진(bevy_egui, egui)이 즐겨 쓰는 패턴이 그대로 터미널에 왔다.

대신 비용도 있다. 매 프레임 다 그리는 책임은 개발자에게 있다. 60fps로 돌리면 60번 draw 클로저가 호출된다. 비싼 계산은 직접 캐시해야 한다. (실제로는 input event가 있을 때만 다시 그리는 패턴이 일반적.)

위젯과 layout

use ratatui::{
    layout::{Constraint, Direction, Layout},
    widgets::{Block, Borders, List, ListItem, Paragraph},
};

let chunks = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
    .split(frame.area());

frame.render_widget(sidebar, chunks[0]);
frame.render_widget(main_area, chunks[1]);

기본 위젯: Block, Paragraph, List, Table, Tabs, Gauge, Sparkline, BarChart, Chart, Canvas. Canvas로는 점·선·심지어 세계지도까지 그릴 수 있다.

이벤트 — crossterm

Ratatui 자체는 그리기만 하고, 이벤트는 crossterm(또는 termion)이 담당한다.

use crossterm::event::{self, Event, KeyCode};

if event::poll(std::time::Duration::from_millis(100))? {
    if let Event::Key(key) = event::read()? {
        match key.code {
            KeyCode::Char('q') => break,
            KeyCode::Up => count += 1,
            KeyCode::Down => count -= 1,
            _ => {}
        }
    }
}

누가 쓰는가

  • atuin — 셸 히스토리
  • gitui — Git TUI
  • yazi — 파일 매니저
  • bottom — 시스템 모니터(htop 대안)
  • television — fuzzy finder, fzf의 후계자 후보
  • tabiew — CSV/Parquet 뷰어
  • slumber — HTTP 클라이언트

강점·약점

  • 강점 — 성능(컴파일된 Rust), 메모리 효율, 안정성, 풍부한 위젯, 활발한 커뮤니티, 책(Manning 2024)·ratatui.rs 공식 사이트.
  • 약점 — immediate-mode 학습 곡선, 보일러플레이트(crossterm 초기화·alternate screen·raw mode), 디버그 어렵다(터미널 그 자체가 stdout이라 println 디버그 불가).

4장 · Bubble Tea — Go의 The Elm Architecture

출발점

Charm사(Toby Padilla·Christian Rocha)가 2020년에 발표. The Elm Architecture(TEA)를 Go에 직접 옮겼다. 2026년 5월 기준 v1.3, GitHub star 약 32k. lazygit이 기반을 옮기는 중, gh CLI의 일부, gum 전체, glow 전체, soft-serve 전체가 Bubble Tea 위에 있다.

핵심 철학 — Elm Architecture(MVU)

ModelUpdateView. 상태는 Model에 있다. 메시지(이벤트)가 들어오면 Update(msg, model) -> (newModel, cmd). View(model) -> string이 화면을 그린다. 부수효과는 Cmd(고차 함수)로 격리한다.

type Model struct {
    count int
}

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.KeyMsg:
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "up":
            m.count++
        case "down":
            m.count--
        }
    }
    return m, nil
}

func (m Model) View() string {
    return fmt.Sprintf("count: %d\n\nq to quit", m.count)
}

이 함수형 패턴이 주는 이점은 immediate-mode와 다르다. 상태 변화의 흐름이 한 곳에 있다. Update만 보면 무엇이 가능한 변화인지 안다. 테스트하기 쉽다(순수 함수).

Lipgloss — 스타일링

Bubble Tea 단독으로는 색·박스·정렬만 그린다. 그 위에 Lipgloss가 CSS-like 스타일 API를 얹는다.

import "github.com/charmbracelet/lipgloss"

var titleStyle = lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("#FAFAFA")).
    Background(lipgloss.Color("#7D56F4")).
    PaddingTop(1).
    PaddingBottom(1).
    PaddingLeft(2).
    PaddingRight(2)

fmt.Println(titleStyle.Render("Hello, TUI"))

색·여백·박스·정렬·정렬·줄임·줄 바꿈 모두 메서드 체인으로. 둥근 모서리 박스도 한 줄.

Bubbles — 위젯 라이브러리

Bubble Tea 코어는 의도적으로 작다. 위젯은 별도 라이브러리 bubbles에서. textinput, textarea, list, table, viewport, spinner, progress, paginator, key 등.

Wish & Soft Serve — TUI를 SSH로

Charm의 또 다른 작품. wish는 SSH 서버 라이브러리. Bubble Tea 앱을 SSH로 노출시키면, 사용자는 ssh tui.example.com만으로 접속한다. install 필요 없음. soft-serve(Charm의 셀프호스트 Git 서버)가 이 패턴의 대표 예.

누가 쓰는가

  • gum — 셸 스크립트 위젯
  • glow — 마크다운 뷰어
  • vhs — 터미널 녹화
  • soft-serve — Git 서버 TUI
  • bubbletea-app-template 기반 신생 앱 다수
  • lazygit 일부 — 점진 마이그레이션

강점·약점

  • 강점 — 깔끔한 아키텍처, 함수형이라 테스트 쉬움, Lipgloss로 시각적 폴리시 높음, Charm 생태계의 거대한 도구 모음.
  • 약점 — Go 인터페이스의 매번 캐스팅 보일러플레이트(tea.Msg 타입 스위치), 매우 큰 모델에서는 immutable update의 비용, ad-hoc 비동기는 tea.Cmd로 감싸야 함.

5장 · Textual — Python의 선언적 TUI

출발점

Will McGugan(Rich 라이브러리 저자)이 2021년에 시작. Rich가 "예쁜 print"라면 Textual은 "예쁜 앱". 2026년 5월 기준 v5.x, GitHub star 약 28k. posting의 성공이 결정타였다.

핵심 철학 — 웹스러운 선언

CSS-like 스타일링(.tcss), 위젯 트리, 이벤트 핸들러, async-first. 한마디로 "터미널에서 도는 React 비슷한 것" — 다만 명시적 가상 DOM은 없다.

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static

class CounterApp(App):
    BINDINGS = [
        ("up", "increment", "Increment"),
        ("down", "decrement", "Decrement"),
        ("q", "quit", "Quit"),
    ]

    def __init__(self):
        super().__init__()
        self.count = 0

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static(f"count: {self.count}", id="counter")
        yield Footer()

    def action_increment(self):
        self.count += 1
        self.query_one("#counter", Static).update(f"count: {self.count}")

    def action_decrement(self):
        self.count -= 1
        self.query_one("#counter", Static).update(f"count: {self.count}")

if __name__ == "__main__":
    CounterApp().run()

여기서 보이는 것 — compose로 트리 선언, BINDINGS로 단축키 매핑, action_* 메서드가 자동으로 호출됨, query_one으로 CSS-like 셀렉터로 위젯 조회.

CSS-like 스타일 — TCSS

별도 .tcss 파일에 스타일을 분리.

Static {
    background: $boost;
    padding: 1 2;
    border: round $primary;
    text-align: center;
}

#counter {
    width: 30;
    height: 5;
    margin: 1;
}

색은 디자인 시스템 변수($primary, $boost, $success 등)를 그대로 쓸 수 있다.

위젯과 풍부함

Static, Label, Button, Input, TextArea, Select, Switch, Checkbox, RadioSet, ListView, DataTable, Tree, Tabs, ContentSwitcher, ProgressBar, Sparkline, LoadingIndicator, Pretty, Markdown, RichLog, Log, Header, Footer, Placeholder. 정말 많다.

Async-first

async def on_button_pressed(self, event: Button.Pressed) -> None:
    async with httpx.AsyncClient() as client:
        result = await client.get("https://api.example.com")
        self.query_one("#result", Static).update(result.text)

Python의 asyncio를 일급으로 받아들여서 HTTP·DB·파일 IO가 자연스럽다. posting이 이걸 활용한 대표 예.

Textual Web — 같은 코드, 웹으로

textual serve로 같은 앱을 브라우저에서 띄울 수 있다. SSH·로컬·웹 세 가지 배포 경로. 2026년 현재 베타지만 작동한다.

누가 쓰는가

  • posting — 터미널 HTTP 클라이언트(Postman 대안)
  • harlequin — DuckDB·Postgres SQL IDE
  • textual-keymaps — 시각적 키맵
  • frogmouth — 마크다운 뷰어
  • memray — 메모리 프로파일러(부분)
  • 사내 데이터 도구 다수

강점·약점

  • 강점 — Python 친화적, async 자연스러움, CSS 분리로 디자이너·개발자 분업 쉬움, Textual Web으로 웹 배포까지, 풍부한 위젯, Rich와의 시너지(마크다운·코드 하이라이트).
  • 약점 — Python 시작 시간(인터프리터 + import), 메모리 사용량(Rust/Go 대비 큼), GIL의 한계, async 학습 곡선.

6장 · Ink — Node.js + JSX/React for CLIs

출발점

Vadim Demedes가 2017년에 시작. "React 컴포넌트로 CLI를 짜고 싶다"는 단순한 아이디어에서 출발. 2026년 5월 기준 v5.x, GitHub star 약 28k. Claude Code, GitHub Copilot CLI, Gatsby CLI, Prisma CLI, AWS Amplify CLI 모두 Ink로 짰다.

핵심 철학 — React 컴포넌트가 텍스트를 렌더

브라우저에서 React가 DOM을 렌더하듯, Ink는 React를 터미널 텍스트로 렌더한다. <div>/<span> 대신 <Box>/<Text>.

import React, { useState } from 'react';
import { render, Box, Text, useInput } from 'ink';

const Counter = () => {
  const [count, setCount] = useState(0);

  useInput((input, key) => {
    if (key.upArrow) setCount(c => c + 1);
    if (key.downArrow) setCount(c => c - 1);
    if (input === 'q') process.exit(0);
  });

  return (
    <Box borderStyle="round" padding={1}>
      <Text>count: {count}</Text>
    </Box>
  );
};

render(<Counter />);

React Hooks가 그대로 작동한다. useState, useEffect, useReducer, 커스텀 훅까지. 라우팅이 필요하면 ink-router, 폼이 필요하면 ink-form, 입력은 ink-text-input, 멀티-셀렉트는 ink-multi-select.

Flexbox layout

브라우저처럼 Flexbox로 배치한다.

<Box flexDirection="column" width="100%">
  <Box height={3} borderStyle="single">
    <Text>Header</Text>
  </Box>
  <Box flexGrow={1}>
    <Text>Body</Text>
  </Box>
</Box>

flexDirection, flexGrow, justifyContent, alignItems, padding, margin, width, height 모두 친숙한 CSS 이름 그대로.

Suspense·streaming·async

React 18+ 패턴(Suspense, transitions)이 Ink 5에서 작동한다. AI 코딩 도구가 토큰 스트리밍 응답을 보여주는 데 적합한 이유.

누가 쓰는가

  • Claude Code — Anthropic의 코딩 에이전트
  • GitHub Copilot CLIgh copilot
  • Gatsby CLI
  • Prisma CLI
  • AWS Amplify CLI
  • Twilio CLI
  • terkelg/prompts의 React 버전들

강점·약점

  • 강점 — React 지식 그대로 전이, npm 생태계, JSX의 표현력, AI/스트리밍 UI 자연스러움, 디자이너 친화적.
  • 약점 — Node.js 시작 비용(node + V8 + 의존성 로드), 메모리(가장 무거움), Text 내부에 Box 못 넣는 등 텍스트-노드 트리의 제약, 큰 트리에서 리렌더 비용.

7장 · 같은 카운터 앱, 네 언어로 — 직접 비교

가장 간단한 앱: "↑/↓로 카운트 증감, q로 종료." 네 toolkit이 어떻게 다른지 한 화면에 보자.

Ratatui (Rust)

use crossterm::{
    event::{self, Event, KeyCode},
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
    ExecutableCommand,
};
use ratatui::{
    backend::CrosstermBackend,
    widgets::{Block, Borders, Paragraph},
    Terminal,
};
use std::io;

fn main() -> io::Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    stdout.execute(EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let mut count: i32 = 0;

    loop {
        terminal.draw(|f| {
            let block = Block::default().borders(Borders::ALL).title("Counter");
            let para = Paragraph::new(format!("count: {}", count)).block(block);
            f.render_widget(para, f.area());
        })?;

        if event::poll(std::time::Duration::from_millis(100))? {
            if let Event::Key(key) = event::read()? {
                match key.code {
                    KeyCode::Char('q') => break,
                    KeyCode::Up => count += 1,
                    KeyCode::Down => count -= 1,
                    _ => {}
                }
            }
        }
    }

    disable_raw_mode()?;
    terminal.backend_mut().execute(LeaveAlternateScreen)?;
    Ok(())
}

Bubble Tea (Go)

package main

import (
    "fmt"
    "os"
    tea "github.com/charmbracelet/bubbletea"
)

type model struct{ count int }

func (m model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if k, ok := msg.(tea.KeyMsg); ok {
        switch k.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "up":
            m.count++
        case "down":
            m.count--
        }
    }
    return m, nil
}

func (m model) View() string {
    return fmt.Sprintf("count: %d\n\n(q to quit)\n", m.count)
}

func main() {
    if _, err := tea.NewProgram(model{}).Run(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

Textual (Python)

from textual.app import App, ComposeResult
from textual.widgets import Static

class CounterApp(App):
    BINDINGS = [
        ("up", "inc", "Up"),
        ("down", "dec", "Down"),
        ("q", "quit", "Quit"),
    ]

    count = 0

    def compose(self) -> ComposeResult:
        yield Static(f"count: {self.count}", id="c")

    def render_count(self):
        self.query_one("#c", Static).update(f"count: {self.count}")

    def action_inc(self):
        self.count += 1
        self.render_count()

    def action_dec(self):
        self.count -= 1
        self.render_count()

if __name__ == "__main__":
    CounterApp().run()

Ink (TSX)

import React, { useState } from 'react';
import { render, Box, Text, useInput } from 'ink';

const Counter = () => {
  const [count, setCount] = useState(0);

  useInput((input, key) => {
    if (key.upArrow) setCount(c => c + 1);
    if (key.downArrow) setCount(c => c - 1);
    if (input === 'q') process.exit(0);
  });

  return (
    <Box borderStyle="round" padding={1}>
      <Text>count: {count}</Text>
    </Box>
  );
};

render(<Counter />);

코드 라인 수 비교

  • Ratatui: 약 35줄(터미널 raw 모드 셋업 포함). 가장 길지만 가장 명시적.
  • Bubble Tea: 약 30줄. MVU 패턴이 깔끔하지만 타입 스위치가 보일러플레이트.
  • Textual: 약 20줄. 가장 짧고 가장 읽기 쉽다.
  • Ink: 약 15줄. JSX 친화적이라면 가장 짧다.

라인 수가 곧 우열은 아니다. 가장 짧은 코드(Ink)는 가장 무거운 런타임(Node + V8)을 깔고 있고, 가장 긴 코드(Ratatui)는 가장 가벼운 단일 정적 바이너리를 만든다.


8장 · 첫 TUI 30분 — 실전 경로

Path A: Ratatui로 시작 (성능·배포 우선)

# 1. 새 프로젝트
cargo new my_tui && cd my_tui

# 2. 의존성 추가
cargo add ratatui crossterm

# 3. src/main.rs에 위 카운터 예제 붙여넣기
cargo run

배포는 cargo build --release 한 줄로 단일 정적 바이너리. 의존성 zero.

Path B: Bubble Tea로 시작 (Go 생태계 친화)

mkdir my_tui && cd my_tui
go mod init my_tui
go get github.com/charmbracelet/bubbletea
# main.go에 위 예제 붙여넣기
go run main.go

크로스 플랫폼 빌드는 GOOS=linux GOARCH=amd64 go build 식으로 간단.

Path C: Textual로 시작 (Python 친화)

pip install textual
python counter.py

textual run --dev counter.py로 hot reload + dev console까지 켜진다.

Path D: Ink로 시작 (React 친화)

npx create-ink-app my-cli --typescript
cd my-cli
npm run dev

스캐폴드가 텍스트 입력·테스트·빌드까지 다 해준다.

그다음에 할 일 — 공통 체크리스트

  • alt screen 모드 진입/이탈을 정확히 처리(Ratatui는 명시적, 나머지는 자동).
  • raw mode 진입 시 Ctrl+C 처리도 직접(또는 toolkit이 처리).
  • resize 이벤트 받으면 다시 그리기.
  • mouse 이벤트 옵트인.
  • truecolor 지원되는지 $COLORTERM 확인.
  • Unicode 너비 처리(이모지·CJK는 2칸 차지). unicode-width 크레이트나 wcwidth 사용.
  • 로그 분리 — println으로 디버그 못 함. 별도 로그 파일에 쓰거나 dev console로.

9장 · 정직한 trade-off — TUI가 안 어울리는 경우

TUI가 모든 걸 해결하지는 않는다. 다음 경우엔 다른 도구가 낫다.

1. 차트·그래프 무거운 대시보드

박스 그리기 문자로 sparkline은 그릴 수 있지만, 산점도·히트맵·3D 차트는 한계가 명확하다. 데이터 시각화가 중심이면 웹/Streamlit·Grafana가 낫다.

2. 이미지·동영상

iTerm2·Kitty·WezTerm의 inline image 프로토콜이 있지만 사용자 환경 의존성이 크다. tmux 안에서 깨지는 경우가 많다.

3. 마우스 중심 워크플로

복잡한 드래그 앤 드롭, 시각적 편집(다이어그램·이미지·캔버스). 가능은 하지만 사람들이 좋아하지 않는다.

4. 비개발자 대상

TUI는 사용자에게 키 단축키와 모드 개념을 요구한다. 일반 사용자에게는 학습 곡선이 너무 가파르다.

5. 접근성이 critical한 경우

스크린리더는 GUI/웹에 최적화되어 있고, TUI에 대한 지원은 부분적이다. 시각장애 사용자 비율이 큰 도구는 GUI/웹이 낫다.

6. 매우 큰 데이터를 한 화면에

터미널의 한 줄당 문자 수, 화면당 줄 수의 제한이 있다. 가상화 스크롤·페이지네이션이 필요한데, 웹에서 더 자연스럽다.

기억할 한 줄: "TUI는 키보드 우선 워크플로, 원격 접근, 가벼움이 가치인 곳에서 빛난다. 시각화 중심·일반 사용자·접근성 critical인 곳에서는 GUI/웹이 낫다."


10장 · 도구 선택 매트릭스

상황별로 어떤 toolkit을 고를지 한 표로.

상황추천이유
시스템 도구(파일/Git/모니터링)Ratatui성능·배포·단일 바이너리
셸 스크립트 위젯Bubble Tea(gum)즉시 사용 가능, 표준 도구
사내 데이터 도구(SQL·HTTP·분석)Textualasync·Python 라이브러리·풍부한 위젯
AI 코딩 도구·CLI 마법사Ink스트리밍 UI·React 친화
새 Go 서비스의 관리 콘솔Bubble Tea같은 Go 코드베이스 안에서
Rust 서버의 관리자 TUIRatatui같은 바이너리 안에서
빠른 프로토타입Textual가장 적은 보일러플레이트
SSH로 노출할 멀티유저 TUIBubble Tea + wishCharm의 SSH 라이브러리
클러스터 운영(k8s 등)Bubble Tea or Ratatui키 단축키·즉시 반응

에필로그 — 터미널은 죽지 않았다, 부활했다

긴 글이었다. 정리한다.

한 줄 요약

TUI 르네상스의 본질은 '레트로 감성'이 아니라 '키보드 우선·원격 친화·가벼운 배포'라는 세 가지 가치의 재발견이고, Ratatui·Bubble Tea·Textual·Ink가 각자 다른 언어 생태계에서 그 가치를 구현한다.

30초 체크리스트

  1. 새 TUI를 시작하기 전에, 정말 TUI가 맞는 형식인지 9장의 anti-case와 대조했는가?
  2. 대상 사용자가 키보드 중심 워크플로에 익숙한가?
  3. 언어 생태계·팀 역량에 맞는 toolkit을 골랐는가(Rust→Ratatui, Go→Bubble Tea, Python→Textual, Node→Ink)?
  4. 단일 바이너리 배포가 중요한가? Rust/Go가 맞다.
  5. async가 중요한가? Textual·Ink가 자연스럽다.
  6. raw mode·alt screen·resize·Unicode 너비를 처리할 준비가 됐나?
  7. 터미널 디버그 어려움(println 못 씀)을 위한 별도 로그 파일/dev console을 준비했나?
  8. 마우스 옵션을 켜야 한다면, 어떤 사용자가 거부할지 생각했나?
  9. truecolor를 가정해도 되는가? $COLORTERM을 확인하는가?
  10. GIF/스크린샷용 vhs 같은 도구로 데모를 만들어 마케팅까지 챙겼나?

안티패턴 10가지

  1. 매 키 입력마다 무거운 계산을 동기로 돌려서 UI가 멈춤.
  2. immediate-mode(Ratatui)에서 매 프레임 비싼 IO를 다시 함.
  3. Ink의 Text 안에 Box를 넣어서 런타임 에러.
  4. Bubble Tea의 Update에서 부수효과를 Cmd로 격리하지 않고 직접 실행.
  5. Textual의 compose에서 동기 IO를 길게 막아 시작 시간 폭주.
  6. raw mode를 활성화한 채 panic으로 종료해 터미널 상태가 깨짐.
  7. 터미널 resize 이벤트를 무시해서 레이아웃 깨짐.
  8. CJK·이모지 너비 처리를 안 해서 위젯 경계가 어긋남.
  9. println/fmt.Println/print()을 디버그로 써서 화면을 망가뜨림.
  10. 사용자가 다양한 터미널을 쓴다는 사실을 잊고 $TERM/$COLORTERM 미체크.

다음 글 예고

다음 글 후보: Charm 생태계 정복기 — Bubble Tea·Lipgloss·Bubbles·Wish·Glow·VHS 한 호흡으로, Ratatui로 Git TUI 만들기 — lazygit의 Rust 클론을 한 주 만에, Textual + SQLite로 사내 데이터 도구 — Python의 강점을 TUI로.

"GUI는 마우스의 시대를 정의했고, TUI는 키보드의 시대를 다시 가져왔다. 둘은 적이 아니라, 같은 작업에 대한 다른 답이다."

— TUI 르네상스 2026, 끝.


참고 / References

The TUI Renaissance 2026 — Ratatui, Bubble Tea, Textual, Ink and the Beautifully Reborn Terminal (Deep Dive)

Prologue — A Renaissance on a Black Screen

Mid-2010s. Tell someone you were building a terminal UI and you got two reactions. First, "why?" Second, "ncurses?" That ended the conversation.

The 2026 landscape is entirely different.

  • Someone seeing lazygit for the first time asks "is this an Electron app?" No. It is a TUI written in Go.
  • k9s has become the standard tool for Kubernetes operators. Faster than any web dashboard.
  • atuin stores shell history in SQLite, syncs it across devices, and searches it through a TUI. Once you try it, you cannot go back to plain Ctrl+R.
  • gum puts rounded-corner input forms on top of shell scripts. gum choose, gum confirm and your install.sh becomes pretty.
  • posting brings Postman into the terminal. JSON tree, markdown response, variable environments, the works.
  • slumber rebuilt the HTTP client around TOML config plus keybindings.

This became possible because four toolkits matured at roughly the same time.

  • Ratatui (Rust, immediate-mode) — forked from tui-rs and officially launched in 2023, now v0.31 in 2026. Practically owns the lazygit-ish tools on the Rust side.
  • Bubble Tea (Go, Elm Architecture) — Charm's Go port of The Elm Architecture (TEA). The foundation under lazygit, gh, gum, and soft-serve.
  • Textual (Python, declarative with CSS-like styling) — built by Will McGugan (author of Rich). Now at 5.x in 2026 and the base for posting.
  • Ink (Node.js, JSX/React) — Vadim Demedes's React for CLIs. Powers Claude Code, GitHub Copilot CLI, and the Gatsby CLI.

Add C++'s FTXUI, Rust's cursive, C's notcurses, Node's blessed/blessed-contrib, and one thing is clear: the terminal is not a dead platform, it is a resurrected one.

This piece organises that renaissance. Why the terminal, again (chapter 1). Which demo apps changed the wind (chapter 2). The four toolkits and their philosophies (chapters 3–6). Same counter app in four languages, side by side (chapter 7). A 30-minute first-TUI path (chapter 8). Honest trade-offs (chapter 9).


1. Why TUIs Came Back — Six Reasons

Honest first. TUIs are not about "vibes" or "retro." That illusion does not let lazygit replace GitHub Desktop. The real reasons are elsewhere.

1. SSH, containers, and CI became daily life. With cloud, Kubernetes, and remote development everywhere, we work "without a GUI" often. Being able to launch k9s with one ssh line beats X11 forwarding a GUI app, by a wide margin.

2. Keyboard-first workflows were rediscovered. For Vim, Neovim, and Emacs users every round trip to the mouse is a cognitive tax. lazygit's Ctrl+e to fetch, s to stage, c to commit flow is faster than SourceTree. Measurably.

3. Terminal emulators got powerful. Alacritty, WezTerm, Kitty, Ghostty support GPU acceleration, truecolor, image protocols, undercurl, ligatures, and mouse events. The 256-color ceiling of 1990s ncurses is gone.

4. Unicode and fonts caught up. Nerd Fonts (icons), Powerline glyphs, box-drawing characters, rounded corners (╭─╮) became default. The old claim that "terminals can only draw text" is, practically, untrue.

5. The default UI of AI coding tools went TUI. Claude Code, Codex CLI, GitHub Copilot CLI, Aider — every flagship coding agent lives inside the terminal. A chat UI outside the browser is no longer weird, it is normal.

6. Marketing-savvy libraries appeared. Charm shipped lipgloss, glow, gum, soft-serve, VHS and proved by demo that TUIs can be pretty. Ratatui drove the Rust TUI scene with a book (Manning, 2024), tutorials, and a polished site at ratatui.rs. The tools were always there. The marketing was missing.

Remember one line: "A TUI is not 'worse than a GUI.' It is a different form that finds a different local optimum under different constraints."


2. The Demo Apps That Made TUIs Cool Again

Tools succeed because of what is built with them, not because of the tools themselves. Here are the apps that drove the 2020s TUI revival.

lazygit (Go, on gocui before the Bubble Tea era)

Every common Git task in one or two keystrokes. Panel switching is 1/2/3/4, stage is s, commit is c, push is P. A de facto replacement for Git GUIs. About 53k GitHub stars as of May 2026.

gh (Go, partly Bubble Tea)

GitHub's official CLI. The familiar gh pr create, gh pr view, gh repo clone commands, with Bubble Tea patterns visible inside gh pr checkout and interactive flows.

gum (Go, Bubble Tea + Lipgloss)

Charm's "widget library for shell scripts." Call gum input, gum confirm, gum choose, gum spin directly from bash. Effectively the standard for install scripts and dotfile bootstraps.

k9s (Go, tcell + tview)

Operate Kubernetes clusters from a TUI. Switch namespaces, tail pod logs, port-forward, edit deployments — keyboard only. The ops team's standard tool.

atuin (Rust, Ratatui)

Magical shell history. Records commands in SQLite, syncs across devices, fuzzy-searches them on Ctrl+R. Once you adopt it, the plain shell history feels broken.

posting (Python, Textual)

"Postman in the terminal." HTTP requests, environment variables, collections, JSON tree view, markdown response rendering. Darren Burns built it to show Textual's limits, and it grew into a real product.

slumber (Rust, Ratatui)

A TOML-and-keybindings HTTP client. For people who want to keep their Postman collections in plain text under version control.

gitui (Rust, Ratatui)

The Rust take on lazygit. The selling point is faster, leaner memory. v0.27 in 2026.

btop (C++, custom rendering)

The successor to top/htop. Rounded boxes, graphs, mouse support. Written in C++ but its UI design set the visual bar for the renaissance.

glow (Go, Bubble Tea)

A markdown viewer. glow README.md and the README looks great in the terminal. One of the entry points to Charm's ecosystem.

vhs (Go, Bubble Tea + headless terminal)

Generates terminal GIFs and MP4s from .tape scripts. The reason README demo GIFs across the Charm world look consistent.

nushell, tabiew, yazi, television, and more

CSV/Parquet viewer tabiew (Rust/Ratatui), file manager yazi (Rust/Ratatui), fuzzy finder television (Rust/Ratatui), and dozens of others. Ratatui-based tools have exploded in number.

One pattern jumps out. Go = Bubble Tea, Rust = Ratatui, Python = Textual, Node = Ink. Each language picked a de facto standard toolkit.


3. Ratatui — Rust's Immediate-Mode TUI

Origins

Florian Dehau's tui-rs started in 2016 and stalled in 2023 when the maintainer stepped back. The community forked it as ratatui and made it official. As of May 2026 it is at v0.31.x with about 14k GitHub stars and over 1,000 downstream crates. Practically every major Rust TUI — atuin, gitui, yazi, bottom, television — sits on top of Ratatui.

Core Philosophy — Immediate Mode

Most GUI frameworks are retained-mode: build a widget tree, mutate it on state changes, let the framework redraw. Ratatui is immediate-mode: you draw "the UI for this frame" yourself every frame.

// The draw closure is invoked every frame
terminal.draw(|frame| {
    let area = frame.area();
    let block = Block::default().borders(Borders::ALL).title("Counter");
    let text = Paragraph::new(format!("count: {}", count)).block(block);
    frame.render_widget(text, area);
})?;

Why is that nice? No state-sync bugs. No stale nodes hanging around in a tree. The pattern game engines (egui, bevy_egui) love, ported to the terminal.

The cost is on you. Drawing every frame is your responsibility. At 60fps that means 60 draw calls. Expensive computations need explicit caching. In practice most apps only redraw on input events, which is fine.

Widgets and Layout

use ratatui::{
    layout::{Constraint, Direction, Layout},
    widgets::{Block, Borders, List, ListItem, Paragraph},
};

let chunks = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
    .split(frame.area());

frame.render_widget(sidebar, chunks[0]);
frame.render_widget(main_area, chunks[1]);

Built-in widgets: Block, Paragraph, List, Table, Tabs, Gauge, Sparkline, BarChart, Chart, Canvas. With Canvas you can draw points, lines, even a world map.

Events — crossterm

Ratatui itself only draws. Events come from crossterm (or termion).

use crossterm::event::{self, Event, KeyCode};

if event::poll(std::time::Duration::from_millis(100))? {
    if let Event::Key(key) = event::read()? {
        match key.code {
            KeyCode::Char('q') => break,
            KeyCode::Up => count += 1,
            KeyCode::Down => count -= 1,
            _ => {}
        }
    }
}

Who Uses It

  • atuin — shell history
  • gitui — Git TUI
  • yazi — file manager
  • bottom — system monitor, an htop alternative
  • television — fuzzy finder, a candidate successor to fzf
  • tabiew — CSV/Parquet viewer
  • slumber — HTTP client

Strengths and Weaknesses

  • Strengths — performance (compiled Rust), memory efficiency, stability, rich widgets, active community, a Manning book in 2024, the polished ratatui.rs site.
  • Weaknesses — immediate-mode learning curve, boilerplate (crossterm init, alternate screen, raw mode), hard to debug (stdout is the screen, so no println debugging).

4. Bubble Tea — The Elm Architecture in Go

Origins

Charm (Toby Padilla and Christian Rocha) released it in 2020. A direct port of The Elm Architecture (TEA) into Go. As of May 2026 it is at v1.3 with about 32k GitHub stars. lazygit is migrating, parts of gh CLI use it, all of gum, all of glow, and all of soft-serve sit on top of it.

Core Philosophy — Elm Architecture (MVU)

Model, Update, View. State lives in Model. Messages (events) flow through Update(msg, model) -> (newModel, cmd). View(model) -> string paints the screen. Side effects are isolated into Cmd (higher-order functions).

type Model struct {
    count int
}

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.KeyMsg:
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "up":
            m.count++
        case "down":
            m.count--
        }
    }
    return m, nil
}

func (m Model) View() string {
    return fmt.Sprintf("count: %d\n\nq to quit", m.count)
}

The functional pattern buys different benefits than immediate mode. The flow of state change is in one place. Read Update and you know every possible transition. Easy to test (pure functions).

Lipgloss — Styling

Bubble Tea on its own draws colors, boxes, and alignment. Lipgloss layers a CSS-like style API on top.

import "github.com/charmbracelet/lipgloss"

var titleStyle = lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("#FAFAFA")).
    Background(lipgloss.Color("#7D56F4")).
    PaddingTop(1).
    PaddingBottom(1).
    PaddingLeft(2).
    PaddingRight(2)

fmt.Println(titleStyle.Render("Hello, TUI"))

Colors, padding, borders, alignment, truncation, wrapping — all chained methods. Rounded corners in one line.

Bubbles — Widget Library

The Bubble Tea core stays intentionally small. Widgets live in the separate bubbles library: textinput, textarea, list, table, viewport, spinner, progress, paginator, key.

Wish and Soft Serve — TUIs Over SSH

Another Charm piece. wish is an SSH server library. Expose a Bubble Tea app over SSH and users connect with ssh tui.example.com — no install needed. soft-serve, Charm's self-hosted Git server, is the canonical example.

Who Uses It

  • gum — shell script widgets
  • glow — markdown viewer
  • vhs — terminal recording
  • soft-serve — Git server TUI
  • Many newer apps started from the bubbletea-app-template
  • lazygit is gradually migrating

Strengths and Weaknesses

  • Strengths — clean architecture, easy to test thanks to the functional shape, strong visual polish via Lipgloss, the huge Charm ecosystem.
  • Weaknesses — Go's interface type switches give you boilerplate (tea.Msg type matching), immutable update cost in very large models, ad hoc async needs tea.Cmd wrappers.

5. Textual — Declarative TUI for Python

Origins

Will McGugan (author of Rich) started it in 2021. If Rich is "pretty print," Textual is "pretty app." As of May 2026 it is at v5.x with about 28k GitHub stars. posting's success was the deciding moment.

Core Philosophy — Web-Like Declarations

CSS-like styling (.tcss), a widget tree, event handlers, async-first. In one phrase: "something like React, in the terminal" — minus the explicit virtual DOM.

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static

class CounterApp(App):
    BINDINGS = [
        ("up", "increment", "Increment"),
        ("down", "decrement", "Decrement"),
        ("q", "quit", "Quit"),
    ]

    def __init__(self):
        super().__init__()
        self.count = 0

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static(f"count: {self.count}", id="counter")
        yield Footer()

    def action_increment(self):
        self.count += 1
        self.query_one("#counter", Static).update(f"count: {self.count}")

    def action_decrement(self):
        self.count -= 1
        self.query_one("#counter", Static).update(f"count: {self.count}")

if __name__ == "__main__":
    CounterApp().run()

Three things to notice: compose declares the tree, BINDINGS maps shortcuts, action_* methods are dispatched automatically, and query_one finds widgets by CSS-like selectors.

CSS-Like Styling — TCSS

Styles go in a separate .tcss file.

Static {
    background: $boost;
    padding: 1 2;
    border: round $primary;
    text-align: center;
}

#counter {
    width: 30;
    height: 5;
    margin: 1;
}

Colors can reference the design-system variables (primary,primary, boost, $success, etc.).

Rich Widget Library

Static, Label, Button, Input, TextArea, Select, Switch, Checkbox, RadioSet, ListView, DataTable, Tree, Tabs, ContentSwitcher, ProgressBar, Sparkline, LoadingIndicator, Pretty, Markdown, RichLog, Log, Header, Footer, Placeholder. A lot.

Async-First

async def on_button_pressed(self, event: Button.Pressed) -> None:
    async with httpx.AsyncClient() as client:
        result = await client.get("https://api.example.com")
        self.query_one("#result", Static).update(result.text)

Python's asyncio is first-class, which makes HTTP, DB, and file IO feel natural. posting is the canonical example.

Textual Web — Same Code, in the Browser

textual serve hosts the same app in a browser. Three deployment paths: SSH, local, web. Still beta in 2026 but it works.

Who Uses It

  • posting — terminal HTTP client (Postman alternative)
  • harlequin — DuckDB and Postgres SQL IDE
  • frogmouth — markdown viewer
  • memray — memory profiler (partial)
  • Many internal data tools

Strengths and Weaknesses

  • Strengths — Python-friendly, async feels natural, separating CSS lets designers and engineers split work, Textual Web for browser deployment, rich widget library, great synergy with Rich (markdown, code highlighting).
  • Weaknesses — Python startup time (interpreter plus imports), memory usage (heavier than Rust/Go), the GIL ceiling, async learning curve.

6. Ink — JSX/React for CLIs in Node.js

Origins

Vadim Demedes started it in 2017 from one simple idea: "I want to write CLIs with React components." As of May 2026 it is at v5.x with about 28k GitHub stars. Claude Code, GitHub Copilot CLI, Gatsby CLI, Prisma CLI, AWS Amplify CLI all use Ink.

Core Philosophy — React Renders Text

Just as React renders DOM in the browser, Ink renders React to terminal text. Instead of div and span, you use Box and Text.

import React, { useState } from 'react';
import { render, Box, Text, useInput } from 'ink';

const Counter = () => {
  const [count, setCount] = useState(0);

  useInput((input, key) => {
    if (key.upArrow) setCount(c => c + 1);
    if (key.downArrow) setCount(c => c - 1);
    if (input === 'q') process.exit(0);
  });

  return (
    <Box borderStyle="round" padding={1}>
      <Text>count: {count}</Text>
    </Box>
  );
};

render(<Counter />);

React Hooks work as-is. useState, useEffect, useReducer, custom hooks. Need routing? ink-router. Forms? ink-form. Text input? ink-text-input. Multi-select? ink-multi-select.

Flexbox Layout

Layout uses Flexbox, like the browser.

<Box flexDirection="column" width="100%">
  <Box height={3} borderStyle="single">
    <Text>Header</Text>
  </Box>
  <Box flexGrow={1}>
    <Text>Body</Text>
  </Box>
</Box>

flexDirection, flexGrow, justifyContent, alignItems, padding, margin, width, height — familiar CSS names, identical semantics.

Suspense, Streaming, Async

React 18+ patterns (Suspense, transitions) work under Ink 5. Why it fits AI coding tools that stream tokens: rendering partial responses is exactly what Suspense was designed for.

Who Uses It

  • Claude Code — Anthropic's coding agent
  • GitHub Copilot CLIgh copilot
  • Gatsby CLI
  • Prisma CLI
  • AWS Amplify CLI
  • Twilio CLI

Strengths and Weaknesses

  • Strengths — React knowledge transfers directly, npm ecosystem, JSX expressiveness, natural fit for AI streaming UIs, designer-friendly.
  • Weaknesses — Node startup cost (node, V8, deps), heaviest memory footprint, constraints in the text-node tree (you cannot nest Box inside Text), re-render cost in large trees.

7. The Same Counter App, in Four Languages — A Direct Comparison

The simplest possible app: up/down keys increment/decrement a counter, q quits. Here is how the four toolkits express it.

Ratatui (Rust)

use crossterm::{
    event::{self, Event, KeyCode},
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
    ExecutableCommand,
};
use ratatui::{
    backend::CrosstermBackend,
    widgets::{Block, Borders, Paragraph},
    Terminal,
};
use std::io;

fn main() -> io::Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    stdout.execute(EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let mut count: i32 = 0;

    loop {
        terminal.draw(|f| {
            let block = Block::default().borders(Borders::ALL).title("Counter");
            let para = Paragraph::new(format!("count: {}", count)).block(block);
            f.render_widget(para, f.area());
        })?;

        if event::poll(std::time::Duration::from_millis(100))? {
            if let Event::Key(key) = event::read()? {
                match key.code {
                    KeyCode::Char('q') => break,
                    KeyCode::Up => count += 1,
                    KeyCode::Down => count -= 1,
                    _ => {}
                }
            }
        }
    }

    disable_raw_mode()?;
    terminal.backend_mut().execute(LeaveAlternateScreen)?;
    Ok(())
}

Bubble Tea (Go)

package main

import (
    "fmt"
    "os"
    tea "github.com/charmbracelet/bubbletea"
)

type model struct{ count int }

func (m model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if k, ok := msg.(tea.KeyMsg); ok {
        switch k.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "up":
            m.count++
        case "down":
            m.count--
        }
    }
    return m, nil
}

func (m model) View() string {
    return fmt.Sprintf("count: %d\n\n(q to quit)\n", m.count)
}

func main() {
    if _, err := tea.NewProgram(model{}).Run(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

Textual (Python)

from textual.app import App, ComposeResult
from textual.widgets import Static

class CounterApp(App):
    BINDINGS = [
        ("up", "inc", "Up"),
        ("down", "dec", "Down"),
        ("q", "quit", "Quit"),
    ]

    count = 0

    def compose(self) -> ComposeResult:
        yield Static(f"count: {self.count}", id="c")

    def render_count(self):
        self.query_one("#c", Static).update(f"count: {self.count}")

    def action_inc(self):
        self.count += 1
        self.render_count()

    def action_dec(self):
        self.count -= 1
        self.render_count()

if __name__ == "__main__":
    CounterApp().run()

Ink (TSX)

import React, { useState } from 'react';
import { render, Box, Text, useInput } from 'ink';

const Counter = () => {
  const [count, setCount] = useState(0);

  useInput((input, key) => {
    if (key.upArrow) setCount(c => c + 1);
    if (key.downArrow) setCount(c => c - 1);
    if (input === 'q') process.exit(0);
  });

  return (
    <Box borderStyle="round" padding={1}>
      <Text>count: {count}</Text>
    </Box>
  );
};

render(<Counter />);

Line Counts

  • Ratatui: ~35 lines, including terminal raw-mode setup. Longest but most explicit.
  • Bubble Tea: ~30 lines. The MVU pattern is clean but the type switches add boilerplate.
  • Textual: ~20 lines. Shortest and easiest to read.
  • Ink: ~15 lines. If you already think in JSX, the shortest.

Line count is not quality. The shortest code (Ink) sits on the heaviest runtime (Node and V8). The longest (Ratatui) compiles to the smallest single static binary.


8. Your First TUI in 30 Minutes — Real Paths

Path A: Start with Ratatui (performance and deployment first)

# 1. New project
cargo new my_tui && cd my_tui

# 2. Add deps
cargo add ratatui crossterm

# 3. Paste the counter example into src/main.rs
cargo run

For shipping: cargo build --release gives you a single static binary. Zero dependencies.

Path B: Start with Bubble Tea (Go-friendly stack)

mkdir my_tui && cd my_tui
go mod init my_tui
go get github.com/charmbracelet/bubbletea
# Paste the example into main.go
go run main.go

Cross-compilation is one env var: GOOS=linux GOARCH=amd64 go build.

Path C: Start with Textual (Python-friendly stack)

pip install textual
python counter.py

textual run --dev counter.py adds hot reload and a dev console.

Path D: Start with Ink (React-friendly stack)

npx create-ink-app my-cli --typescript
cd my-cli
npm run dev

The scaffold takes care of testing and builds for you.

After That — A Common Checklist

  • Get alternate screen enter/exit right (explicit in Ratatui, automatic in the rest).
  • Handle raw mode carefully, including Ctrl+C trapping.
  • React to resize events and redraw.
  • Opt into mouse events deliberately.
  • Verify truecolor support with COLORTERM.
  • Handle Unicode width correctly — emoji and CJK characters take two cells. Use unicode-width or wcwidth.
  • Split logging from the screen. You cannot debug with println; write to a log file or to a dev console.

9. Honest Trade-Offs — Where TUIs Are the Wrong Form

TUIs do not solve everything. In these cases a different tool is better.

1. Heavy chart/graph dashboards

Box-drawing sparklines are fine, but scatter plots, heatmaps, and 3D charts hit hard limits. If data visualisation is the centre, the web (Streamlit, Grafana) wins.

2. Images and video

iTerm2, Kitty, and WezTerm have inline image protocols, but the environment dependency is significant. Things break inside tmux often.

3. Mouse-first workflows

Complex drag-and-drop, visual editing (diagrams, images, canvases). Technically possible, but users do not like it.

4. Non-developer audiences

A TUI asks users to learn modes and shortcuts. The curve is too steep for the general public.

5. Critical accessibility needs

Screen readers target GUIs and the web first. TUI support is partial. Tools serving large populations of visually impaired users should go GUI/web.

6. Very large data on one screen

A terminal has hard limits on cells per line and lines per screen. Virtualisation and pagination are needed anyway, and the browser does that better.

Remember one line: "A TUI shines when keyboard-first, remote access, and lightweight deployment matter. It loses where heavy visualisation, general audiences, or accessibility dominate."


10. Tool Selection Matrix

A quick lookup by situation.

SituationPickWhy
System tools (file, Git, monitoring)Ratatuiperformance, deploy, single binary
Shell script widgetsBubble Tea (gum)ready to use, the standard
Internal data tools (SQL, HTTP, analysis)Textualasync, Python libs, rich widgets
AI coding tools, CLI wizardsInkstreaming UI, React skills
Admin console inside a new Go serviceBubble Teasame Go codebase
Operator TUI for a Rust serverRatatuisame binary
Fast prototypeTextualleast boilerplate
Multi-user TUI over SSHBubble Tea + wishCharm's SSH lib
Cluster operations (k8s and similar)Bubble Tea or Ratatuikeyboard shortcuts, instant feedback

Epilogue — The Terminal Is Not Dead, It Was Reborn

That was long. Recap.

One-Liner

The TUI renaissance is not retro nostalgia. It is the rediscovery of three values — keyboard-first interaction, remote-friendliness, and lightweight deployment. Ratatui, Bubble Tea, Textual, and Ink instantiate those values in their respective language ecosystems.

Thirty-Second Checklist

  1. Before starting a new TUI, did you check Chapter 9 anti-cases to confirm TUI really is the right form?
  2. Is your audience comfortable with keyboard-first workflows?
  3. Did you pick a toolkit that matches your language ecosystem and team — Rust to Ratatui, Go to Bubble Tea, Python to Textual, Node to Ink?
  4. Does single-binary deployment matter? Rust and Go are the answer.
  5. Does async matter? Textual and Ink feel natural.
  6. Are you ready for raw mode, alternate screen, resize, and Unicode-width handling?
  7. Have you planned a log file or dev console because println debugging is impossible?
  8. If you turn on mouse mode, did you think about who will refuse it?
  9. Can you assume truecolor, or do you need to check COLORTERM?
  10. Did you build a demo with vhs or similar so marketing keeps up with the code?

Ten Anti-Patterns

  1. Doing heavy work synchronously on every keystroke and freezing the UI.
  2. Repeating expensive IO on every frame in immediate-mode (Ratatui).
  3. Nesting Box inside Text in Ink — runtime error.
  4. Running side effects directly in Bubble Tea's Update instead of isolating them into Cmd.
  5. Blocking sync IO in Textual's compose, causing slow startup.
  6. Crashing while raw mode is on and leaving the terminal in a broken state.
  7. Ignoring resize events and breaking the layout.
  8. Skipping CJK/emoji width handling and breaking widget borders.
  9. Using println / fmt.Println / print() for debug and corrupting the screen.
  10. Assuming everyone uses the same terminal and skipping TERM/COLORTERM checks.

What's Next

Candidates: Conquering the Charm Ecosystem — Bubble Tea, Lipgloss, Bubbles, Wish, Glow, VHS in One Pass, Build a Git TUI with Ratatui — Cloning lazygit in a Week, Textual plus SQLite for Internal Data Tools — Python's Strengths in a TUI.

"The GUI defined the mouse era. The TUI brought the keyboard era back. They are not enemies — they are different answers to the same question."

— TUI Renaissance 2026, end.


References