解密openclaw底层pi-mono架构系列一:3.pi-tui

解密openclaw底层pi-mono架构系列一:3.pi-tui

不谈玄学,只讲落地。

我是一名深耕算法工程化一线的实践者,擅长将 新技术、关键技术、AI/ML 技术从论文和 demo 转化为可规模化部署的生产系统。在这里,你看不到堆砌公式的理论空谈,只有真实项目中踩过的坑、趟过的路,每一篇文章都源自实战经验的提炼。我相信技术的价值在于解决真实问题,而不是制造焦虑。如果你也厌倦了"收藏即学会",渴望掌握让算法真正跑起来的硬核能力,那么这里就是你的技术补给站。

pi-tui:终端也能画出漂亮 UI ------ 终端界面框架详解

前两篇我们介绍了 pi-ai(架构)和 pi-agent-core(智能体运行时)。AI 能力有了,但用户怎么跟它交互?本篇介绍 pi-tui------一个专为终端打造的 UI 框架,让你在黑底白字的终端里也能构建不闪烁、可交互、有组件化架构的漂亮界面。


目录

  1. [为什么需要终端 UI 框架](#为什么需要终端 UI 框架)
  2. [pi-tui 核心架构](#pi-tui 核心架构)
  3. 差分渲染:不闪烁的秘密
  4. [5 分钟上手:Hello TUI](#5 分钟上手:Hello TUI)
  5. 组件系统:像搭积木一样构建界面
  6. 内置组件一览
  7. [实战:用 SelectList 构建交互菜单](#实战:用 SelectList 构建交互菜单)
  8. 中文支持:宽字符处理
  9. 总结与下一步

1. 为什么需要终端 UI 框架

直接用 console.log 不行吗?

简单场景当然可以。但当你要构建像 Claude Code 这样的交互式终端应用 时,console.log 面临三个致命问题:

问题 表现 影响
闪烁 清屏重绘时屏幕闪白 用户体验极差
无组件化 每次手动拼字符串 维护困难,代码混乱
无交互 键盘事件需要手动处理 raw mode 容易出 bug

pi-tui 的定位:给终端应用提供类似前端 React 的开发体验------组件化 + 声明式渲染 + 事件处理。

类比理解

复制代码
console.log  →  手工画海报(每次推倒重来)
pi-tui       →  PPT 软件(改哪里刷哪里,组件可复用)

2. pi-tui 核心架构

架构图

三层架构

复制代码
┌─────────────────────────────────────┐
│          应用层 (你的代码)            │
│   HelloComponent / SelectList / ...  │
├─────────────────────────────────────┤
│          TUI 引擎层                  │
│   组件树管理 │ 差分渲染 │ 事件分发    │
├─────────────────────────────────────┤
│          终端抽象层                   │
│   ProcessTerminal │ VirtualTerminal  │
└─────────────────────────────────────┘

核心设计思想

  • 终端抽象层把真实终端和测试用虚拟终端统一,组件不关心运行环境
  • TUI 引擎层负责差分渲染和键盘事件分发,组件只需关注"画什么"
  • 应用层 通过实现 Component 接口或使用内置组件来构建界面

源码目录结构

复制代码
packages/tui/src/
├── terminal/
│   ├── terminal.ts          # 终端抽象接口
│   ├── process-terminal.ts  # 真实终端(Node.js stdout)
│   └── virtual-terminal.ts  # 虚拟终端(测试用)
│
├── components/
│   ├── text.ts              # 纯文本
│   ├── input.ts             # 输入框
│   ├── select-list.ts       # 可选择列表
│   ├── markdown.ts          # Markdown 渲染
│   ├── container.ts         # 滚动容器
│   ├── loader.ts            # 加载动画
│   ├── image.ts             # 图片渲染
│   └── ...
│
├── overlay/                 # 悬浮弹窗
├── autocomplete/            # 自动补全
└── index.ts                 # 公共导出

3. 差分渲染:不闪烁的秘密

这是 pi-tui 最核心的技术点。

传统方式 vs 差分渲染

传统方式(清屏重绘):

复制代码
每次更新:
  1. 清除整个屏幕     ← 闪烁!用户看到一瞬间空白
  2. 重新绘制所有内容  ← 大量无用 IO
  3. 重复...

pi-tui 差分渲染

复制代码
每次更新:
  1. 组件的 render() 生成新的行数组
  2. 引擎逐行对比新旧内容
  3. 只有变化的行才移动光标并写入  ← 最小化 IO
  4. 结果:无闪烁,丝滑更新

直观例子

5 行中只需写入 3 行,IO 量减少 40%。界面越大、变化越少,收益越明显。


4. 5 分钟上手:Hello TUI

对应 examples/03-pi-tui/01-hello-tui.ts

完整代码

typescript 复制代码
import { TUI, ProcessTerminal, visibleWidth, truncateToWidth, type Component } from "@mariozechner/pi-tui";

// ① 实现 Component 接口
class HelloComponent implements Component {
  private count = 0;
  private startTime = Date.now();

  // render(width) 返回每一行的字符串数组
  render(width: number): string[] {
    const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
    const innerWidth = width - 2; // 去掉左右边框

    function boxLine(content: string): string {
      const truncated = truncateToWidth(content, innerWidth);
      const vw = visibleWidth(truncated);
      const pad = Math.max(0, innerWidth - vw);
      return `│${truncated}${" ".repeat(pad)}│`;
    }

    const sep = "─".repeat(Math.max(0, width - 2));
    return [
      `┌${sep}┐`,
      boxLine("  Hello, pi-tui!"),
      boxLine(`  运行时间: ${elapsed}s`),
      boxLine(`  刷新次数: ${this.count}`),
      `└${sep}┘`,
      "",
      "  操作: [Q / Ctrl+C] 退出  [R] 重置计数",
    ];
  }

  handleInput(data: string): void {
    if (data === "r" || data === "R") {
      this.count = 0;
      this.startTime = Date.now();
    }
  }

  invalidate(): void {}

  tick(): void { this.count++; }
}

// ② 创建终端和 TUI
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
const hello = new HelloComponent();
tui.addChild(hello);
tui.setFocus(hello);

// ③ 启动
tui.start();

// ④ 退出键
tui.addInputListener((data: string) => {
  if (data === "q" || data === "Q" || data === "\x03") {
    clearInterval(timer);
    tui.stop();
    terminal.stop();
    process.exit(0);
  }
  return undefined;
});

// ⑤ 定时刷新(200ms)
const timer = setInterval(() => {
  hello.tick();
  tui.requestRender();
}, 200);

运行效果

关键步骤拆解

步骤 代码 作用
① 实现 Component class HelloComponent implements Component 定义"画什么"和"怎么响应输入"
② 组装 TUI tui.addChild(hello) 把组件挂到 TUI 组件树上
③ 启动 tui.start() 进入 raw mode,开始渲染循环
④ 退出键 tui.addInputListener(...) 全局键盘监听,按 Q 退出
⑤ 定时刷新 setInterval + requestRender 触发重渲染,差分引擎自动处理

Component 接口三要素

typescript 复制代码
interface Component {
  render(width: number): string[];  // 画什么?返回行数组
  handleInput(data: string): void;  // 怎么响应键盘?
  invalidate(): void;               // 通知需要重绘
}

这就是 pi-tui 的"最小契约"。实现这三个方法,你的类就是一个可渲染、可交互的终端组件。


5. 组件系统:像搭积木一样构建界面

pi-tui 的组件系统遵循组合优于继承的原则。

组件组合模型

复制代码
TUI(根节点)
 └── Container(垂直排列)
      ├── Text("标题")
      ├── SelectList([选项1, 选项2, ...])
      ├── Text("")          ← 空行做间距
      └── Text("状态信息")

通过 Container 容器将多个组件垂直排列 ,就像 HTML 里的 <div> 一样自然。

焦点管理

同一时刻只有一个组件接收键盘输入:

typescript 复制代码
tui.setFocus(list);  // 方向键和回车都给 list 处理

未获得焦点的组件只负责渲染,不响应按键。


6. 内置组件一览

组件 用途 关键 API
Text 显示纯文本 new Text(text, paddingX, paddingY) / setText()
SelectList 可上下选择的列表 new SelectList(items, maxVisible, theme) / onSelect
Container 垂直排列子组件 addChild()
Input 单行输入框 onSubmit / onChange
Editor 多行编辑器 代码编辑场景
Markdown Markdown 渲染 自动语法高亮
Loader 加载动画 start() / stop()
Box 带边框容器 title / children
Image 终端图片渲染 Kitty / iTerm2 协议
Overlay 悬浮弹窗 模型选择等弹出场景

7. 实战:用 SelectList 构建交互菜单

对应 examples/03-pi-tui/02-list-component.ts

这个示例展示如何用内置组件快速搭建一个 AI Provider 选择菜单。

完整代码

typescript 复制代码
import {
  TUI, ProcessTerminal, SelectList, Text, Container,
  type SelectItem, type SelectListTheme,
} from "@mariozechner/pi-tui";

// ① 列表数据
const items: SelectItem[] = [
  { label: "Anthropic Claude",  value: "anthropic",  description: "Claude Haiku / Sonnet / Opus" },
  { label: "Google Gemini",     value: "google",     description: "Gemini 2.0 Flash / Pro" },
  { label: "OpenAI GPT",        value: "openai",     description: "GPT-4o / GPT-4o Mini" },
  { label: "MiniMax",           value: "minimax-cn", description: "MiniMax-M2 / M2.5" },
  { label: "DeepSeek",          value: "deepseek",   description: "DeepSeek-V3 / R1" },
];

// ② 主题(控制颜色样式)
const listTheme: SelectListTheme = {
  selectedPrefix: (t) => t,
  selectedText:   (t) => `\x1b[32m${t}\x1b[0m`,  // 绿色
  description:    (t) => `\x1b[90m${t}\x1b[0m`,  // 灰色
  scrollInfo:     (t) => `\x1b[90m${t}\x1b[0m`,
  noMatch:        (t) => `\x1b[31m${t}\x1b[0m`,  // 红色
};

// ③ 组合组件
const title  = new Text("  选择你的 AI Provider:", 0, 0);
const status = new Text("  尚未选择,使用 ↑↓ 选择,Enter 确认,Q 退出", 0, 0);
const list   = new SelectList(items, 8, listTheme);

const container = new Container();
container.addChild(title);
container.addChild(list);
container.addChild(new Text("", 0, 0));  // 空行
container.addChild(status);

// ④ 启动 TUI
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
tui.addChild(container);
tui.setFocus(list);   // 焦点给列表,接收方向键
tui.start();

// ⑤ 选择事件
list.onSelect = (item: SelectItem) => {
  status.setText(`  已选择: ${item.label} (${item.value})  --- 1 秒后退出`);
  tui.requestRender();

  setTimeout(() => {
    tui.stop();
    terminal.stop();
    console.log(`\n你选择了: ${item.label} (${item.value})`);
    process.exit(0);
  }, 1000);
};

// ⑥ 全局退出键
tui.addInputListener((data: string) => {
  if (data === "q" || data === "Q" || data === "\x03") {
    tui.stop();
    terminal.stop();
    process.exit(0);
  }
  return undefined;
});

运行效果

自定义组件 vs 内置组件对比

对比项 01-hello-tui(自定义组件) 02-list-component(内置组件)
组件来源 自己实现 Component 接口 直接使用 SelectList / Text
代码量 ~70 行(含 render 逻辑) ~50 行(声明式组合)
键盘处理 手动在 handleInput 中判断 SelectList 内置方向键和回车
适用场景 完全定制化需求 标准交互模式

经验法则:能用内置组件就用内置组件,需要独特渲染逻辑时再自定义。


8. 中文支持:宽字符处理

终端中一个中文字符占 2 列宽度,而英文/数字只占 1 列。如果不处理这个差异,界面会对不齐。

pi-tui 提供两个关键工具函数:

typescript 复制代码
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";

visibleWidth("Hello");      // → 5(英文 5 字符 = 5 列)
visibleWidth("你好");        // → 4(中文 2 字符 = 4 列)
visibleWidth("Hi你好");      // → 6(2 + 4 = 6 列)

// 安全截断,不会切断一半汉字
truncateToWidth("你好世界", 5);  // → "你好"(4 列,5 列放不下"世")

在 render 中使用

typescript 复制代码
render(width: number): string[] {
  const innerWidth = width - 2;  // 减去边框

  function boxLine(content: string): string {
    const truncated = truncateToWidth(content, innerWidth);  // 安全截断
    const vw = visibleWidth(truncated);                       // 实际宽度
    const pad = Math.max(0, innerWidth - vw);                 // 补空格
    return `│${truncated}${" ".repeat(pad)}│`;                // 对齐边框
  }

  return [boxLine("  运行时间: 3.2s"), boxLine("  你好世界!")];
}

规则render(width) 生成的每一行,其 visibleWidth 必须 <= width,否则终端会自动换行导致布局混乱。


9. 总结与下一步

pi-tui 的核心价值

能力 说明
差分渲染 只更新变化的行,终端 UI 不再闪烁
组件化 Text / SelectList / Input 等开箱即用
组合式布局 Container 垂直排列,像搭积木
键盘交互 焦点管理 + 全局监听,处理优雅
宽字符安全 visibleWidth / truncateToWidth 解决中文对齐
可测试 VirtualTerminal 让 TUI 可以无头测试

一句话记住

console.log 是往终端"倒文字"。
pi-tui 是在终端里"画界面"------有组件、有布局、有交互,而且不闪。

下一步

有了 pi-ai(LLM 调用)+ pi-agent-core(智能体运行时)+ pi-tui(终端 UI),下一篇我们将把它们组合起来,看看 pi-coding-agent 如何构建一个完整的交互式终端编程助手

相关推荐
幂律智能1 小时前
Agent × 流程引擎融合架构:从静态流程到智能流程编排
人工智能·架构·agent
Python小老六1 小时前
冯诺依曼架构 vs 哈佛架构 对比
stm32·单片机·嵌入式硬件·架构
xiaodaidai丶2 小时前
解决Sa-Token在 Spring MVC + WebFlux 混合架构中流式接口报错SaTokenContext 上下文尚未初始化的问题
spring·架构·mvc
wuchen10042 小时前
网狐的定时器引擎架构理解
架构·定时器·网狐
@PHARAOH3 小时前
HOW - Moleculer 微服务构建分布式服务系统
微服务·云原生·架构
KKKlucifer3 小时前
零信任架构下的安全服务:动态防御与持续合规双驱动
安全·架构
偷吃的耗子3 小时前
大数据报表系统技术方案与业务方案设计
大数据·架构
Nile4 小时前
解密openclaw底层pi-mono架构系列一:1.从架构到实战
架构
霖霖总总4 小时前
[Redis小技巧5]Redis Sorted Set 深度解析:从跳表原理到亿级排行榜架构
redis·架构