Skip to content

필사 모드: ターミナルUI開発ガイド — Ink・OpenTUI・Bubble Tea・ratatui・Textual、何をいつ使うか

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — shadcn方式がターミナルにやってきた

termcnがGeekNewsに上がりました。説明は一行で済みます — ターミナルアプリのためのshadcn/ui。コピーして自分のコードベースに入れ、好きに書き換えるReactコンポーネント集ですが、レンダリングのバックエンドはブラウザではなくInkOpenTUIです。

このニュース自体は小さな話題です。しかし今のTUIエコシステムで起きていることをよく要約しています。Web UIのパラダイム — コンポーネント、フレックスボックスレイアウト、テーマトークン、コピー&ペーストでの配布 — がそのままターミナルに移ってきています。OpenTUIはZigで書かれたネイティブコアにTypeScriptバインディングを載せ、Yogaフレックスボックスレイアウトエンジンとtree-sitterによる構文ハイライトを内蔵しつつ、ReactとSolidのバインディングを提供します。ターミナルアプリを作ることが、Webアプリを作ることに近づいているということです。

ただし、ここには落とし穴があります。ブラウザは自分が何をサポートしているかを教えてくれますが、ターミナルは教えてくれません。しかもレンダリングモデルがフレームワークごとに根本的に違います。この記事はこの二点を軸に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);
})?;

利点は概念が極端に少ないことです。ウィジェットは状態を持たず、「今の状態を見て今の画面を描く」がすべてです。レンダリングと状態のあいだの同期バグが構造的に生まれません。欠点は、イベントループ・フォーカス管理・スクロール位置をすべて自前で作らねばならないことです。自由な分、手間もかかります。

React式の再調整 — Ink、OpenTUI、そして性格は異なりますがTextualもここに属します。コンポーネントツリーを作ると、再調整機が前のツリーと比較して変わった部分だけを描き直します。InkはYogaのフレックスボックスでレイアウトを計算し、Textualはスタイルを CSSに似た文法で分離します。

利点はWeb開発者の知識がそのまま転用できることです。useState、useEffect、コンポーネントの合成、リストのkeyはすべて同じように動作します。Claude CodeとGemini CLIがInkの上で動いているという事実は、このモデルの実戦での裏付けでもあります。欠点は抽象化層が厚く性能問題を追いにくいことと、再レンダリングが実際のターミナルへの書き込みにどれだけつながるかを直感的に把握しづらいことです。

フレームワークの地図

フレームワーク言語 / ランタイムモデルレイアウトどこに向くか
Bubble TeaGoElmアーキテクチャLip Glossのスタイル文字列合成単一バイナリ配布が重要な開発者ツール
ratatuiRust即時モード制約ベースのレイアウト分割性能と制御が必要なフルスクリーンアプリ
TextualPython再調整 + CSSスタイルシートCSS類似の文法Pythonエコシステム、データツール、Web同時配布
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のようなフォークが後を継いでいます。一方、Pythonの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

# 一次デバイス属性の問い合わせ — 応答があれば少なくとも生きているターミナルだ
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

そして全画面アプリなら代替画面バッファ(alternate screen buffer)を使わなければなりません。入場時にCSI ? 1049 h、終了時にCSI ? 1049 lを送ると、アプリが終わったあとユーザーのシェル画面とスクロールバックがそのまま戻ってきます。これをしないと、ユーザーのターミナル履歴がアプリの画面で上書きされてしまいます — TUIの礼儀としてもっとも基本的なことです。終了処理も重要です。パニックやシグナルで死ぬときも、代替画面から抜け、カーソルを再び見えるようにし、raw modeを解除しなければなりません。そうしないと、ユーザーは入力が見えないシェルに取り残されます。

小さな例 — 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、Pythonのデータツールなら Textual、TypeScriptで書きつつ性能が必要ならOpenTUI。termcnはフレームワークではなくその上のコンポーネント集なので、InkかOpenTUIをすでに選んでいるときに画面作りの時間を短縮する用途です。

おわりに — ターミナルは画面ではなくプロトコルだ

TUIフレームワークは楽になりました。フレックスボックスで配置し、テーマトークンで色を変え、コンポーネントをコピー&ペーストします。だからこそ、かえって下の層を忘れやすくなりました。

  • レンダリングモデルを先に選んでください。Elmは状態の流れを一箇所に集め、即時モードは概念を減らし、再調整はWebの知識を再利用します。言語よりもこの選択がコード構造を決めます。
  • 能力は推測せず検出してください。COLORTERMNO_COLOR、TTYかどうかは最低限の三つで、DECRQMクエリには必ずタイムアウトを設けてください。
  • リサイズはイベントとして受け取り、スクロール位置は毎回範囲に収め直してください。
  • 一フレームは一回のwriteで、できれば同期出力の区間の中で送り出してください。
  • 代替画面に入ったら、どんな経路で死んでも必ず抜けてください。カーソルの復元とraw modeの解除までが一セットです。
  • 韓国語を表示するなら、文字幅の計算を必ず確認してください。文字列の長さは幅ではありません。

ターミナルはただの黒い画面ではなく、何十年分ものエスケープシーケンスの規約が積み重なった地層です。フレームワークはその上に美しい層をもうひとつ載せているだけで、規約そのものはそのまま生き続けています。

参考資料

현재 단락 (1/158)

[termcn](https://github.com/shadcn-labs/termcn)がGeekNewsに上がりました。説明は一行で済みます — ターミナルアプリのためのshadcn/ui。コ...

작성 글자: 0원문 글자: 10,202작성 단락: 0/158