重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
npx skills add https://github.com/mhagrelius/dotfiles --skill building-tui-appsTUI 是响应式终端界面。与 CLI(单次操作 → 退出)不同,TUI 维护状态、处理事件并持续更新显示。可以将它们视为终端的 Web 应用。
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 框架 | 简单交互 |
|---|
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 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 框架都使用这种响应式模式:
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):
"""主 TUI 应用程序。"""
CSS = """
#sidebar { width: 30; }
#main { width: 1fr; }
"""
BINDINGS = [
("q", "quit", "退出"),
("r", "refresh", "刷新"),
("enter", "select", "选择"),
]
# 响应式状态 - 变化触发 UI 更新
selected_id: reactive[str | None] = reactive(None)
items: reactive[list] = reactive([])
def compose(self) -> ComposeResult:
"""构建 UI 树。"""
yield Header()
with Horizontal():
yield DataTable(id="table")
yield Static(id="detail")
yield Footer()
def on_mount(self) -> None:
"""应用启动时调用。"""
self.load_data()
def watch_selected_id(self, new_id: str | None) -> None:
"""selected_id 变化时自动调用。"""
self.update_detail_panel(new_id)
def action_refresh(self) -> None:
"""处理 'r' 键。"""
self.load_data()
async def load_data(self) -> None:
"""加载数据而不阻塞 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:
"""在后台运行,不会阻塞 UI。"""
result = await api.get_items()
self.items = result
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""处理 worker 完成。"""
if event.state == WorkerState.SUCCESS:
self.refresh_table()
自定义部件:
from textual.widget import Widget
from textual.message import Message
class NoticeCard(Widget):
"""带消息传递的自定义部件。"""
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))
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();
// 处理键盘输入
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]);
});
// 挂载时加载数据
useEffect(() => {
loadItems().then(setItems);
}, []);
return (
<Box flexDirection="column">
<Box borderStyle="single" padding={1}>
<Text bold>仪表板</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>;
};
using Terminal.Gui;
class Program
{
static void Main()
{
Application.Init();
var top = Application.Top;
var win = new Window("仪表板")
{
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 - 使用 CSS 自动处理
CSS = """
#sidebar {
width: 30;
}
@media (width < 80) {
#sidebar { display: none; }
}
"""
// Ink - 使用 useStdout 钩子
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>
);
};
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ 页眉 │ │ 侧边栏 │ 主区域 │
├──────────┬─────────────────────┤ │ │ │
│ 侧边栏 │ 主区域 │ │ ────── │ │
│ │ │ │ 项 1 │ 详情视图 │
│ 导航 │ 内容 │ │ 项 2 │ │
│ │ │ │ 项 3 │ │
├──────────┴─────────────────────┤ │ │ │
│ 页脚 │ └─────────────┴──────────────────┘
└────────────────────────────────┘
主从视图 侧边栏 + 内容
digraph state {
rankdir=LR;
"用户输入" [shape=ellipse];
"事件处理器" [shape=box];
"状态更新" [shape=box];
"重新渲染" [shape=box];
"显示" [shape=ellipse];
"用户输入" -> "事件处理器";
"事件处理器" -> "状态更新";
"状态更新" -> "重新渲染";
"重新渲染" -> "显示";
"显示" -> "用户输入" [style=dashed, label="下一个输入"];
}
规则:
# 错误 - 每个项都会触发重新渲染
for item in items:
self.items.append(item) # 每次追加都会触发渲染!
# 正确 - 单次更新
self.items = new_items # 一次渲染
# Textual DataTable 自动处理此问题
# 对于自定义部件,仅渲染可见项
def render_visible(self):
viewport_start = self.scroll_offset
viewport_end = viewport_start + self.height
visible_items = self.items[viewport_start:viewport_end]
# 仅渲染 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):
# 一次性处理所有待处理的更新
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 会冻结。
from textual.app import App
from textual.worker import Worker, WorkerState
class DashboardApp(App):
def on_mount(self) -> None:
# 启动 worker - 不阻塞 UI
self.run_worker(self.fetch_data())
async def fetch_data(self) -> None:
"""在后台线程中运行。"""
result = await api.get_items() # 网络调用
self.items = result # 完成后更新状态
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.state == WorkerState.ERROR:
self.show_error(str(event.worker.error))
const Dashboard = () => {
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// useEffect 中的异步操作 - 不阻塞渲染
(async () => {
const result = await fetchData();
setData(result);
setLoading(false);
})();
}, []);
if (loading) return <Text>加载中...</Text>;
return <DataView data={data} />;
};
// 使用 Application.MainLoop.Invoke 进行线程安全的 UI 更新
Task.Run(async () => {
var data = await FetchDataAsync();
Application.MainLoop.Invoke(() => {
listView.SetSource(data); // 在主线程上更新 UI
});
});
# Textual - 使用语义化部件
from textual.widgets import Button, Label
# 错误 - 仅视觉
yield Static("[bold red]错误![/]")
# 正确 - 语义化 + 视觉
yield Label("错误:文件未找到", id="error", classes="error")
| 反模式 | 问题 | 修复方案 |
|---|---|---|
| 阻塞主线程 | UI 冻结 | 使用 workers/异步 |
| 手动清屏 | 闪烁 | 使用框架的渲染 |
| 全局状态突变 | 竞态条件 | 使用响应式状态 |
| 未处理调整大小 | 布局损坏 | 使用小终端测试 |
| 硬编码尺寸 | 不可移植 | 使用相对尺寸(Dim.Fill、百分比) |
| 无键盘快捷键 | 依赖鼠标 | 添加 BINDINGS/useInput |
| 在渲染中轮询 | CPU 空转 | 使用计时器、事件 |
from textual.testing import AppTest
async def test_dashboard():
async with AppTest(DashboardApp()) as app:
# 等待挂载
await app.wait_for_loaded()
# 检查初始状态
table = app.query_one("#table", DataTable)
assert table.row_count > 0
# 模拟按键
await app.press("down")
await app.press("enter")
# 检查结果
detail = app.query_one("#detail", Static)
assert "selected" in detail.render()
my_tui/
├── app.py # 主 App 类
├── screens/ # 全屏视图
│ ├── main.py
│ └── detail.py
├── widgets/ # 可复用组件
│ ├── sidebar.py
│ └── status_bar.py
├── state/ # 状态管理
│ └── store.py
├── api/ # 后端通信
│ └── client.py
├── styles.css # Textual CSS(如果使用)
└── tests/
└── test_app.py
每周安装量
63
仓库
GitHub 星标数
2
首次出现
2026年1月24日
安全审计
安装于
gemini-cli53
opencode53
codex52
claude-code51
github-copilot49
cursor44
TUIs are reactive terminal interfaces. Unlike CLIs (single operation → exit), TUIs maintain state, handle events, and update displays continuously. Think of them as web apps for the terminal.
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 is right when: Dashboard monitoring, file browsers, log viewers, interactive data exploration, multi-step wizards with navigation CLI is better when: Single operation, piping output, scripting, simple progress display
| Language | Full TUI Framework | Simple Interactive |
|---|---|---|
| Python | textual (modern, reactive) | rich (tables, progress, prompts) |
| TypeScript | ink (React-like) or blessed | inquirer (prompts only) |
| C# | Terminal.Gui (full widgets) | Spectre.Console (tables, prompts) |
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 ←─────────────────┘ │
└─────────────────────────────────────────────────────────┘
All modern TUI frameworks use this reactive pattern:
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()
Workers for async operations:
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()
Custom widgets:
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))
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 />);
Reactive updates:
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>;
};
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();
}
}
Handle terminal resize gracefully:
# 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"];
}
Rules:
# 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
| Key | Action |
|---|---|
↑/↓ or j/k | Navigate items |
Enter | Select/confirm |
Escape | Cancel/back |
q | Quit |
? | Help |
/ |
# Textual
class MyApp(App):
def action_next_panel(self) -> None:
self.screen.focus_next()
def action_prev_panel(self) -> None:
self.screen.focus_previous()
Critical rule: Never block the main thread. TUIs freeze if you make synchronous network/file calls.
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))
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} />;
};
// 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")
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Blocking main thread | UI freezes | Use workers/async |
| Manual screen clear | Flicker | Use framework's render |
| Global state mutations | Race conditions | Use reactive state |
| Not handling resize | Broken layout | Test with small terminals |
| Hardcoded dimensions | Not portable | Use relative sizing (Dim.Fill, percentages) |
| No keyboard shortcuts | Mouse-dependent | Add BINDINGS/useInput |
| Polling in render | CPU spin | Use timers, events |
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()
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
Weekly Installs
63
Repository
GitHub Stars
2
First Seen
Jan 24, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
gemini-cli53
opencode53
codex52
claude-code51
github-copilot49
cursor44
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
123,700 周安装
| Search |
Tab | Next panel |