mcpField
B
building-tui-apps

building-tui-apps

github.com/mhagrelius开发100 次安装
代码仓库

关于 building-tui-apps

building-tui-apps 是构建终端用户界面的参考技能,面向开发者,介绍多种语言的 TUI 库与设计模式。内容涵盖响应式状态、布局、异步操作、无障碍和测试。

主要功能

  1. 01按语言列出 TUI 库选择参考
  2. 02讲解响应式状态与事件模式
  3. 03提供布局和键盘导航指导
  4. 04介绍异步操作与性能处理
  5. 05涵盖无障碍和测试策略

适用场景

  1. 01构建终端仪表板或日志查看器
  2. 02实现多视图文件浏览或交互式数据探索
  3. 03为终端界面处理后台异步工作
  4. 04规划键盘交互、终端缩放和界面测试

安装

复制下面的命令,在终端中运行。

$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()
关键模式

用于异步操作的 Worker:

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. 单一事实来源——每个状态都只有一个存放位置
  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()

异步操作:Worker 模式

关键规则: 切勿阻塞主线程。如果进行同步网络/文件调用,TUI 就会卡住。

Python Textual Worker
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 卡住使用 Worker/异步操作
手动清屏闪烁使用框架的渲染功能
全局状态变更竞态条件使用响应式状态
未处理大小调整布局错乱使用小尺寸终端进行测试
硬编码尺寸无法跨平台使用使用相对尺寸(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

评论

支持 Markdown