Skip to content

Split View: 터미널 UI 개발 가이드 — Ink·OpenTUI·Bubble Tea·ratatui·Textual, 무엇을 언제 쓰는가

✨ Learn with Quiz
|

터미널 UI 개발 가이드 — Ink·OpenTUI·Bubble Tea·ratatui·Textual, 무엇을 언제 쓰는가

들어가며 — shadcn 방식이 터미널에 도착했다

termcn이 GeekNews에 올라왔습니다. 설명은 한 줄로 끝납니다 — 터미널 앱을 위한 shadcn/ui. 복사해서 내 코드베이스에 넣고 마음대로 고치는 리액트 컴포넌트 모음인데, 렌더링 백엔드가 브라우저가 아니라 InkOpenTUI입니다.

이 소식 자체는 작은 뉴스입니다. 하지만 지금 TUI 생태계에서 벌어지는 일을 잘 요약합니다. 웹 UI 패러다임 — 컴포넌트, 플렉스박스 레이아웃, 테마 토큰, 카피-페이스트 배포 — 이 그대로 터미널로 넘어오고 있습니다. OpenTUI는 Zig로 짠 네이티브 코어에 TypeScript 바인딩을 얹고, Yoga 플렉스박스 레이아웃 엔진과 tree-sitter 문법 강조를 내장한 채 React와 Solid 바인딩을 제공합니다. 터미널 앱을 만드는 일이 웹 앱을 만드는 일과 비슷해지고 있다는 뜻입니다.

그런데 여기에는 함정이 있습니다. 브라우저는 자기가 무엇을 지원하는지 알려 주지만, 터미널은 알려 주지 않습니다. 그리고 렌더링 모델이 프레임워크마다 근본적으로 다릅니다. 이 글은 그 두 가지를 중심으로 TUI 개발을 정리합니다. 도구별 소개와 문화적 배경은 TUI 르네상스 2026 편에서 다뤘으니, 여기서는 "무엇을 고르고 무엇을 조심할 것인가"에 집중합니다.

세 가지 렌더링 모델

프레임워크를 언어로 분류하면 선택에 도움이 안 됩니다. 화면을 갱신하는 방식으로 나누면 셋입니다.

Elm 아키텍처 — Bubble Tea가 대표입니다. 앱은 Model(상태), Update(메시지를 받아 새 상태를 반환), View(상태를 문자열로 렌더링) 세 조각입니다. 상태 변경은 오직 메시지를 통해서만 일어나고, Update는 순수 함수입니다. 부수 효과는 Cmd로 표현해 런타임에 넘깁니다.

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
}

장점은 상태 흐름이 한 곳에 모인다는 것입니다. 어떤 키가 무엇을 바꾸는지 Update 함수 하나만 읽으면 됩니다. 단점은 앱이 커지면 Update가 거대한 switch가 되고, 컴포넌트를 중첩할 때 메시지를 자식으로 위임하는 배관 코드가 늘어난다는 것입니다. 참고로 Bubble Tea는 2026년 2월에 v2가 나오면서 첫 파괴적 변경을 겪었고, 렌더러가 교체되며 성능이 크게 개선됐다고 보고됐습니다.

즉시 모드 — ratatui가 대표입니다. 위젯 트리를 보관하지 않습니다. 매 루프마다 Frame에 위젯을 그리고, 라이브러리는 이전 버퍼와 비교해 바뀐 셀만 터미널에 씁니다.

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);
})?;

장점은 개념이 극도로 적다는 것입니다. 위젯은 상태를 갖지 않고, "지금 상태를 보고 지금 화면을 그린다"가 전부입니다. 렌더링과 상태 사이의 동기화 버그가 구조적으로 생기지 않습니다. 단점은 이벤트 루프·포커스 관리·스크롤 위치를 전부 직접 만들어야 한다는 것입니다. 자유롭고, 그만큼 손이 많이 갑니다.

리액트식 재조정 — Ink, OpenTUI, 그리고 성격은 다르지만 Textual이 여기 속합니다. 컴포넌트 트리를 만들면 재조정기가 이전 트리와 비교해 바뀐 부분만 다시 그립니다. Ink는 Yoga 플렉스박스로 레이아웃을 계산하고, Textual은 CSS 비슷한 문법으로 스타일을 분리합니다.

장점은 웹 개발자의 지식이 그대로 전이된다는 것입니다. useState, useEffect, 컴포넌트 합성, 리스트 키가 모두 똑같이 동작합니다. Claude Code와 Gemini CLI가 Ink 위에서 돌아간다는 사실이 이 모델의 실전 검증이기도 합니다. 단점은 추상화 층이 두꺼워 성능 문제를 추적하기 어렵고, 리렌더가 실제 터미널 쓰기로 얼마나 이어지는지 직관적으로 알기 힘들다는 것입니다.

프레임워크 지도

프레임워크언어 / 런타임모델레이아웃어디에 맞는가
Bubble TeaGoElm 아키텍처Lip Gloss 스타일 문자열 조합단일 바이너리 배포가 중요한 개발자 도구
ratatuiRust즉시 모드제약 기반 레이아웃 분할성능과 제어가 필요한 전체 화면 앱
TextualPython재조정 + CSS 스타일시트CSS 유사 문법파이썬 생태계, 데이터 도구, 웹 동시 배포
InkNode.js / React재조정Yoga 플렉스박스이미 Node로 만든 CLI에 UI를 얹을 때
OpenTUIZig 코어 + TS 바인딩재조정(React·Solid)Yoga 플렉스박스성능이 필요한 TypeScript TUI
termcnInk·OpenTUI 위의 컴포넌트 모음위 둘을 따름위 둘을 따름화면을 빨리 만들어야 할 때
blessed 계열Node(위젯 라이브러리) / Python(능력 래퍼)보유형 위젯 트리절대·비율 좌표레거시 유지보수, 저수준 능력 조회

blessed에 대해서는 오해가 잦아 짚어 둡니다. Node.js의 blessed는 오래된 위젯 라이브러리로 원본은 사실상 유지보수가 멈췄고 neo-blessed 같은 포크가 이어받았습니다. 반면 파이썬의 blessed는 완전히 다른 물건으로, 위젯이 아니라 터미널 능력 조회와 입력 처리를 담당하는 저수준 라이브러리이고 지금도 활발히 관리됩니다. 최신 문서에는 kitty 키보드 프로토콜 지원까지 들어 있습니다. 이름이 같다고 같은 계보로 묶으면 안 됩니다.

버전에 대해서는 솔직하게 적습니다. ratatui는 0.30 계열이 확인되고 v2 작업이 진행 중이라는 보고가 있으나 시점에 따라 다르게 보이므로 릴리스 페이지에서 직접 확인하는 편이 정확합니다. Textual은 릴리스 주기가 매우 빨라 특정 버전 번호를 인용하는 것이 무의미할 정도입니다. Bubble Tea v2와 Ink 6은 각각 2026년에 메이저 전환을 마쳤습니다.

터미널은 자기 능력을 알려 주지 않는다

브라우저에는 기능 탐지 API가 있습니다. 터미널에는 없습니다. 있는 것은 관습적인 환경 변수 몇 개와, 답을 줄 수도 있고 안 줄 수도 있는 이스케이프 시퀀스 질의뿐입니다. TUI에서 "왜 내 화면에 이상한 문자가 찍히지"의 원인은 거의 항상 여기입니다.

실무에서 필요한 탐지는 넷입니다.

. TERM은 트루컬러 여부를 잘 담지 못하고, terminfo의 RGB 능력도 균일하게 채워져 있지 않습니다. 현실적으로 가장 신뢰할 만한 신호는 COLORTERM 환경 변수이며, 값이 truecolor 또는 24bit이면 24비트 SGR 시퀀스를 안전하게 내보낼 수 있습니다. kitty, WezTerm, Ghostty, foot, iTerm2, Alacritty, Windows Terminal이 자동으로 설정합니다. 반대 방향으로는 NO_COLOR가 설정돼 있으면 색을 완전히 끄는 것이 관례입니다.

출력 대상이 TTY인가. 이게 가장 자주 빠뜨리는 검사입니다. 표준 출력이 파이프나 파일이면 색·스피너·커서 이동을 전부 꺼야 합니다. 그렇지 않으면 로그 파일에 제어 문자가 그대로 들어가고, grep으로 필터링할 수 없게 됩니다.

터미널 크기. 환경 변수가 아니라 ioctl(TIOCGWINSZ)로 얻고, 변경은 SIGWINCH 시그널로 통지받습니다. Node에서는 process.stdout.columns와 stdout의 resize 이벤트가 그 래퍼입니다.

고급 기능. 동기화 출력, kitty 키보드 프로토콜, 마우스 리포팅은 DECRQM 질의로 물어볼 수 있습니다. 다만 응답하지 않는 터미널이 있으므로 타임아웃을 반드시 두어야 합니다. 응답을 기다리며 영원히 멈춰 있는 TUI는 흔한 버그입니다.

# 지금 터미널이 무엇을 자칭하는지 확인
printf 'TERM=%s COLORTERM=%s TERM_PROGRAM=%s\n' \
  "$TERM" "$COLORTERM" "$TERM_PROGRAM"

# 동기화 출력(DEC 프라이빗 모드 2026) 지원 여부 질의 — DECRQM
# 응답 형식: CSI ? 2026 ; <상태> $ y   (상태 0이면 미지원)
printf '\033[?2026$p'; sleep 0.2; echo

# 1차 장치 속성 질의 — 응답이 오면 최소한 살아 있는 터미널이다
printf '\033[c'; sleep 0.2; echo

# 트루컬러 육안 확인
printf '\033[38;2;255;100;0mtruecolor\033[0m\n'

여기에 하나 더. 문자 폭 계산입니다. 한글·한자·이모지는 셀 하나가 아니라 둘을 차지하고, 이모지 시퀀스는 코드포인트 여러 개가 한 글자입니다. 폭 계산을 문자열 길이로 하면 한글이 섞이는 순간 테두리가 어긋납니다. 프레임워크가 대개 처리해 주지만, 직접 폭을 재는 코드를 쓸 때는 반드시 wcwidth 계열 함수나 그래핌 클러스터 단위 계산을 써야 합니다. 한국어 TUI를 만든다면 이건 선택이 아닙니다.

리사이즈와 깜빡임

TUI에서 사용자가 가장 먼저 알아채는 결함 둘입니다.

리사이즈는 이벤트로 다뤄야 합니다. 렌더링할 때마다 크기를 다시 읽는 방식은 프레임 중간에 크기가 바뀌면 반쪽짜리 화면을 만듭니다. 올바른 순서는 리사이즈 시그널을 받아 상태를 갱신하고, 그 갱신이 렌더링을 유발하게 하는 것입니다. Bubble Tea는 tea.WindowSizeMsg로, Ink는 stdout의 resize 이벤트로, Textual은 on_resize로 이걸 표현합니다.

리사이즈를 처리할 때 자주 놓치는 것이 스크롤 위치 보정입니다. 창이 줄어들면 현재 보이는 범위가 콘텐츠 끝을 넘어갈 수 있으므로, 매번 위치를 유효 범위로 다시 묶어 줘야 합니다.

깜빡임과 찢어짐은 원인이 하나입니다. 한 프레임을 여러 번의 write로 나눠 내보내는 동안 터미널이 중간 상태를 화면에 그리는 것. 대응은 셋입니다.

  1. 한 프레임을 한 번의 write로. 문자열을 전부 조립한 뒤 한 번에 씁니다.
  2. 바뀐 셀만 다시 그리기. 전체를 지우고 다시 그리면(clear 후 redraw) 반드시 깜빡입니다. 이전 프레임과 diff를 내는 것이 표준입니다.
  3. 동기화 출력 사용. DEC 프라이빗 모드 2026을 켜고 끄면 터미널이 그 구간을 원자적으로 처리합니다. Ink 6.7 이상과 Bubble Tea v2가 이 프로토콜을 채택했다고 보고됩니다.
# 동기화 출력의 원형 — 프레임 시작과 끝을 알린다
printf '\033[?2026h'   # begin synchronized update
#   ... 프레임 전체를 여기서 출력 ...
printf '\033[?2026l'   # end synchronized update

그리고 전체 화면 앱이라면 대체 화면 버퍼를 써야 합니다. 진입 시 CSI ? 1049 h, 종료 시 CSI ? 1049 l을 보내면 앱이 끝난 뒤 사용자의 셸 화면과 스크롤백이 그대로 돌아옵니다. 이걸 안 하면 사용자의 터미널 히스토리가 앱 화면으로 덮여 버립니다 — TUI 예의에서 가장 기본입니다. 종료 처리도 중요합니다. 패닉이나 시그널로 죽을 때도 대체 화면에서 나오고, 커서를 다시 보이게 하고, 원시 모드를 해제해야 합니다. 그러지 않으면 사용자는 입력이 보이지 않는 셸에 남겨집니다.

작은 예제 — Ink로 만드는 스크롤 로그 뷰어

리사이즈, 키 입력, 범위 보정, 종료 처리를 모두 포함한 최소 예제입니다. 그대로 실행됩니다.

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 — 실행: 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)

  // 크기는 렌더링 중에 읽지 않고 이벤트로 받아 상태에 넣는다
  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)

  // 창이 줄어들면 현재 위치가 범위를 넘어갈 수 있다 — 매번 다시 묶어 준다
  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 이동   PgUp·PgDn 페이지   q 종료'}</Text>
      </Box>
      {view.map((line, i) => (
        <Text key={top + i} wrap="truncate-end">
          {line}
        </Text>
      ))}
    </Box>
  )
}

// 출력이 TTY가 아니면 UI를 띄우지 않고 그냥 텍스트로 흘려보낸다
if (!process.stdout.isTTY) {
  for (const line of LINES) console.log(line)
} else {
  render(<Viewer lines={LINES} />)
}

이 40여 줄에 앞에서 말한 규칙들이 들어 있습니다. 크기를 이벤트로 받고, 스크롤 위치를 범위로 묶고, wrap="truncate-end"로 긴 줄이 레이아웃을 깨뜨리지 않게 하고, TTY가 아니면 UI 자체를 포기합니다. 마지막 조건이 특히 중요합니다 — 이 한 줄이 node viewer.tsx | grep worker-2를 동작하게 만듭니다.

Ink는 기본적으로 일반 화면 버퍼에 그립니다. 전체 화면 앱으로 만들려면 대체 화면 진입·복귀를 직접 넣어야 합니다.

// 전체 화면으로 쓰려면 진입과 복귀를 짝으로 관리한다
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)
})

process.on('exit', leave)가 있어야 예외로 죽을 때도 사용자의 화면이 복구됩니다. 이 처리를 빼먹은 TUI가 정말 많습니다.

CLI인가 전체 화면 앱인가

마지막 판단이 사실 첫 번째로 해야 할 판단입니다. 둘은 다른 물건입니다.

스트리밍 CLI는 표준 출력에 줄 단위로 쓰고, 끝나면 결과가 스크롤백에 남고, 파이프로 넘길 수 있습니다. 진행 표시줄이나 스피너가 있어도 본질은 같습니다. 이쪽을 골라야 할 신호는 이렇습니다.

  • 결과를 다른 명령으로 넘기거나 파일로 저장할 가능성이 있다
  • CI에서 실행된다
  • 사용자가 명령을 실행하고 결과를 읽고 나가는 흐름이다
  • 한 번의 실행이 몇 초 안에 끝난다

전체 화면 앱은 대체 화면을 점유하고 입력을 독점하며, 종료하면 아무것도 남기지 않습니다. 이쪽 신호는 다음과 같습니다.

  • 사용자가 같은 화면에서 여러 작업을 오간다(탐색, 필터, 선택, 실행)
  • 상태가 계속 갱신된다(로그 추적, 리소스 모니터)
  • 키보드 단축키가 여러 개 필요하다
  • 세션이 분 단위 이상 지속된다

애매하면 스트리밍 CLI로 시작하세요. 전체 화면 앱은 되돌리기 어렵고, 접근성·자동화·파이프 호환을 전부 포기하는 결정입니다. 실제로 잘 만들어진 도구 다수가 두 모드를 같이 제공합니다 — 인자를 주면 한 번에 출력하고, 인자 없이 실행하면 인터랙티브 화면을 띄우는 방식입니다.

프레임워크 선택은 그다음입니다. 배포 형태가 단일 바이너리여야 하면 Go나 Rust(Bubble Tea, ratatui), 이미 Node CLI가 있으면 Ink, 파이썬 데이터 도구라면 Textual, TypeScript로 쓰되 성능이 필요하면 OpenTUI. termcn은 프레임워크가 아니라 그 위의 컴포넌트 모음이므로, Ink나 OpenTUI를 이미 골랐을 때 화면 만드는 시간을 줄이는 용도입니다.

마치며 — 터미널은 화면이 아니라 프로토콜이다

TUI 프레임워크는 편해졌습니다. 플렉스박스로 배치하고, 테마 토큰으로 색을 바꾸고, 컴포넌트를 복사해 붙입니다. 그래서 오히려 아래층을 잊기 쉬워졌습니다.

  • 렌더링 모델을 먼저 고르세요. Elm은 상태 흐름을 한곳에 모으고, 즉시 모드는 개념을 줄이고, 재조정은 웹 지식을 재사용합니다. 언어보다 이 선택이 코드 구조를 결정합니다.
  • 능력은 추정하지 말고 탐지하세요. COLORTERM, NO_COLOR, TTY 여부는 최소 셋이고, DECRQM 질의에는 반드시 타임아웃을 두세요.
  • 리사이즈는 이벤트로 받고, 스크롤 위치는 매번 범위로 묶으세요.
  • 한 프레임은 한 번의 write로, 가능하면 동기화 출력 구간 안에서 내보내세요.
  • 대체 화면에 들어갔으면 어떤 경로로 죽더라도 나오세요. 커서 복원과 원시 모드 해제까지가 한 세트입니다.
  • 한글을 표시한다면 문자 폭 계산을 반드시 확인하세요. 문자열 길이는 폭이 아닙니다.

터미널은 그냥 검은 화면이 아니라 수십 년치 이스케이프 시퀀스 규약의 퇴적층입니다. 프레임워크가 그 위에 예쁜 층을 하나 더 얹어 줄 뿐, 규약은 그대로 살아 있습니다.

참고 자료

Terminal UI Development Guide — Ink, OpenTUI, Bubble Tea, ratatui, Textual: What to Use When

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

FrameworkLanguage / RuntimeModelLayoutWhere it fits
Bubble TeaGoElm architectureLip Gloss style-string compositionDev tools where single-binary distribution matters
ratatuiRustImmediate modeConstraint-based layout splittingFull-screen apps that need performance and control
TextualPythonReconciliation + CSS stylesheetsCSS-like syntaxPython ecosystem, data tools, simultaneous web deployment
InkNode.js / ReactReconciliationYoga flexboxAdding a UI to a CLI you already built in Node
OpenTUIZig core + TS bindingsReconciliation (React, Solid)Yoga flexboxTypeScript TUIs that need performance
termcnComponent collection on top of Ink/OpenTUIFollows the two aboveFollows the two aboveWhen you need to put a screen together fast
blessed familyNode (widget library) / Python (capability wrapper)Retained widget treeAbsolute / percentage coordinatesLegacy 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.

  1. One write per frame. Assemble the entire string first, then write it all at once.
  2. 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.
  3. 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