docs/research/opencode-tui-research.md
Rama main · réplica de lectura del repositorio original.
# OpenCode TUI 实现研究文档
> 研究对象:[opencode-ai/opencode](https://github.com/opencode-ai/opencode)(Go)
> 目标:理解其终端交互架构,对比 mao-agent 现状,提出改进方案。
---
## 1. 核心架构:Bubble Tea + 全屏重绘模型
OpenCode 使用 Charm 家族的 **Bubble Tea**(Elm Architecture)作为 TUI 框架。核心思想是:
```
Model (状态) → Update(Msg) → (新Model, Cmd) → View() → 整屏字符串 → Bubble Tea 渲染
```
### 1.1 全屏重绘,而非逐行操控
Bubble Tea 的渲染机制是:**每次 `View()` 返回整个屏幕的字符串**,Bubble Tea 引擎负责 diff 并输出最小 ANSI 变化序列。开发者不需要手写 `\x1b[K`、`\r`、光标定位等控制序列。
```go
// tui.go - View() 返回完整画面
func (a appModel) View() string {
components := []string{
a.pages[a.currentPage].View(), // 聊天页(含消息列表 + 编辑器)
a.status.View(), // 底部状态栏
}
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
// 叠加 overlay(权限对话框、帮助、退出确认等)
if a.showPermissions {
overlay := a.permissions.View()
appView = layout.PlaceOverlay(col, row, overlay, appView, true)
}
return appView
}
```
### 1.2 Alt Screen + 虚拟滚动 = 解决 scrollback 问题
```go
// cmd/root.go
program := tea.NewProgram(
tui.New(app),
tea.WithAltScreen(), // 进入备用屏
)
```
**关键点**:OpenCode **确实使用了 alt screen**,但它不依赖终端的 scrollback buffer 来保存历史。它使用 `bubbles/viewport` 组件实现**应用内虚拟滚动**:
- 所有消息渲染为一个大字符串,设置到 `viewport.SetContent()`
- `viewport` 管理可见区域和滚动偏移
- PageUp/PageDown/Ctrl+U/Ctrl+D 在应用内滚动,而非依赖终端 scrollback
```go
// list.go
type messagesCmp struct {
viewport viewport.Model // 虚拟滚动组件
messages []message.Message
// ...
}
// 消息更新时重新渲染内容到 viewport
func (m *messagesCmp) renderView() {
// 将所有消息渲染为一个长字符串
messages := make([]string, 0)
for _, v := range m.uiMessages {
messages = append(messages, v.content, "")
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Top, messages...)
)
}
func (m *messagesCmp) View() string {
return lipgloss.JoinVertical(lipgloss.Top,
m.viewport.View(), // 只显示 viewport 可见部分
m.working(), // "Generating..." 状态
m.help(), // 按键提示
)
}
```
### 1.3 布局系统:SplitPane + Container
```go
// page/chat.go - NewChatPage
layout.NewSplitPane(
layout.WithLeftPanel(messagesContainer), // 消息列表(viewport)
layout.WithBottomPanel(editorContainer), // 底部输入框
)
```
- **SplitPaneLayout**:支持上下/左右分割,按比例分配空间
- **Container**:包裹组件,提供 padding/border/背景色
- 底部编辑器固定高度(verticalRatio=0.9,底部占 10%),上方消息区占 90%
- 窗口大小变化时通过 `SetSize()` 级联传递
### 1.4 编辑器:bubbles/textarea
```go
// editor.go
ta := textarea.New()
ta.ShowLineNumbers = false
ta.CharLimit = -1
```
- 使用 `bubbles/textarea` 组件,支持多行编辑、光标移动
- Enter 发送(`\` 续行),Ctrl+E 打开外部编辑器
- 编辑器始终聚焦,消息列表的滚动键被拦截不传递给 textarea
### 1.5 消息渲染
- **Markdown**:使用 `charmbracelet/glamour` 渲染 Markdown(代码高亮、列表、标题等)
- **用户消息**:左边框(thick border)+ Secondary 色标识
- **助手消息**:左边框 + Primary 色标识,底部显示模型名和耗时
- **工具调用**:左边框 + TextMuted 色,显示工具名+参数+结果(结果截断到 maxResultHeight=10 行)
- **缓存**:按消息 ID 和宽度缓存渲染结果,避免重复渲染
### 1.6 状态栏
```go
// status.go - View()
func (m statusCmp) View() string {
status := getHelpWidget() // "ctrl+? help" 徽章
status += tokensStyle.Render(tokens) // Context: 1.2K, Cost: $0.05
status += infoStyle.Render(msg) // 临时信息/警告/错误
status += diagnostics // LSP 诊断
status += m.model() // 当前模型名
return status
}
```
底栏是单行高度,水平排列多个信息块,整个 appModel 的 `View()` 用 `lipgloss.JoinVertical` 把页面内容和状态栏垂直拼接。
### 1.7 Overlay/模态对话框
通过 `layout.PlaceOverlay()` 在主画面上叠加内容(居中),不破坏底层布局。用于权限请求、会话切换、命令面板、帮助等。
---
## 2. 与 mao-agent 的对比
| 维度 | OpenCode (Go) | mao-agent (Node.js) |
|------|---------------|---------------------|
| **渲染模型** | 全屏重绘(Bubble Tea diff) | 逐行 ANSI 操控(手写 `\r`/`\x1b[K`/`\x1b[<n>A`) |
| **滚动** | 应用内 viewport 虚拟滚动 | 依赖终端 scroll region + 原生 scrollback(失败) |
| **布局** | SplitPane 比例分割 + Container | 手写 scroll region (`\x1b[r`) + 固定行定位 |
| **输入框** | bubbles/textarea(完整多行编辑) | 自写 InputController(单行拼接,功能有限) |
| **Markdown** | glamour(完整渲染) | renderMarkdownLite(简化版) |
| **状态管理** | Elm Architecture(Model-Update-View) | 命令式直接写 stdout/stderr |
| **alt screen** | 使用 + viewport 虚拟滚动 | 之前使用(无 viewport,内容丢失),已去掉但问题仍在 |
| **工具输出** | 左边框 + 参数摘要 + 结果截断 | box.ts 格式化,类似但功能较弱 |
| **模态** | PlaceOverlay 叠加 | 模态拦截按键,但无 overlay 视觉效果 |
### 2.1 mao-agent 的根本问题
mao-agent 之前的实现尝试用 **ANSI scroll region**(`\x1b[top;bottom r`)模拟"固定顶栏 + 滚动中间区 + 固定底栏"的布局。这个方案有根本缺陷:
1. **Scroll region 不扩展终端 scrollback**:终端的 scrollback buffer 只记录超出屏幕顶部的原始行。当 scroll region 激活时,光标被限制在中间区域,内容在区域内滚动,超出区域顶部的内容**不会进入 scrollback**,而是被丢弃。
2. **去掉 alt screen 后仍然不行**:因为 scroll region 仍然在拦截"溢出"的行。终端原生 scrollback 只对主屏的普通滚动有效,scroll region 模式下的内容不会正常推入 scrollback。
3. **手动 ANSI 操控脆弱**:`flushAssistantMarkdown` 用 `\x1b[<n>A` 回退行数重写已输出内容,容易在行数计算错误时破坏画面;流式输出和状态栏/输入框的刷新互相干扰。
---
## 3. 改进方案
### 方案 A:引入 Node.js TUI 框架(推荐)
参考 OpenCode 的架构,在 Node.js 生态中选择类似 Bubble Tea 的全屏重绘框架:
#### 候选框架
| 框架 | 说明 | 适合度 |
|------|------|--------|
| **Ink** (npm: ink) | React for CLI,全屏重绘模型,VDOM diff → ANSI | ★★★★★ |
| **blessed** | 终端 UI 框架,内置 widget | ★★★☆☆ |
| **neo-blessed** | blessed 的现代 fork | ★★★☆☆ |
**推荐 Ink**,原因:
1. **全屏重绘模型**:和 Bubble Tea 一样,`render()` 返回整个画面,Ink 负责 diff 和 ANSI 输出。不再需要手写 `\r`、`\x1b[K`、`\x1b[<n>A`。
2. **组件化**:用 React 组件描述 UI(`<Box>`、`<Text>`),天然支持嵌套布局。
3. **measured-layout**:Ink 内部用 Yoga 布局引擎做 flexbox 布局,自动处理宽度/高度分配,类似 lipgloss 的 JoinVertical/JoinHorizontal。
4. **focus 管理**:ink-text-input / ink-textarea 提供成熟的输入组件。
5. **广验证**:Vercel CLI、Cloudflare Wrangler、Gatsby 等大型 CLI 工具使用 Ink。
6. **useInput 钩子**:处理键盘事件,支持自定义快捷键和模态拦截。
#### 架构重构
```
agent-cli/src/
tui/
App.tsx — 根组件,管理页面和模态
ChatView.tsx — 聊天页:消息列表 + 编辑器
MessageList.tsx — 消息虚拟滚动(用 ink 的 <Static> + <Box> overflow)
Editor.tsx — 输入框(ink-textarea 或自写)
StatusBar.tsx — 底部状态栏
ToolCard.tsx — 工具调用卡片
UserMessage.tsx — 用户消息块
AssistantMessage.tsx — 助手消息块
Modal/ — 模态对话框
hooks/
useStream.ts — WS 流式事件 → 状态
useSession.ts — 会话管理
```
#### 滚动方案
Ink 提供两种方式处理历史内容:
1. **`<Static>` 组件**:内容一次性写入终端的 scrollback buffer(退出后仍可滚动查看),适合已完成的消息。
2. **应用内滚动**:在 `<Box height={N}>` 内管理滚动偏移,类似 viewport。
推荐混合方案:
- 已完成的消息 → `<Static>` 写入 scrollback(用户可终端原生滚动)
- 当前进行中的回合 → 在 `<Box>` 中动态渲染
- 底部固定编辑器 + 状态栏
这和 Claude Code 的体验完全一致:历史对话在 scrollback 里,底部输入框固定。
### 方案 B:修复现有 ANSI 操控(不推荐,短期补丁)
如果不想引入框架,需要做以下修改:
1. **完全移除 scroll region**(`\x1b[r`):不设置滚动区域,让所有内容自然流式输出到主屏。
2. **放弃固定顶栏**:顶栏信息(session banner)只在启动时打印一次,进入 scrollback。
3. **底栏用 ANSI 定位而非 scroll region**:每次更新底栏时用 `\x1b[s`(保存光标)→ `\x1b[<row>;1H`(移到底行)→ 写入 → `\x1b[u`(恢复光标)。
4. **流式输出直接写 stdout**:不做 rewind 重渲染。
5. **Markdown 延迟渲染**:回合结束时一次性渲染,而非中途 rewind。
**问题**:这个方案本质上是退回到"简单流式输出"模式,无法实现 Claude Code 那样的"底部固定输入框 + 上方滚动"效果,因为 Node.js 没有类似 bubbles/viewport 的现成组件。
---
## 4. 结论与建议
### OpenCode 的核心思路
OpenCode 之所以能实现 Claude Code 级别的终端交互体验,核心在于:
1. **全屏重绘模型**(Bubble Tea):开发者只描述"画面应该长什么样",框架负责 ANSI diff,消除了手写控制序列的复杂性和脆弱性。
2. **应用内虚拟滚动**(viewport):不依赖终端 scrollback,所有历史消息在 viewport 中管理,PageUp/PageDown 在应用内滚动。
3. **组件化布局**(SplitPane + Container + lipgloss):声明式描述布局结构,自动处理尺寸分配。
4. **成熟组件**(textarea、spinner、viewport):不需要自造轮子。
### mao-agent 的改进路径
**强烈推荐方案 A(Ink)**:
- Ink 的全屏重绘模型彻底解决 ANSI 操控脆弱问题
- `<Static>` 组件让历史对话进入终端原生 scrollback,实现 Claude Code 级体验
- React 组件化使 UI 代码可维护性大幅提升
- ink-textarea 等现成组件省去自造输入框
迁移工作量集中在 `ReplRenderer` → Ink 组件的映射,WS 事件处理逻辑(`SessionRunner`、`WsClient`、`event-filter`)可完全复用。
### 与 Claude Code 的对比
Claude Code(Anthropic 的 CLI 工具)使用类似架构:
- 全屏重绘(疑似自研或 Ink 变体)
- 历史内容写入 scrollback,底部固定输入框
- 流式输出时底部状态栏持续可见
- 回合结束后 Markdown 整段着色
Ink + `<Static>` 方案可以完全复现这个体验。