主题
OpenClaw 源码解读(二十)TUI 终端交互界面
一、导读
TUI(Terminal User Interface)是 OpenClaw 的终端全功能客户端——一个运行在命令行中的交互式聊天界面,使用 @mariozechner/pi-tui(Ink 框架衍生)构建,支持实时流式输出、斜杠命令、模型/Agent/Session 选择器、工具调用实时展示等。
通过 openclaw tui 启动,它通过 WebSocket 连接 Gateway,提供接近 Web UI 的交互体验:
┌─────────────────────────────────────────────┐
│ 🤖 openclaw main › claude-sonnet-4-2025... │ ← 状态栏
├─────────────────────────────────────────────┤
│ [user] Hello, what can you do? │
│ [assistant] I can help you with... │ ← 聊天日志
│ ▸ tool: web_search("latest news") │ ← 工具调用
│ [assistant] Here are the latest news... │
├─────────────────────────────────────────────┤
│ > /model ▍ │ ← 输入框(支持斜杠命令补全)
└─────────────────────────────────────────────┘源码位于 src/tui/,约 40 个文件,~4000 行代码。
TUI 终端交互界面/01-infographic-tui-architecture-overview-1775150681188.png)
二、入口 —— tui.ts
startTui() 是 TUI 的主入口函数(~500 行),它的启动流程可以分为 7 个阶段:
阶段 1:配置加载
resolvedGatewayUrl ← 配置文件 / 命令行参数 / 默认值
sessionKey ← 参数指定 / Gateway sessions.resolve
agentId ← 参数指定 / 默认 "main"阶段 2:Gateway 连接
client = createGatewayChatClient({
url, sessionKey, password, token, deliver
})
await client.connect() // WebSocket 握手GatewayChatClient 封装了 WebSocket 连接、JSON-RPC 请求/响应、事件监听。
阶段 3:TUI 框架初始化
const tui = createTUI({
input: process.stdin,
output: process.stdout,
slashCommands: getSlashCommands({ cfg, provider, model })
})pi-tui 框架接管 stdin/stdout,提供组件渲染、键盘事件、光标控制。
阶段 4:组件树构建
chatLog ← ChatLog 组件(消息列表 + 流式更新)
statusLine ← 状态栏(连接/Agent/模型/Session 信息)
inputHandler ← 输入处理器(消息发送 + 命令分发)
overlay ← 叠层(模型选择器/Agent 选择器/设置面板)阶段 5:事件处理器注册
commandHandlers ← 20+ 斜杠命令处理
eventHandlers ← Gateway 事件路由(chat/agent/presence)阶段 6:历史加载 + 会话信息
loadHistory() ← 加载聊天历史
refreshSessionInfo() ← 获取当前会话的 Agent/模型/状态阶段 7:主循环
tui.start() // 进入渲染循环
await exitPromise // 等待退出信号TUI 终端交互界面/02-infographic-startup-7-stages-1775150682110.png)
三、GatewayChatClient —— WebSocket 通信
gateway-chat.ts 中的 GatewayChatClient 是 TUI 与 Gateway 之间的通信桥梁:
3.1 核心 RPC 方法
| 方法 | Gateway RPC | 用途 |
|---|---|---|
sendMessage() | chat.send | 发送聊天消息 |
abortRun() | chat.abort | 中止当前 AI 运行 |
loadHistory() | chat.history | 加载聊天历史 |
patchSession() | sessions.patch | 修改会话(模型/标签) |
listSessions() | sessions.list | 列出所有会话 |
listModels() | models.list | 列出可用模型 |
getStatus() | status | 获取 Gateway 状态 |
listAgents() | agents.list | 列出所有 Agent |
3.2 自动重连
与 Web UI 的 WebSocket 客户端类似,TUI 也实现了指数退避重连:
断连 → delay(1s) → 重连 → 成功: 重置延迟
→ 失败: delay *= 1.5, max 15s断连时状态栏自动显示 disconnected,重连后自动恢复 session 状态。
TUI 终端交互界面/03-infographic-gateway-websocket-1775150683094.png)
四、流式文本组装 —— TuiStreamAssembler
tui-stream-assembler.ts 是 TUI 的"文本流装配工厂"——它将 Gateway 推送的增量事件装配成完整的显示文本。
4.1 增量处理
Gateway 的 chat.delta 事件携带的是累积全文(不是增量)。TuiStreamAssembler 负责:
typescript
ingestDelta(runId, message, showThinking): string | null {
// 1. 从 message 中提取 fullText
// 2. 可选提取 thinking text(推理过程)
// 3. 根据 showThinking 决定是否拼接思考文本
// 4. 应用静默令牌过滤(NO_REPLY)
// 5. 返回显示文本(如果和上次相同则返回 null,跳过渲染)
}4.2 Final 处理
typescript
finalize(runId, message, showThinking): string {
// 1. 提取最终完整文本
// 2. 如果是空消息 → 返回 "(no output)"
// 3. 如果包含静默令牌 → 剥离
// 4. 清除该 runId 的状态
}4.3 多 Run 追踪
Assembler 维护 per-runId 的状态,支持同时追踪多个并发运行(比如主 Agent 和 Sub-Agent 同时输出)。
TUI 终端交互界面/04-infographic-stream-assembly-1775150683948.png)
五、事件处理 —— tui-event-handlers.ts
createEventHandlers() 返回两个事件处理函数:
5.1 Chat 事件
delta → assembler.ingestDelta() → chatLog.updateAssistant() → setActivityStatus("streaming")
final → assembler.finalize() → chatLog.finalizeAssistant() → setActivityStatus("idle")
aborted → chatLog.addSystem("run aborted") → terminateRun()
error → chatLog.addSystem(errorMessage) → terminateRun()精巧的防重复设计:
finalizedRunsMap 追踪已完成的 runId(最多 200 条,10 分钟过期)- 对已 finalized 的 run 的 delta/final 事件直接跳过
- Session 切换时清除所有追踪状态
5.2 Agent 事件(工具调用)
tool.start → chatLog.startTool(toolCallId, toolName, args)
tool.result → chatLog.updateToolResult(toolCallId, result)工具调用是否显示受 verbose 级别控制——verbose=off 时静默处理。
5.3 并发 Run 处理
typescript
const hasConcurrentActiveRun = (runId: string) => {
const activeRunId = state.activeChatRunId;
if (!activeRunId || activeRunId === runId) return false;
return sessionRuns.has(activeRunId);
};当检测到并发运行时,只刷新非本地发起的 run 的历史(避免重复加载自己刚发送的消息)。
TUI 终端交互界面/05-infographic-event-handlers-1775150684756.png)
六、命令系统 —— commands.ts + tui-command-handlers.ts
6.1 斜杠命令注册
TUI 注册了 20+ 个本地斜杠命令 + Gateway 的全部远程命令:
| 命令 | 处理位置 | 功能 |
|---|---|---|
/help | 本地 | 显示帮助 |
/status | 本地 → Gateway RPC | 显示状态摘要 |
/model <name> | 本地 → Gateway RPC | 切换模型 |
/models | 本地 | 打开模型选择器 |
/agent <id> | 本地 | 切换 Agent |
/agents | 本地 | 打开 Agent 选择器 |
/session <key> | 本地 | 切换会话 |
/sessions | 本地 | 打开会话选择器 |
/think <level> | 本地 → Gateway RPC | 设置思考级别 |
/verbose <on|off> | 本地 | 工具输出显示 |
/new / /reset | 本地 → Gateway RPC | 重置会话 |
/abort | 本地 → Gateway RPC | 中止运行 |
/settings | 本地 | 打开设置面板 |
/exit / /quit | 本地 | 退出 TUI |
| Gateway 命令 | 远程 | 动态注册,透传到 Gateway |
6.2 命令自动补全
每个命令可以注册 getArgumentCompletions() 回调:
typescript
{ name: "think", getArgumentCompletions: (prefix) =>
thinkLevels.filter(v => v.startsWith(prefix)).map(value => ({ value, label: value }))
}当用户输入 /think h 时,TUI 自动提示 high。
6.3 选择器叠层
模型/Agent/Session 选择都通过叠层(Overlay)实现:
typescript
const openModelSelector = async () => {
const models = await client.listModels();
const selector = createSearchableSelectList(items, 9); // 最多显示 9 行
openSelector(selector, async (value) => {
await client.patchSession({ key: state.currentSessionKey, model: value });
});
};支持两种选择器:
- SearchableSelectList:输入过滤(实时搜索)
- FilterableSelectList:按类别过滤
TUI 终端交互界面/06-infographic-command-system-1775150685882.png)
七、命令处理上下文
CommandHandlerContext 是命令处理器的依赖注入容器:
typescript
type CommandHandlerContext = {
client: GatewayChatClient; // Gateway 通信
chatLog: ChatLog; // 聊天日志组件
tui: TUI; // TUI 框架
opts: TuiOptions; // 启动选项
state: TuiStateAccess; // 共享状态
deliverDefault: boolean; // 是否默认投递到渠道
openOverlay / closeOverlay; // 叠层控制
refreshSessionInfo / loadHistory; // 数据刷新
setSession / refreshAgents; // 导航
abortActive; // 中止运行
noteLocalRunId / forgetLocalRunId; // 本地 run 追踪
requestExit; // 退出请求
};通过上下文注入而非全局状态,让命令处理器可以独立测试。
八、状态管理
TuiStateAccess 定义了 TUI 的共享状态:
| 状态 | 类型 | 说明 |
|---|---|---|
currentSessionKey | string | 当前会话 key |
currentAgentId | string | 当前 Agent ID |
agentDefaultId | string | 默认 Agent ID |
agents | AgentSummary[] | Agent 列表 |
activeChatRunId | string | null | 活跃的 AI 运行 ID |
sessionInfo | SessionInfo | 会话信息(模型/状态/verbose 等) |
toolsExpanded | boolean | 工具输出是否展开 |
showThinking | boolean | 是否显示思考过程 |
connected | boolean | 连接状态 |
状态变更直接触发 tui.requestRender(),按需重绘。
TUI 终端交互界面/07-infographic-context-state-1775150686665.png)
九、设计模式总结
| 模式 | 应用位置 | 效果 |
|---|---|---|
| Context Injection | CommandHandlerContext | 命令处理器解耦 |
| Stream Assembly | TuiStreamAssembler | 累积文本 → 显示文本 |
| Run Tracking | finalizedRuns + sessionRuns | 防止重复处理 + 并发安全 |
| Overlay Stack | openOverlay/closeOverlay | 模态选择器 |
| Auto-Complete | getArgumentCompletions | 斜杠命令智能补全 |
| Dual Source | 本地命令 + Gateway 命令 | 统一命令体验 |
| Pruning Map | pruneRunMap() | 有界内存(200 条上限) |
TUI 终端交互界面/08-infographic-design-patterns-1775150687568.png)
十、推荐阅读顺序
src/tui/commands.ts— 斜杠命令注册 + 解析src/tui/tui-types.ts— 核心类型定义src/tui/gateway-chat.ts— Gateway 通信客户端src/tui/tui-stream-assembler.ts— 流式文本组装src/tui/tui-event-handlers.ts— 事件处理(chat + agent)src/tui/tui-command-handlers.ts— 命令处理器src/tui/components/— UI 组件(chatLog, selectors, statusLine)src/tui/tui.ts— 主入口(7 阶段启动流程)
十一、思考题
TUI 和 Web UI 都是 Gateway 的客户端——它们的代码能否抽象出共享层? 两者的事件处理逻辑(chat delta/final/error)几乎相同。
finalizedRuns和sessionRuns的双 Map 设计是否过度复杂? 它解决了什么具体的并发问题?选择器使用 Overlay 模式而非页面切换——在终端环境中哪种更合适? 考虑终端尺寸限制和用户习惯。
TUI 的命令系统同时注册本地命令和 Gateway 远程命令——如果命名冲突怎么办? 当前的优先级策略是什么?