TUI 会话记录渲染与交互#
本文是 Ink TUI 会话记录显示与交互的设计:工具调用如何渲染、输出如何折叠与 展开、命令与键位如何声明、运行中途的提问如何呈现。配套文档: user-input-requests.md (运行中途的提问;其 TUI 呈现层在本文中规定)。
1. 目标#
会话记录应做到:工具调用折叠但不丢失信息、提供 ctrl+o 的展开视图、结构化 渲染 diff、agent 忙碌时排队消息,以及给出用户能发现并覆盖的键位绑定。
2. 起点#
TUI 底座提供了内置的 hermes-ink 单元格网格渲染器(带鼠标追踪与 ScrollBox)、
4 套可实时预览的主题、显示 tokens / context% / cache / permission mode 的
BottomBar、命令面板(ctrl+k)、fish 风格自动建议、@file 补全、按会话保存
的草稿,以及 account 与 channel 流程。本设计要补的缺口集中在会话记录本身:
- 工具输出硬性折叠在 6 行(
Turn.tsxMAX_LINES),没有任何展开手段——没有 键位绑定,没有 verbose 模式。 - 工具参数只有一行截断显示;没有按工具区分的渲染,每个工具看起来都一样。
- 没有 diff 渲染;
/diff直接输出原始的git diff文本。 - 流式文本显示原始 markdown 源码,等最终渲染落地时画面会跳动
(
Turn.tsx:123);renderMarkdown在全量重绘的渲染器下未做 memoize。 follow_up_question/approval_request信封在ws/client.ts中已有类型 定义,但被无声丢弃,导致 agent 的提问超时、askpermission mode 无法触达 (shift+tab 只在 bypass↔auto 之间循环)。/resume仅从 role + content 重建会话记录,工具历史丢失 (useWsEvents.ts:326-336)。- 工具结果按工具名匹配到调用(服务端流事件不带 call id),并发的同名调用会被 错误归属。
- 忙碌即输入被锁定(
submitText直接返回);没有消息排队。 ui/套件(ModalProvider / Confirm / Form / MultiSelect / Toast)已构建, 但只被--demo界面使用;REPL 仍在跑一个 24 状态的pickerKind枚举。
3. 会话记录渲染#
信息密度沿用 Claude Code 的做法(指向
references/claude-code-leaked/src 的文件指针见 references 文档)。
- 工具渲染器接口。 每个工具获得一组渲染钩子(use-line / progress /
result / error),共用同一个外壳:状态圆点(
⏺排队时暗、运行时闪烁、 完成为绿、错误为红)、加粗的工具名、括号内的参数摘要,结果缩进在⎿边槽之下。两个字形承载全部工具状态;没有方框。 - 3 行截断,配
… +N lines (ctrl+o to expand),另加两处细化:只隐藏 1 行时就直接显示它;超大输出先按字符数预截断,再估算剩余行数。 - 量化的单行摘要,每个工具一条,而非省略号截断:
Read 52 lines、Added 5 lines, removed 2 lines后接 diff、Found 8 files,子运行用Done (12 calls · 48k tokens · 2m 10s)。 - ctrl+o 打开冻结快照的会话记录界面——一个独立的 Screen 状态,而非原地 展开。它冻结消息列表,把所有内容重新渲染为展开形态,页脚给出退出提示; 在其中按 ctrl+e 则完全不截断地显示全部内容。
- 复合 spinner 行:
✻ verb… (esc to interrupt · 42s · ↓ 3.2k tokens), 带渐进式宽度门控。spinner 与 token 统计已各自存在,这一步是把它们合并。 - 忙碌时排队的消息:运行期间打字会把消息加入队列(暗色,显示在输入框 上方),↑ 可调回编辑,队列在 turn 之间清空。
diff 渲染自己写:行号、增删着色、3 行上下文,供 edit 风格的工具结果和
/diff 使用。markdown 通过 marked-terminal 渲染并按 turn memoize——在每帧
全量重绘的渲染器下这一点很关键。
渲染器本身不更换。内置的 hermes-ink 已经有鼠标追踪和 ScrollBox,换成 OpenTUI 在这里没有收益。
4. 命令与交互架构#
交互结构沿用 opencode(指向
references/opencode/packages/opencode/src 的指针见 references 文档)。
- 一份命令注册表作为唯一事实来源。 每条命令只声明一次(name / title /
category / keybind / slash-name / enabled),这份声明驱动键位绑定、ctrl+k
面板、斜杠命令,以及页脚里的实时按键提示。单一张表正是让注册表与处理器
不再彼此漂移的东西——
/branch已实现但未列出、/memory已列出但是空壳。 - 键位定义表 + 用户覆盖。 默认值与描述只声明一次,并据此生成配置
schema,契合现有的 schema 驱动设置设计。未知按键报错;
"none"表示禁用。 - 问题与权限提示替换输入框,通过一个三选一的槽位(Prompt |
QuestionPrompt | ApprovalPrompt),而非弹出模态框。会话记录保持可见且可
滚动,esc 保持单一明确的语义。这就是 user-input-requests.md 的 TUI 呈现
层,处理
follow_up_question和approval_request,并让askpermission mode 可触达。 - 行内危险确认,用于具破坏性的 picker 操作:再按一次即确认,该行变红, 取代嵌套的确认层。
REPL 的 picker 从 pickerKind 枚举迁移到 ModalProvider/Form 套件;该改动是
机械的,可以按 picker 逐个进行。
5. 服务端支持#
设计中有两处需要服务端目前未发送的数据:
- 工具流事件带上
call_id,TUI 便按 id 而非按名字匹配结果,并发的同名调用 归属正确。 conversation_loaded携带工具块,/resume得以恢复工具历史。
两者都会触及 _event_parsing.py 与 dispatcher 的事件发射,与事件总线的
工作共享同一片代码。
6. 约束#
- 单元格网格渲染器每帧重绘全部内容,因此上述更重的按 turn 渲染依赖于对 markdown 与 diff 输出的 memoize。
- ctrl+o 作为全局按键是安全的:终端流控用的是 ctrl+s 与 ctrl+q。
附录:实现状态#
调研已完成,实现尚未开始。计划的落地顺序:
- P0 —— 会话记录密度(纯 TUI,无服务端改动):工具渲染外壳、3 行截断、
ctrl+o 会话记录界面、diff 组件、memoize 的
renderMarkdown。验收标准是: 一次混合工具的运行读起来是一条条两行的条目,ctrl+o 显示全部内容,一次 edit 显示带颜色的 diff,冗长的 bash 输出折叠并给出准确的 +N 计数。 - P1 —— 交互:忙碌时排队的消息与 ↑ 编辑、复合 spinner/状态行、命令注册
表统一、带
~/.openprogram覆盖的键位定义表,以及由同一张表生成的?快捷键帮助。 - P2 —— 依赖服务端的部分:工具流事件中的
call_id、conversation_loaded中的工具块、输入槽位里的问题/批准提示,以及 REPL picker 的迁移。