# 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[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[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[A`。 2. **组件化**:用 React 组件描述 UI(``、``),天然支持嵌套布局。 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 的 + overflow) Editor.tsx — 输入框(ink-textarea 或自写) StatusBar.tsx — 底部状态栏 ToolCard.tsx — 工具调用卡片 UserMessage.tsx — 用户消息块 AssistantMessage.tsx — 助手消息块 Modal/ — 模态对话框 hooks/ useStream.ts — WS 流式事件 → 状态 useSession.ts — 会话管理 ``` #### 滚动方案 Ink 提供两种方式处理历史内容: 1. **`` 组件**:内容一次性写入终端的 scrollback buffer(退出后仍可滚动查看),适合已完成的消息。 2. **应用内滚动**:在 `` 内管理滚动偏移,类似 viewport。 推荐混合方案: - 已完成的消息 → `` 写入 scrollback(用户可终端原生滚动) - 当前进行中的回合 → 在 `` 中动态渲染 - 底部固定编辑器 + 状态栏 这和 Claude Code 的体验完全一致:历史对话在 scrollback 里,底部输入框固定。 ### 方案 B:修复现有 ANSI 操控(不推荐,短期补丁) 如果不想引入框架,需要做以下修改: 1. **完全移除 scroll region**(`\x1b[r`):不设置滚动区域,让所有内容自然流式输出到主屏。 2. **放弃固定顶栏**:顶栏信息(session banner)只在启动时打印一次,进入 scrollback。 3. **底栏用 ANSI 定位而非 scroll region**:每次更新底栏时用 `\x1b[s`(保存光标)→ `\x1b[;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 操控脆弱问题 - `` 组件让历史对话进入终端原生 scrollback,实现 Claude Code 级体验 - React 组件化使 UI 代码可维护性大幅提升 - ink-textarea 等现成组件省去自造输入框 迁移工作量集中在 `ReplRenderer` → Ink 组件的映射,WS 事件处理逻辑(`SessionRunner`、`WsClient`、`event-filter`)可完全复用。 ### 与 Claude Code 的对比 Claude Code(Anthropic 的 CLI 工具)使用类似架构: - 全屏重绘(疑似自研或 Ink 变体) - 历史内容写入 scrollback,底部固定输入框 - 流式输出时底部状态栏持续可见 - 回合结束后 Markdown 整段着色 Ink + `` 方案可以完全复现这个体验。