mcpField
B
building-tui-apps

building-tui-apps

github.com/mhagrelius開発100 回インストール
リポジトリ

building-tui-apps について

building-tui-apps は、ターミナルUIを作る開発者向けのリファレンススキルです。複数言語のライブラリ選定に加え、リアクティブな状態管理、レイアウト、非同期処理、アクセシビリティ、テストを扱います。

主な機能

  1. 01言語別のTUIライブラリ選定
  2. 02リアクティブな状態管理とイベント設計
  3. 03レイアウトとキーボード操作の指針
  4. 04非同期処理とパフォーマンスの手法
  5. 05アクセシビリティとテスト戦略

活用シーン

  1. 01ターミナルダッシュボードやログビューアの構築
  2. 02複数画面のファイルブラウザーやデータ探索UIの実装
  3. 03ターミナルUIでのバックグラウンド処理
  4. 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 フレームワークシンプルな対話機能
Pythontextual(モダン、リアクティブ)rich(テーブル、進捗表示、プロンプト)
TypeScriptink(React のような構成)または blessedinquirer(プロンプトのみ)
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 フレームワークはすべて、このリアクティブパターンを採用しています。

  1. 状態の変更 → 再レンダリングをトリガー
  2. イベント(キーボード、マウス、サイズ変更) → 状態を更新
  3. ウィジェットを組み合わせてレイアウトを構成

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. 信頼できる唯一の情報源 - 状態の各要素を管理する場所は 1 つにします
  2. 単方向データフロー - イベント → 状態 → レンダリング
  3. リアクティブな更新 - 手動で更新するのではなく、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
    });
});

アクセシビリティ

  1. デフォルトで高コントラスト - 色だけに頼らないでください
  2. スクリーンリーダー向けテキスト - テキストによる代替手段を用意します
  3. キーボードのみで操作可能 - すべての機能にキーボードでアクセスできるようにします
# 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()
テスト戦略
  1. スナップショットテスト - レンダリング結果を比較する
  2. インタラクションテスト - キー入力をシミュレートし、状態を検証する
  3. 状態テスト - 状態管理ロジックを直接テストする
  4. 統合テスト - 実際のバックエンドを使ってテストする(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

コメント