跳转到内容

TUI 工程 — Ink 终端渲染

TUI 工程 — Ink 终端渲染

学习目标

  • 理解 Ink 的渲染模型:React 组件渲染到固定字符网格,而非浏览器 DOM
  • 掌握 Goose TUI 的高度预算计算与字符截断策略
  • 学会遵循 AGENTS.md 中的 Ink 布局规则
  • 理解 Ink 与 Ratatui 的设计取舍

项目实践

Ink 渲染模型:React 到固定字符网格

Goose 的终端 UI(ui/text/)使用 Ink 框架——将 React 组件渲染到终端的固定字符网格。与 Web React 的本质区别:

终端是一个等宽字符矩阵——宽度是列数,高度是行数。Ink 将 React 的 Flexbox 布局映射到这个字符网格上。关键约束:内容超出容器尺寸时不会被裁剪,而是溢出到相邻单元格,破坏整体布局

这意味着在 Web 中习以为常的 overflow: hidden 在终端中不存在——你必须在渲染前确保内容不会超出容器。

字符预算计算

Goose TUI 的核心技术之一是字符预算——在渲染前精确计算每个区域可以容纳多少字符。

高度预算计算

tui.tsx 中,整个终端被划分为几个区域,每个区域占据固定的行数:

safeTermHeight = 终端总行数(从 stdout.rows 获取,最小 10)
headerH = 2(Header 组件占据 2 行)
inputBarH = 2 + inputContentRows + inputExtraLines + 1(输入栏)
historyBarH = 2(历史记录导航栏,仅在查看历史时显示)
PAD_TOP / PAD_BOTTOM = 0(左右内边距的垂直分量)
viewportHeight = safeTermHeight - PAD_TOP - PAD_BOTTOM - headerH - inputBarH - historyBarH

viewportHeight 是内容视区可用的最大行数。如果内容行数超过这个值,Ink 不会自动裁剪——而是让内容溢出到 Header 或 InputBar 区域,导致边框重叠、文字错位。

宽度预算计算

safeTermWidth = 终端总列数(最小 40)
PAD_X = 2(左右各 2 字符的 padding)
contentWidth = safeTermWidth - PAD_X * 2(内容可用宽度,最小 20)

所有文本渲染都必须以 contentWidth 为上限。

内容预截断策略

由于终端没有 overflow: hidden,Goose 采用预截断策略——在内容传递给 Ink 之前,就将其裁剪到字符预算之内。

文本预截断

InputBar 组件中,粘贴的长文本会被预先截断:

availableWidth = contentWidth - 20(预留右侧滚动提示空间)
flat = 文本去换行、去多余空白
suffix = " ({字符数} chars)"
previewLen = availableWidth - suffix.length - 1
输出 = flat.slice(0, previewLen) + "..." + suffix

用户粘贴 10000 字符的代码时,终端只显示前几个字符加上 "(10,000 chars)" 的统计信息。这避免了文本撑破输入栏。

用户消息预截断

buildContentLines 中,用户发送的消息同样经过预截断:

maxPreview = max(safeWidth - 30, min(SENT_PREVIEW_LEN, safeWidth - 10))
if flat.length <= maxPreview + 10:
完整显示
else:
preview = flat.slice(0, maxPreview) + "..."
remaining = flat.length - maxPreview
输出 = preview + " ({remaining} more chars)"

Viewport 滚动裁剪

Viewport 组件处理内容行数超过 viewportHeight 的情况:

total = 内容总行数
overflows = total > viewportHeight
contentHeight = overflows ? viewportHeight - 2 : viewportHeight(-2 为滚动指示符)
endIdx = clamp(min(contentHeight, total), max(total - scrollOffset), total)
startIdx = max(0, endIdx - contentHeight)
visible = lines.slice(startIdx, endIdx)

超出部分不渲染,而是用滚动指示符(▲ N more (up) / ▼ N more (down))提示用户。

AGENTS.md 中的 Ink 布局规则

Goose 项目在 AGENTS.mdui/text/AGENTS.md 中定义了严格的 Ink 布局规则,这些规则是从生产事故中总结的:

规则原因后果
固定高度 Box 内禁用 wrap="wrap"换行文本可能超出 Box 高度,溢出到相邻组件布局崩坏,文字覆盖到 Header
文本容器避免使用 flexGrow文本会尝试填满可用空间但 Ink 不会裁剪超出部分内容溢出到边界外
改变卡片尺寸时必须重新计算内容预算边框占 2 字符,padding、margin、兄弟元素都占行预算不足导致边框重叠
列表最后一项不要加 marginBottom浪费一行,可能把内容挤出容器最后一行被截断或溢出
高度预算必须审计每一行height - 8 看似合理,实际 chrome 可能用 16 行间距被挤压,边框合并

分辨率顺序

Goose TUI 的启动解析顺序:

1. GOOSE_TUI_SCRIPT 环境变量 — 指向自定义 TUI 脚本路径
2. 本地 dist 目录 — 构建产物 ui/text/dist/tui.mjs
3. npx 全局安装 — 回退到 npx 执行

这个顺序确保开发时可以指向源码,生产时使用构建产物,全局安装时仍能正常工作。

启动流程

问题与规避

文本溢出破坏布局

问题: 在固定高度的卡片中使用 wrap="wrap",文本换行后行数超过容器高度,内容溢出到 Header 区域。

规避: 使用 wrap="truncate"wrap="truncate-end",并在代码层面预先截断字符串到字符预算(行数 x 宽度)。

// 错误 — 换行后超出高度
<Text wrap="wrap">很长的文本...</Text>
// 正确 — 截断到预算
<Text wrap="truncate-end">{preTruncatedText}</Text>

高度预算不足导致边框重叠

问题: 计算可用内容行数时低估了 Header、Footer、边框、滚动指示符的占用。例如认为 chrome 占 8 行,实际占 16 行,导致 Ink 压缩内容区,挤压项之间的 margin 变为 0,边框视觉上合并。

规避: 逐个审计每一行的实际用途。在 tui.tsx 中:

headerH = 2(Header 的 title + status 行)
inputBarH = 2(边框)+ inputContentRows(输入行数)+ inputExtraLines(提示行)+ 1(marginTop)
historyBarH = 2(分隔线 + 导航文本)

每个数字都有明确来源,不使用魔法数字。

最后一项的 marginBottom 浪费

问题: 列表渲染时,每一项都带 marginBottom={1},最后一项的 margin 浪费一行空间。在高度紧张的场景下(如 10 行终端),这一行可能导致关键信息被截断。

规避: 使用条件 margin 或容器的 gap 属性:

// 条件 margin
items.map((item, i) => (
<Box marginBottom={i < items.length - 1 ? 1 : 0}>
<Text>{item}</Text>
</Box>
))
// 或使用 gap(如果 Ink 版本支持)
<Box gap={1}>{items}</Box>

动画 tick 导致长会话 OOM

问题: goose-framespinner 的动画每 300ms 触发一次 setState,导致整个 Viewport 重新渲染。在长时间运行的会话中(数百轮对话),每轮对话的 Markdown 都被重新构建,最终 OOM。

规避:tui.tsx 中,动画 tick 被条件化——只在 bannerVisibleloading 时启动定时器,两者都为 false 时完全停止重新渲染:

useEffect(() => {
if (!bannerVisible && !loading) return; // 无动画时停止 tick
const t = setInterval(() => {
if (loading) setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length);
if (bannerVisible) setGooseFrame((f) => (f + 1) % GOOSE_FRAMES.length);
}, 300);
return () => clearInterval(t);
}, [bannerVisible, loading]);

终端尺寸变化导致滚动偏移失效

问题: 用户调整终端窗口大小时,内容行数因宽度变化而改变,之前保存的 scrollOffset 指向错误位置。

规避:tui.tsx 中监听 termWidthtermHeight 变化,自动重置滚动偏移到最新内容:

useEffect(() => {
setScrollOffset(0);
}, [termWidth, termHeight]);

设计取舍

Ink (React 生态) vs Ratatui (原生 Rust TUI)

维度Ink (React)Ratatui (Rust)
语言TypeScript / JavaScriptRust
生态复用 React 组件、hooks、状态管理独立生态,组件有限
开发效率热重载、浏览器调试、React DevTools需要编译、终端内调试
性能Node.js 进程,启动较慢原生二进制,启动极快
架构复杂度需要 Node.js 运行时,Rust + TS 双栈纯 Rust 单栈
类型安全TypeScript 类型,运行时可能出错Rust 编译期全检查
团队技能前端工程师可贡献需要 Rust 系统编程经验

Goose 选择 Ink 的核心考量是开发效率与生态复用。Goose 已有 Electron 桌面端(React 技术栈),TUI 复用相同的 React 模式,团队无需学习新的 TUI 框架。同时 Ink 的组件模型(hooks、memo、context)与 Web React 一致,降低了学习成本。

代价是架构复杂度——Goose 需要同时维护 Rust 后端和 TypeScript 前端两个运行时,通过 ACP(Agent Client Protocol)通信。

React 模式复用 vs 平台特定优化

Ink 使用 Flexbox 布局,与 Web React 一致。但终端环境有独特约束:

  • overflow: hidden——必须预截断
  • 固定字符网格——尺寸单位是字符数而非像素
  • 无鼠标滚轮——键盘导航(上下箭头、PageUp/PageDown)
  • 颜色受限——16 色/256 色/真彩色,依赖终端支持

Goose 的选择是:保留 React 的组件模型和状态管理模式,但在布局和文本处理上采用终端特定策略。例如:

  • 使用 React.memo 减少重新渲染(通用 React 优化)
  • 使用 useInput hook 处理键盘事件(Ink 特定)
  • 手动计算字符预算并预截断(终端特定)
  • useEffect 订阅 stdout.on("resize") 响应终端尺寸变化(终端特定)

参考来源

  • 源码:.op/goose/ui/text/src/tui.tsx — TUI 主组件,包含完整的布局预算、滚动、输入处理
  • 源码:.op/goose/ui/text/AGENTS.md — Ink 开发规则与最佳实践
  • 源码:.op/goose/AGENTS.md — 项目级 Ink 布局约束规则
  • 源码:.op/goose/ui/text/src/components/ContentRenderers.tsx — 内容行渲染器
  • 源码:.op/goose/ui/text/src/constants.tsx — 常量定义(PASTE_THRESHOLD、INPUT_MAX_ROWS 等)