# Agent Note: TUI 语义终端状态快照

Status: implemented

[English](2026-07-18-tui-terminal-state-snapshots.md) | 中文

## 问题

TUI 是有状态的渲染器。用户最终看到的结果取决于 ANSI 解析、差分帧、换行、回滚缓冲、视口位置、终端宽度、焦点、光标状态，以及各工具的呈现意图。收集 `Terminal.write()` 片段的单元测试可以验证事件处理，却无法验证终端最终显示的画面。同一画面也可能由不同的写入片段产生，因此固定这些片段会制造误报。

组件行快照止于 ANSI 进入终端之前，无法覆盖光标移动、清屏、样式、浮层组合和重排。栅格截图会带入与 TUI 契约无关的字体和平台渲染噪声。直接追加看似合理的会话事件来构造完整流程还存在另一处盲区：这种测试只能证明渲染器接受这些数据形态，无法证明生产环境的 agent loop（智能体循环）和工具实现会生成这些事件。

因此，TUI 既需要确定、便于评审的终端状态表示，也需要通过已录制模型流程执行真实下游组件，并保留一项范围更小、覆盖真实进程与 PTY 边界的测试。

## 决策

TUI 覆盖分为四个互补层次：

1. `packages/ui/tui/tests/tui.spec.ts` 直接测试事件映射、输入路由、资源释放和错误行为。
2. `packages/ui/tui/tests/tui.snapshot.ts` 将生产 TUI 挂载到无界面终端模拟器，覆盖完整会话日志无法保留的瞬态：进行中的流式输出、待完成工具调用、浮层、展开状态、压缩重排、错误和关闭过程。
3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志，再比较生成的语义终端状态。
4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合，驱动一段经过流式输出和 `ask_user_question` 的脚本化会话，并验证启动、输入、退出、失败报告和终端恢复。

可运行 TUI 就是交付的 `apps/cli` 组合：共享的 `base.cordis.yml` 加 `tui.cordis.yml` overlay，后者拥有交互式 coding 后端、工具与前端入口。TUI 快照和 PTY 测试位于 `apps/cli/tests/`。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。

### 已录制会话回放

每个示例级场景目录都包含 `session.jsonl`、可选的子会话日志 `session.<n>.jsonl`，以及 `terminal.expected.txt`。主日志提供用户来源的 `user/message` 提示词和已录制的 `assistant/chunk` 序列。`dsh-llm-replay` 为每个会话派生一份模型调用脚本，并将子日志绑定到新建的子会话；这是测试中唯一的 mock 边界。agent loop、bash 与文件系统实现、Code Mode worker、subagent 提供方、工作流 worker、Cordis 工具、呈现器和 TUI 都使用生产实现。

如果工具调用顺序不符、预期事件数量不足、工具结果报错、轮次以错误结束、工作流生命周期不完整，或者实时子会话数量与 fixture（测试前置数据）集合不一致，测试都会失败。即使终端预期输出表面正确，这些断言也能阻止失败或被绕过的生产路径混入结果。

真实模型 fixture 通过 `DSH_SNAPSHOT=record` 更新；录制模式会重写其主会话与子会话 JSONL 日志以及终端预期输出。确定性的 Cordis 工具链保留一份人工编写的完整 JSONL 脚本，因为要求真实模型稳定经过五个指定工具边界和两个子会话并不是可靠的录制契约。`DSH_SNAPSHOT=refresh` 会无密钥回放所有已提交脚本，并且只重写派生的终端预期输出。普通回放只比较而不写入，未知模式值会快速失败。

### 语义终端投影

包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口，并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前，快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval，同时保留跨过一次动画 tick 的真实墙钟等待，从而固定语义状态，而非调度器碰巧渲染出的某个加载动画字形。

每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区；浮层捕获可见视口。文本和样式相互分离，评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。

每个检查点还会对完整终端状态强制执行主题无关性：禁止 RGB 颜色、禁止 ANSI 0–15 以外的调色板项，也禁止显式背景色。选择行使用终端默认色进行反显，因此仍然有效。两套测试都拥有封闭清单，会拒绝缺失的场景、缺失的检查点和遗留预期输出文件。

### 必需场景矩阵

| 层次 | 场景 | 固定的契约 |
|---|---|---|
| 已录制流程 | 多轮会话 | 已录制的推理与文本分片、两轮输入、保留历史、token 总量和空闲编辑器状态 |
| 已录制流程 | Todo 计划 | 真实 `todo_write` 执行、结果卡片和持久计划渲染 |
| 已录制流程 | Bash 终端卡片 | 真实本地执行器输出、说明、退出状态和已完成终端卡片 |
| 已录制流程 | 并行文件读取 | 同一条 assistant 消息中的两次调用、真实文件内容、顺序和两个独立完成卡片 |
| 已录制流程 | Code Mode | 真实 `run_code` worker 执行、两条 `tool/code-dispatch` 事件、捕获的程序输出和已完成卡片 |
| 已录制流程 | 动态工作流 | 真实工作流 worker、阶段生命周期、回放的子会话、结构化返回值和已完成卡片 |
| 已录制流程 | Cordis 动态工具链 | 真实挂载、Code Mode 检查、直接 subagent、工作流子会话、卸载和全部生产呈现器 |
| 瞬态 | 流式输出与待完成高级调用 | 进行中的推理和文本，以及完整日志中不会保留的待完成 Code Mode、工作流和 Cordis 卡片 |
| 瞬态 | 卡片、交互、布局、失败和关闭 | 折叠与展开的卡片族、问题校验、压缩替换、尺寸重排、帮助与错误、光标恢复和终端停止 |

## 曾考虑的替代方案

- **快照原始终端写入**：不予采纳，因为差分渲染可能在画面不变时改变写入边界，而且光标与清屏序列难以评审。
- **快照进入终端输出之前的组件渲染行**：不予采纳，因为它无法测试 ANSI 解析、光标移动、浮层、视口行为，也无法测试独立组件在同一帧中的相互作用。
- **通过追加会话事件构造所有完整流程**：不予采纳，因为人工编写的事件序列可能与 agent loop、工具执行、子会话绑定或 worker 行为发生偏差，但呈现测试仍然保持绿色。直接构造事件只用于渲染器瞬态。
- **复用 ACP stdout 预期输出作为 TUI 判定依据**：不予采纳，因为已录制模型流程与传输方式无关，其呈现方式却并非如此。TUI 场景使用同一套 JSONL 回放词汇，但拥有独立的终端预期输出。
- **提交栅格截图**：不予采纳，因为字体、字形度量、抗锯齿和宿主终端主题会使结果依赖平台，也会增加语义样式变更的评审难度。
- **只使用 PTY 端到端测试**：不予采纳，因为原始 PTY 输出是一系列历史绘制操作，而不是可查询的最终状态。PTY 测试保留真实 Loader、输入与清理边界，模拟器负责广泛的状态覆盖。

## 后果

- 当真实 Code Mode、工作流、subagent、文件系统、bash 或 Cordis 路径损坏时，已完成高级快照会失败，不会继续接受伪造的结果事件。
- TUI 视觉回归会产生便于阅读的单元格和样式 diff，而 JSONL fixture 会保留触发生产路径的确切模型分片。
- 模拟器使用 xterm 的拟议缓冲区 API。升级 xterm 时必须重新运行并评审语义投影；终端特有行为仍需由 PTY 冒烟测试覆盖。
- 预期输出有意固定指定尺寸下的换行与视口行为。预期布局变更使用无密钥刷新；模型流程变更使用录制模式，并同时评审 JSONL 与终端 diff。
