building-tui-apps について
building-tui-apps は、ターミナルUIを作る開発者向けのリファレンススキルです。複数言語のライブラリ選定に加え、リアクティブな状態管理、レイアウト、非同期処理、アクセシビリティ、テストを扱います。
主な機能
- 01言語別のTUIライブラリ選定
- 02リアクティブな状態管理とイベント設計
- 03レイアウトとキーボード操作の指針
- 04非同期処理とパフォーマンスの手法
- 05アクセシビリティとテスト戦略
活用シーン
- 01ターミナルダッシュボードやログビューアの構築
- 02複数画面のファイルブラウザーやデータ探索UIの実装
- 03ターミナルUIでのバックグラウンド処理
- 04キーボード操作・端末リサイズ・UIテストの計画
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ mhagrelius/ dotfiles --skill building-tui-appsインストール後、Claude Code で/building-tui-appsを入力して読み込まれたか確認します。
詳細
TUI アプリケーションの構築
概要
TUI はリアクティブなターミナルインターフェースです。CLI(単一の操作を実行して終了)とは異なり、TUI は状態を保持し、イベントを処理して、表示を継続的に更新します。ターミナル向けの Web アプリだと考えてください。
TUI を使う場面
digraph decision {
rankdir=TB;
"Need persistent display?" [shape=diamond];
"Multiple views/panels?" [shape=diamond];
"Real-time updates?" [shape=diamond];
"CLI with progress" [shape=box, style=filled, fillcolor=lightblue];
"Full TUI" [shape=box, style=filled, fillcolor=lightgreen];
"CLI" [shape=box, style=filled, fillcolor=lightyellow];
"Need persistent display?" -> "CLI" [label="no"];
"Need persistent display?" -> "Multiple views/panels?" [label="yes"];
"Multiple views/panels?" -> "Full TUI" [label="yes"];
"Multiple views/panels?" -> "Real-time updates?" [label="no"];
"Real-time updates?" -> "Full TUI" [label="yes"];
"Real-time updates?" -> "CLI with progress" [label="no"];
}
TUI が適している場面: ダッシュボードでのモニタリング、ファイルブラウザー、ログビューアー、対話的なデータ探索、画面遷移のある複数ステップのウィザード CLI が適している場面: 単一の操作、出力のパイプ処理、スクリプト作成、シンプルな進捗表示
クイックリファレンス:言語別ライブラリ
| 言語 | フル機能の TUI フレームワーク | シンプルな対話機能 |
|---|---|---|
| Python | textual(モダン、リアクティブ) | rich(テーブル、進捗表示、プロンプト) |
| TypeScript | ink(React のような構成)または blessed | inquirer(プロンプトのみ) |
| C# | Terminal.Gui(フル機能のウィジェット) | Spectre.Console(テーブル、プロンプト) |
ライブラリ選択のフローチャート
digraph library {
rankdir=TB;
"Need full-screen app?" [shape=diamond];
"Python or TS?" [shape=diamond];
"C#?" [shape=diamond];
"Modern reactive?" [shape=diamond];
"textual" [shape=box, style=filled, fillcolor=lightgreen];
"ink" [shape=box, style=filled, fillcolor=lightblue];
"blessed" [shape=box, style=filled, fillcolor=lightblue];
"Terminal.Gui" [shape=box, style=filled, fillcolor=lightyellow];
"rich/Spectre" [shape=box, style=filled, fillcolor=lightgray];
"Need full-screen app?" -> "Python or TS?" [label="yes"];
"Need full-screen app?" -> "rich/Spectre" [label="no, just prompts/tables"];
"Python or TS?" -> "textual" [label="Python"];
"Python or TS?" -> "Modern reactive?" [label="TypeScript"];
"Modern reactive?" -> "ink" [label="yes, React-like"];
"Modern reactive?" -> "blessed" [label="no, traditional"];
"Python or TS?" -> "C#?" [label="neither"];
"C#?" -> "Terminal.Gui" [label="yes"];
}
コアアーキテクチャパターン
┌─────────────────────────────────────────────────────────┐
│ App │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ State │→ │ Widgets │→ │ Render │ │
│ │ (reactive) │ │ (compose) │ │ (on change) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ ↑ │ │
│ └────────── Events ←─────────────────┘ │
└─────────────────────────────────────────────────────────┘
最新の TUI フレームワークはすべて、このリアクティブパターンを採用しています。
- 状態の変更 → 再レンダリングをトリガー
- イベント(キーボード、マウス、サイズ変更) → 状態を更新
- ウィジェットを組み合わせてレイアウトを構成
Python:Textual
基本構造
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, DataTable, Static
from textual.reactive import reactive
from textual.containers import Horizontal, Vertical
class DashboardApp(App):
"""Main TUI application."""
CSS = """
#sidebar { width: 30; }
#main { width: 1fr; }
"""
BINDINGS = [
("q", "quit", "Quit"),
("r", "refresh", "Refresh"),
("enter", "select", "Select"),
]
# Reactive state - changes trigger UI updates
selected_id: reactive[str | None] = reactive(None)
items: reactive[list] = reactive([])
def compose(self) -> ComposeResult:
"""Build the UI tree."""
yield Header()
with Horizontal():
yield DataTable(id="table")
yield Static(id="detail")
yield Footer()
def on_mount(self) -> None:
"""Called when app starts."""
self.load_data()
def watch_selected_id(self, new_id: str | None) -> None:
"""Called automatically when selected_id changes."""
self.update_detail_panel(new_id)
def action_refresh(self) -> None:
"""Handle 'r' key."""
self.load_data()
async def load_data(self) -> None:
"""Load data without blocking UI."""
self.items = await self.fetch_items()
主なパターン
非同期操作のためのワーカー:
from textual.worker import Worker
class MyApp(App):
@work(exclusive=True)
async def fetch_data(self) -> None:
"""Run in background, won't block UI."""
result = await api.get_items()
self.items = result
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Handle worker completion."""
if event.state == WorkerState.SUCCESS:
self.refresh_table()
カスタムウィジェット:
from textual.widget import Widget
from textual.message import Message
class NoticeCard(Widget):
"""Custom widget with message passing."""
class Selected(Message):
def __init__(self, notice_id: str) -> None:
self.notice_id = notice_id
super().__init__()
def on_click(self) -> None:
self.post_message(self.Selected(self.notice_id))
TypeScript:Ink
基本構造
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
const Dashboard = () => {
const [items, setItems] = useState<Item[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const { exit } = useApp();
// Handle keyboard input
useInput((input, key) => {
if (input === 'q') exit();
if (key.upArrow) setSelectedIndex(i => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex(i => Math.min(items.length - 1, i + 1));
if (key.return) handleSelect(items[selectedIndex]);
});
// Load data on mount
useEffect(() => {
loadItems().then(setItems);
}, []);
return (
<Box flexDirection="column">
<Box borderStyle="single" padding={1}>
<Text bold>Dashboard</Text>
</Box>
<Box flexDirection="row">
<ItemList items={items} selected={selectedIndex} />
<DetailPanel item={items[selectedIndex]} />
</Box>
</Box>
);
};
render(<Dashboard />);
主なパターン
リアクティブな更新:
import { useEffect, useState } from 'react';
const LiveStatus = () => {
const [status, setStatus] = useState('loading');
useEffect(() => {
const interval = setInterval(async () => {
const data = await fetchStatus();
setStatus(data);
}, 1000);
return () => clearInterval(interval);
}, []);
return <Text color={status === 'ok' ? 'green' : 'red'}>{status}</Text>;
};
C#:Terminal.Gui
基本構造
using Terminal.Gui;
class Program
{
static void Main()
{
Application.Init();
var top = Application.Top;
var win = new Window("Dashboard")
{
X = 0, Y = 1,
Width = Dim.Fill(),
Height = Dim.Fill()
};
var listView = new ListView(items)
{
X = 0, Y = 0,
Width = Dim.Percent(30),
Height = Dim.Fill()
};
var detailView = new TextView()
{
X = Pos.Right(listView) + 1,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
listView.SelectedItemChanged += (args) => {
detailView.Text = GetDetails(items[listView.SelectedItem]);
};
win.Add(listView, detailView);
top.Add(win);
Application.Run();
Application.Shutdown();
}
}
レイアウトパターン
レスポンシブレイアウト
ターミナルのサイズ変更を適切に処理します。
# Textual - automatic with CSS
CSS = """
#sidebar {
width: 30;
}
@media (width < 80) {
#sidebar { display: none; }
}
"""
// Ink - useStdout hook
import { useStdout } from 'ink';
const ResponsiveLayout = () => {
const { stdout } = useStdout();
const width = stdout.columns;
return (
<Box flexDirection={width < 80 ? 'column' : 'row'}>
{width >= 80 && <Sidebar />}
<MainContent />
</Box>
);
};
一般的なレイアウト
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ Header │ │ Sidebar │ Main │
├──────────┬─────────────────────┤ │ │ │
│ Sidebar │ Main │ │ ────── │ │
│ │ │ │ Item 1 │ Detail View │
│ Nav │ Content │ │ Item 2 │ │
│ │ │ │ Item 3 │ │
├──────────┴─────────────────────┤ │ │ │
│ Footer │ └─────────────┴──────────────────┘
└────────────────────────────────┘
Master-Detail Sidebar + Content
状態管理
digraph state {
rankdir=LR;
"User Input" [shape=ellipse];
"Event Handler" [shape=box];
"State Update" [shape=box];
"Re-render" [shape=box];
"Display" [shape=ellipse];
"User Input" -> "Event Handler";
"Event Handler" -> "State Update";
"State Update" -> "Re-render";
"Re-render" -> "Display";
"Display" -> "User Input" [style=dashed, label="next input"];
}
ルール:
- 信頼できる唯一の情報源 - 状態の各要素を管理する場所は 1 つにします
- 単方向データフロー - イベント → 状態 → レンダリング
- リアクティブな更新 - 手動で更新するのではなく、reactive/useState を使います
パフォーマンス
再レンダリングの連発を防ぐ
# Bad - triggers re-render per item
for item in items:
self.items.append(item) # Each append triggers render!
# Good - single update
self.items = new_items # One render
大規模なリストには仮想化を使う
# Textual DataTable handles this automatically
# For custom widgets, only render visible items
def render_visible(self):
viewport_start = self.scroll_offset
viewport_end = viewport_start + self.height
visible_items = self.items[viewport_start:viewport_end]
# Only render visible_items
頻繁な更新をデバウンスする
from textual.timer import Timer
class LiveDashboard(App):
def __init__(self):
self._pending_updates = []
self._update_timer: Timer | None = None
def queue_update(self, data):
self._pending_updates.append(data)
if not self._update_timer:
self._update_timer = self.set_timer(0.1, self._flush_updates)
def _flush_updates(self):
# Process all pending updates at once
self.process_batch(self._pending_updates)
self._pending_updates = []
self._update_timer = None
キーボード操作
標準的なキー割り当て
| キー | 操作 |
|---|---|
↑/↓ または j/k | 項目を移動 |
Enter | 選択/確定 |
Escape | キャンセル/戻る |
q | 終了 |
? | ヘルプ |
/ | 検索 |
Tab | 次のパネル |
フォーカス管理
# Textual
class MyApp(App):
def action_next_panel(self) -> None:
self.screen.focus_next()
def action_prev_panel(self) -> None:
self.screen.focus_previous()
非同期操作:ワーカーパターン
重要なルール: メインスレッドをブロックしないでください。同期的なネットワーク呼び出しやファイル呼び出しを行うと、TUI はフリーズします。
Python Textual のワーカー
from textual.app import App
from textual.worker import Worker, WorkerState
class DashboardApp(App):
def on_mount(self) -> None:
# Start worker - doesn't block UI
self.run_worker(self.fetch_data())
async def fetch_data(self) -> None:
"""Runs in background thread."""
result = await api.get_items() # Network call
self.items = result # Update state when done
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.state == WorkerState.ERROR:
self.show_error(str(event.worker.error))
TypeScript Ink
const Dashboard = () => {
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Async in useEffect - doesn't block render
(async () => {
const result = await fetchData();
setData(result);
setLoading(false);
})();
}, []);
if (loading) return <Text>Loading...</Text>;
return <DataView data={data} />;
};
C# Terminal.Gui
// Use Application.MainLoop.Invoke for thread-safe UI updates
Task.Run(async () => {
var data = await FetchDataAsync();
Application.MainLoop.Invoke(() => {
listView.SetSource(data); // Update UI on main thread
});
});
アクセシビリティ
- デフォルトで高コントラスト - 色だけに頼らないでください
- スクリーンリーダー向けテキスト - テキストによる代替手段を用意します
- キーボードのみで操作可能 - すべての機能にキーボードでアクセスできるようにします
# Textual - use semantic widgets
from textual.widgets import Button, Label
# Bad - visual only
yield Static("[bold red]Error![/]")
# Good - semantic + visual
yield Label("Error: File not found", id="error", classes="error")
避けるべきパターン
| 避けるべきパターン | 問題 | 修正方法 |
|---|---|---|
| メインスレッドをブロックする | UI がフリーズする | ワーカー/非同期処理を使う |
| 画面を手動でクリアする | ちらつきが発生する | フレームワークのレンダリング機能を使う |
| グローバル状態を変更する | 競合状態が発生する | リアクティブな状態管理を使う |
| サイズ変更に対応しない | レイアウトが崩れる | 小さいターミナルでテストする |
| サイズをハードコードする | 移植性が低くなる | 相対サイズ(Dim.Fill、パーセンテージ)を使う |
| キーボードショートカットがない | マウスに依存する | BINDINGS/useInput を追加する |
| レンダリング中にポーリングする | CPU を使い続ける | タイマーやイベントを使う |
TUI アプリのテスト
Textual を使った Python
from textual.testing import AppTest
async def test_dashboard():
async with AppTest(DashboardApp()) as app:
# Wait for mount
await app.wait_for_loaded()
# Check initial state
table = app.query_one("#table", DataTable)
assert table.row_count > 0
# Simulate key press
await app.press("down")
await app.press("enter")
# Check result
detail = app.query_one("#detail", Static)
assert "selected" in detail.render()
テスト戦略
- スナップショットテスト - レンダリング結果を比較する
- インタラクションテスト - キー入力をシミュレートし、状態を検証する
- 状態テスト - 状態管理ロジックを直接テストする
- 統合テスト - 実際のバックエンドを使ってテストする(API はモック化)
ファイル構成
my_tui/
├── app.py # Main App class
├── screens/ # Full-screen views
│ ├── main.py
│ └── detail.py
├── widgets/ # Reusable components
│ ├── sidebar.py
│ └── status_bar.py
├── state/ # State management
│ └── store.py
├── api/ # Backend communication
│ └── client.py
├── styles.css # Textual CSS (if using)
└── tests/
└── test_app.py
コメント
Markdown 対応