Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

从零理解 Agent 终端 TUI:以 pi-tui 为例

TL;DR:pi-tui 是一个进程内的终端 UI 库,不依赖其他 pi 包,pi-coding-agent 的交互模式就建在它上面。它的核心约定是组件只做一件事:render(width) 返回字符串行数组;TUI 负责合并重绘请求、把输入交给焦点组件、合成 overlay,再用差分算法只重写变化的行,并用同步输出(CSI 2026)避免闪烁。1.0 起交互模式默认全屏:在 alternate screen 里由程序自己管理 transcript 视口和滚动,tuiMode: "regular" 可以退回主屏幕、交给终端原生 scrollback。接入 Agent 时,InteractiveMode 订阅 AgentSession 事件,message_update 每次用完整消息快照刷新同一个消息组件,而不是往后追加 token,再调用 requestRender()。原则就一句:Agent 决定发生了什么,TUI 决定怎样显示。

这篇是教程,目的不是复刻 pi-tui,而是讲清楚 Agent 终端界面为什么需要这些抽象、哪些细节绕不开,以及 pi 在 v1.5 里是怎么做的。

先说清运行形态:pi-tui(@earendil-works/pi-tui)是一个普通的 npm 库,和 pi-coding-agent 跑在同一个 Node 进程里,没有独立的渲染服务或守护进程。它的运行时依赖只有三个:marked、marked-cjk-friendly(Markdown)和 get-east-asian-width(字符宽度),不依赖 pi-ai、pi-agent-core。交互模式(pi 不带 -p 运行时)创建一个 TUI 实例,挂上组件,订阅会话事件;Print、JSON、RPC 模式完全不加载这一层(见 运行模式)。扩展也通过 ctx.ui 使用同一个 TUI 实例,官方文档明确要求扩展不要再自己建第二个渲染器。

1. 先分清:CLI、TUI 与 Agent

普通 CLI 是一次性的:读参数、执行、打印、退出。TUI 是常驻的交互循环:不断接收键盘输入和后台事件,修改内存状态,重绘终端里的一部分内容。Agent 又多了一条异步事件流:一次提交可能产生文本增量、thinking、工具调用参数、工具的流式输出,最后是完成、中止或错误。

所以 Agent TUI 不是"给聊天接口加点颜色",而是两个事件循环的接合:

键盘/粘贴/resize ──> TUI 输入分发 ──> 编辑器 onSubmit ──> AgentSession.prompt()
                                                              │
终端字节流 <── 差分渲染 <── 组件树 <── InteractiveMode.handleEvent() <── 会话事件

最重要的分层是:Agent 决定发生了什么,TUI 决定怎样显示。渲染组件不调用模型,Agent 运行时不拼 ANSI 字符串。正因为有这条边界,同一个 AgentSession 才能同时服务 TUI、JSON 流和 RPC。

一句话:Agent TUI 是"输入事件循环"和"Agent 事件流"在组件状态上的汇合点。

2. 终端究竟提供了什么

终端不是浏览器,没有 DOM,也没有"修改第三个消息节点"这种接口。程序能做的只有两件事:从 stdin 读字节,往 stdout 写字符和控制序列。所有能力都从这两条路上长出来。

ANSI 控制序列是夹在文本里的终端命令:\x1b[31m 设颜色,\x1b[2A 光标上移两行,\x1b[2K 清行,\x1b[?25l 隐藏光标,\x1b[?2026h … \x1b[?2026l 是同步输出,让终端把中间的一批更新一次性显示出来。这些序列应该集中在终端适配器和渲染器里,业务组件不该到处写 \x1b[。OSC 一类序列还承载了更高级的能力:OSC 8 超链接、OSC 52 剪贴板、OSC 133 提示符标记、OSC 7501 程序状态,后面会提到。

raw mode:终端默认是 canonical mode,用户按回车后程序才收到整行,终端还会自动回显。TUI 必须切到 raw mode,才能立即收到方向键、Backspace、Escape。代价是 Ctrl+C 不再自动变成 SIGINT,应用要自己识别;而且无论正常退出还是崩溃,都必须把终端恢复原状,否则用户的 shell 会"键盘坏掉"。pi-tui 的 ProcessTerminal 在 stop() 里恢复各种终端模式,1.4 起还注册了进程 exit 处理器,即使没走到 stop() 也会同步恢复鼠标跟踪、Kitty 键盘协议、bracketed paste 和 alternate screen。

bracketed paste:终端用 \x1b[200~ 和 \x1b[201~ 包住粘贴内容,这样一大段粘贴不会被当成一串独立按键(比如里面的换行被当成回车提交)。

字符数不等于终端列宽:ANSI 序列占 0 列,大多数汉字占 2 列,emoji 可能由多个 code point 组成却只显示一个字形,组合字符不额外占列。所以 text.length 和 slice(0, width) 都不能用。pi-tui 用 visibleWidth()、truncateToWidth()、sliceByColumn()、wrapTextWithAnsi() 统一处理测宽、截断和换行,组件只调用这些函数。

主屏幕和 alternate screen:终端有两个缓冲区。主屏幕就是平时的 shell 输出,内容会滚进终端自己的 scrollback;alternate screen 是 vim、less 用的那块"全屏画布",没有 scrollback,退出后主屏幕原样恢复。这个选择决定了谁拥有滚动:在主屏幕上终端拥有滚动历史,程序只能改底部还在视口里的部分;在 alternate screen 上程序拥有整个视口,滚动、选择文本、搜索都要自己实现。pi 1.x 的两种模式就是这两种选择,见第 5 节。

3. 核心抽象

Terminal:隔离真实终端

Terminal 接口把 stdin/stdout、raw mode、尺寸、光标移动、清行清屏这些细节藏起来,是渲染器和操作系统之间的接缝。它有两个实现:ProcessTerminal 连接真实的 stdin/stdout,并负责 bracketed paste、Kitty 键盘协议协商、终端标题和程序状态上报;VirtualTerminal 在测试目录里,基于 @xterm/headless 模拟一个真实终端。这些协议细节都不会泄漏给组件。

Component:宽度进去,行数组出来

pi-tui 最关键的接口非常小:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
  invalidate(): void;
}

每个字符串是一行。组件知道可用宽度,却不知道自己最终会落在终端第几行。这是一个"深模块":调用者只需要会调 render(width),组件内部可以做 Markdown 解析、换行、着色、光标定位和缓存。

这个约定带几条硬约束:每行的可见宽度不能超过 width,超了 TUI 会直接报错;render() 应该是确定性的,不发网络请求、不启动 Agent、不写 stdout;invalidate() 只清理渲染缓存,不直接重绘。TUI 会在每行末尾追加 SGR 重置和 OSC 8 重置,所以样式不会跨行延续,多行文本要逐行重新上色(wrapTextWithAnsi() 会自动处理)。

Container 与布局

终端天然按行排列,所以最基本的布局就是 Container:把子组件的行依次拼起来。pi-tui 没有把 Flexbox 搬进终端;在主屏幕模式下,聊天历史、状态区、编辑器和页脚依次放进根容器就够了。

全屏模式多了一层约束:画面高度固定为终端高度,transcript 要能独立滚动,编辑器要钉在底部。为此 1.x 增加了 VStack、HStack(按 basis/grow/shrink/minSize 分配区域)和 ScrollView(为一个区域负责滚动),通过 setLayoutRoot() 设置。这些布局语义只在 alternate screen 渲染器上生效,因为在主屏幕上滚动归终端管。

TUI:一个接口,两个渲染器

TUI 本身也是组件(它是根容器),同时是渲染引擎:保存上一帧画面,合并重绘请求,把输入交给焦点组件,管理 overlay 栈,比较新旧画面生成 ANSI 更新,负责启动和恢复终端。1.x 把它拆成了一个共享接口加两个实现:

  • TuiMainScreen(tui-main-screen.ts):渲染到主屏幕,保留终端 scrollback;
  • TuiAltScreen(tui-alt-screen.ts):在 alternate screen 里渲染一个终端高度的视口,自己处理滚动、鼠标、文本选择和搜索;停止时恢复主屏幕并打印完整的最终文档。

公共逻辑(渲染调度、输入分发、焦点、overlay 合成、终端颜色查询)在 tui.ts 的 TuiBase 里。应用代码面向 TUI 接口编程,只在构造时选具体渲染器。

焦点、光标与 IME

根 TUI 收到输入后不会广播给所有组件,只交给当前焦点组件(setFocus())。在此之前还有几道拦截:终端颜色查询的回复会先被消费掉;addInputListener() 注册的全局监听器可以改写或吞掉输入;Kitty 协议的按键释放事件默认被过滤,除非组件声明 wantsKeyRelease。

编辑器里有两种"光标":字符串中的逻辑位置,和屏幕上的硬件光标。中文输入法的候选窗口跟随硬件光标,所以光标位置必须准确。pi-tui 的做法是让可聚焦组件(实现 Focusable)在自己的输出里插入一个零宽的 APC 序列 CURSOR_MARKER,渲染器在最终画面里找到它、算出前面的可见宽度、删掉标记,再把硬件光标移过去。这样组件不必知道自己在第几行,IME 也能拿到正确位置。组件声明"光标在这里",渲染器负责"怎样定位终端光标",这是一个很好的接缝。1.2 起组件画出来的假光标也改成用 renderFakeCursor() 包一层标记,由 TUI 统一渲染成反色;打开 showHardwareCursor(或环境变量 PI_HARDWARE_CURSOR=1)时只显示真实光标。包着 Input/Editor 的容器必须把 focused 状态传给子组件,否则候选窗口会出现在错误位置。

Overlay

自动补全、模型选择器、确认对话框适合做 overlay。showOverlay() 返回一个 handle,可以隐藏、重新显示、聚焦、释放焦点;TUI 会记住打开 overlay 前的焦点,关闭后恢复。overlay 在差分之前按终端列合成进主画面(compositeOverlays()),差分比较的是合成后的最终画面。顺序反过来的话,渲染器就没法清理 overlay 的旧位置。

一句话:组件只回答"画面是什么",TUI 决定"怎样、何时写进终端",Terminal 隔离"写到哪个真实设备"。

4. 渲染管线与差分渲染

调度:生产者只请求,渲染器决定何时画

Agent 的 token 增量可能非常密集,如果每个 token 都立即重绘,就会闪烁、CPU 空转。pi-tui 的规则是:状态变化后只调用 requestRender(),由渲染器合并。

requestRender()
  已有待处理请求 → 直接返回(合并)
  否则 nextTick → scheduleRender()
      距上次渲染不足 16ms → setTimeout 补足剩余时间
      到点 → doRender();渲染期间又有新请求 → 再调度一次

键盘输入走另一条路:焦点组件处理完按键后,TUI 调用立即渲染,跳过 16 ms 节流,并取消已排队的节流帧。源码注释给的原因是按键对延迟敏感,而 Windows 上连 setTimeout(0) 都可能等满一个 16 ms tick。所以流式输出最多约 60 帧每秒,打字却是即时响应。

差分:只重写变化的部分

每次渲染先得到组件树的 nextLines(已经合成了 overlay),再和上一帧逐行比较,找出第一处和最后一处变化。TuiMainScreen 有三种策略:

  1. 首次渲染:直接输出所有行,不清 scrollback;
  2. 宽度变化,或变化发生在已滚出视口的区域:清屏全量重绘(软换行全部失效,或者改不到 scrollback 里的内容);
  3. 普通更新:光标移到第一处变化行,清到末尾,重写变化的行。

TuiAltScreen 只维护一个终端高度的视口,就地更新变化的视口行;用户在底部时自动跟随流式输出,手动滚上去后即使内容在增长也保持位置。两个渲染器都把一帧的全部写入包在同步输出 \x1b[?2026h … \x1b[?2026l 里;支持该协议的终端一次显示整批变更,不支持的会忽略它,功能照常。

自己从零写的话,入门版本 resize 时直接清屏全量重绘就行;内容缩短后的残留行、多行内联图片、硬件光标和逻辑光标不在同一行这些情况,等真遇到再处理。

5. 1.x 值得知道的变化

默认全屏。1.0 起交互模式默认 tuiMode: "fullscreen",也就是 TuiAltScreen;设置 "regular" 或传 --tui-mode regular 回到主屏幕。交互模式通过 createInteractiveTui()(tui-renderer.ts)这一个组合根选择渲染器。全屏布局在 chat-viewport.ts:上面是 follow: "end" 的 transcript ScrollView,下面是固定的输入区 VStack,依次放待发送消息、状态行、扩展 widget、编辑器和页脚。全屏模式下程序拥有视口,所以多出了一组能力:鼠标滚轮和触控板滚动、拖动选择并通过 OSC 52 复制(fullscreenCopyOnSelect)、可拖动的滚动条、Ctrl+Shift+F 搜索 transcript、按 OSC 133 标记在消息之间跳转、点击 OSC 8 链接打开。退出时由 fullscreenExitOutput 决定留下什么:默认 "transcript" 会先切到主屏幕渲染器、把完整对话打印进 scrollback,再停止;"resume-hint" 只留一行恢复提示。

两种模式可以在 /settings 里实时切换。switchTuiMode() 把旧渲染器的子组件和焦点原样搬到新渲染器上,组件实例不重建;组件持有的 TUI 引用是一个 Proxy(createInteractiveTuiReference()),始终指向当前渲染器。这正是"组件只依赖 TUI 接口"带来的好处。

程序状态(OSC 7501)。pi 用 Program Status Protocol 告诉终端或 agent 面板自己的状态:working(运行或压缩中)、blocked(扩展对话框或登录在等用户)、done、error、idle。InteractiveMode 每收到一个会话事件就交给 ProgramStatusReporter 处理。上报只在终端回应了支持查询之后才发送,内容从不包含 prompt 或模型输出;PI_PROGRAM_STATUS=1/0 可以强制开关。

终端能力探测与覆盖。pi 自动探测 OSC 8 超链接、内联图片协议(Kitty / iTerm2)和 truecolor,主要依据环境变量,在 tmux 下还会确认 tmux 是否转发超链接。终端代理或多路复用器可能让探测失准,所以每项都能覆盖:环境变量 PI_HYPERLINKS、PI_IMAGE_PROTOCOL、PI_TRUE_COLOR,或设置项 terminal.hyperlinks、terminal.images、terminal.trueColor,设置优先于环境变量。不支持超链接时退回 text (url) 格式;全屏模式下 iTerm2 图片退回文本,因为该协议无法在视口重绘时裁剪或删除图片。

终端颜色与 system 主题。默认主题 system 不自带调色板:启动时向终端查询前景、背景和 16 色调色板(最多等 100 ms,超时先用 ANSI 回退色,回复晚到了再补上),再按对比度要求(正文至少 WCAG 4.5:1)生成配色;终端切换明暗时重新查询、重建主题。颜色值支持 ANSI 索引、sRGB、OKLCH、OKHSL,统一可转成 sRGB 做混色,最后按终端能力输出 truecolor 或 256 色。对组件作者来说,要点是不要把带主题色的字符串长期存在状态里,除非 invalidate() 会重建它们:主题切换会清渲染缓存,但清不掉已经嵌进应用状态的旧 ANSI 颜色。

流式性能(1.5 "Faster streaming")。三处改动都针对"每次 message_update 都把整条消息重做一遍"的成本。第一,pi-ai 新增增量容错的 StreamingJsonParser / ToolCallArgumentsStream,所有 provider 都用它解析流式工具参数,大型工具调用(比如写一个长文件)不再每来一块就从头解析 JSON。第二,Markdown 组件复用上次渲染的顶层块,流式消息只重渲染变化的最后一块,resize 时也复用已高亮的代码。第三,AssistantMessageComponent 在更新之间保留 Markdown 实例(按块类型复用 slot),Text.setText() 遇到相同文本也不再丢弃换行缓存。

原生模块。pi-tui 带了 macOS、Linux(X11)、Windows 的预编译 N-API 模块,提供修饰键状态检测和异步剪贴板访问(文本、图片、文件路径)。修饰键检测解决的是终端发不出区分信号的情况:比如 Apple Terminal 把 Shift+Enter 发成 ESC+回车,有了本地 Shift 状态,ProcessTerminal 才能把它和 Alt+Enter 区分开。原生模块只是增强,没有它也能运行。

6. 接入 Agent:事件更新组件,不要在 token 回调里打印

最容易写出的版本是 agent.onToken(t => process.stdout.write(t))。它绕过了组件树和差分渲染,工具输出、状态行或编辑器一更新,光标和画面就会互相覆盖。正确路径是:事件更新组件状态,然后请求重绘。

pi 里做这件事的是 InteractiveMode.handleEvent(),它订阅 AgentSession 的事件流(事件模型见 pi-coding-agent),映射关系如下:

message_start(assistant)  -> 新建 AssistantMessageComponent,加入 chatContainer
message_update            -> 用完整消息快照 updateContent();
                             发现新的 toolCall 块 -> 按 id 新建 ToolExecutionComponent
                             已有的 toolCall 块   -> updateArgs()
message_end               -> 最终 updateContent();工具参数 setArgsComplete()
tool_execution_start      -> 按 toolCallId 找到(或补建)工具组件,markExecutionStarted()
tool_execution_update     -> updateResult(partial)
tool_execution_end        -> updateResult(final),从 pendingTools 移除
agent_end                 -> 清除 working 状态和残留的流式组件
每个分支最后              -> ui.requestRender()

有两个细节值得讲。一是工具组件在模型还在流式生成参数时就创建了,而不是等工具开始执行,所以用户能看到参数一点点出现(比如 edit 的 diff 在 setArgsComplete() 后才计算)。二是用 pendingTools: Map<toolCallId, Component> 定位组件:工具可以并发执行,不能假设"最后一个工具组件就是当前更新目标"。codemode 脚本里的嵌套调用带 parentToolCallId,显示在父工具那一行里,不单独建组件。

7. 为什么更新"完整消息"而不只追加 delta

教程里写 append(delta) 很直观,但 pi 的 message_update 携带的是当前完整的 assistant message,组件每次调用 updateContent(message)。

收益是幂等:UI 不必推测前一个 delta 有没有丢、有没有重复、结构有没有变。一条完整消息里可能同时有 text、thinking 和 tool call,而且顺序交错。updateContent() 每次按最新消息重建内部内容容器:text 变成 Markdown,连续的 thinking 块合并显示(或折叠成一行标签),停止原因(aborted、error、length)显示成明确的状态。同一个组件既能处理流式更新,也能处理从会话文件恢复的历史消息,这两条路径用的是同一份代码。

代价是每次更新都可能重新解析、高亮整条消息,消息越长越贵。旧版靠渲染节流和组件缓存兜底;1.5 进一步把成本压到"只重做变化的部分"(第 5 节的 Markdown 块复用和 slot 复用)。接口仍然是快照语义,增量只是内部优化。这个取舍值得记住:协议层保持快照的简单和幂等,性能在组件内部用缓存解决。自己写入门实现可以先追加 delta;一旦事件协议需要支持重放、重连或结构化内容,就该换成快照。

一句话:快照让 UI 正确性不依赖事件完整性,缓存让快照不至于太慢。

8. 一次完整场景:流式回复、工具调用、Enter 插话

假设用户问"把 README 里的版本号改成 1.5",全屏模式下。

提交。编辑器有焦点,用户按 Enter(tui.input.submit)。Editor 触发 onSubmit,此时会话不在流式输出,文本经输入回调交给会话开始一次运行。ProgramStatusReporter 收到运行开始,向终端上报 working。

用户消息与思考。message_start(user) 把用户消息组件加入 transcript;turn_start 显示 working 状态指示器。随后 message_start(assistant) 新建 AssistantMessageComponent。模型先吐出几段文字,每个 message_update 都带着完整消息,组件复用同一个 Markdown 实例只改文本,Markdown 只重渲染最后一个块。每次都调用 requestRender(),但 16 ms 内的多次请求合并成一帧,差分只重写 transcript 底部变化的几行。transcript 是 follow: "end",用户没往上滚,视口自动跟随。

工具调用流式出现。模型开始生成 edit 调用。pi-ai 增量解析参数 JSON,下一个 message_update 里出现一个 toolCall 块,InteractiveMode 发现 pendingTools 里没有这个 id,就新建 ToolExecutionComponent 加到 transcript;之后的更新只调用 updateArgs()。message_end 时参数完整,setArgsComplete() 触发 diff 计算。接着 tool_execution_start 用 toolCallId 找到同一个组件、标记执行开始,tool_execution_end 填入结果。

用户插话。工具还在跑,用户想起来还要改 CHANGELOG,在编辑器里输入后按 Enter。按键先走立即渲染路径,输入即时可见。onSubmit 发现 session.isStreaming,于是调用 session.prompt(text, { streamingBehavior: "steer" })。这不会打断当前运行,只是把消息放进 steering 队列。会话发出 queue_update,InteractiveMode 刷新输入区上方的待发送消息区。等 agent 循环在下一个注入点取出这条消息,它会作为 message_start(user) 出现在 transcript 里,模型在后续回合里看到它(队列语义见 pi-agent-core)。如果按的是 Alt+Enter(Windows/WSL 上是 Ctrl+Q),消息进 follow-up 队列,等这次运行全部结束后才发送;按 Escape(app.interrupt)则中止运行。

结束。agent_end 清掉 working 指示器,程序状态变成 done。整个过程里没有任何代码直接写 stdout:Agent 只发事件,InteractiveMode 只改组件,TUI 只管什么时候、写哪几行。

9. 编辑器与快捷键

主编辑器是 pi-tui 的 Editor,交互模式在它上面包了一层 CustomEditor,用来挂应用级快捷键。编辑器自带多行编辑、历史、撤销、kill ring、按词移动和自动补全(/ 补全命令,@ 和 Tab 补全文件路径);自动补全打开时它声明 capturesViewportInput,这样 PageUp/PageDown 移动的是补全选项,而不是滚动 transcript(1.5)。

快捷键是"命名动作 → 按键"的映射:tui.* 是库层动作(tui.input.submit、tui.altScreen.pageUp 等),app.* 是应用层动作(app.interrupt、app.message.followUp 等)。用户在 <agent-dir>/keybindings.json 里覆盖,/reload 生效,/hotkeys 查看当前绑定。按键识别用 matchesKey(),它兼容 Kitty 键盘协议和传统转义序列。部分终端发不出某些组合键(比如 Shift+Enter),terminal-setup.md 给了各终端的配置方法,这也是平台默认值不同的原因:Windows Terminal 把 Alt+Enter 占用为全屏切换,所以那里的 follow-up 改成 Ctrl+Q。扩展替换编辑器时应继承 CustomEditor,把自己不处理的按键转给基类,避免丢掉应用快捷键。

10. 测试:虚拟终端比截图更有用

TUI 很适合通过终端适配器测试。pi-tui 的测试用 VirtualTerminal,它基于 @xterm/headless,是一个真正解析 ANSI 的无头终端,所以测试断言的是"终端最终显示了什么",而不只是"写了哪些字节"。最值得覆盖的是真实风险,不是每个 getter:相同画面不产生内容更新;中间一行变化时只重写变化区域;内容缩短后旧字符被清掉(tui-shrink);resize 后全量重绘;退出和异常时终端模式被恢复(exit-restore);CJK、emoji、ANSI 样式的测宽与截断;overlay 跨越宽字符边界的合成;并发工具事件按 id 更新正确的组件。仓库里还有渲染抖动和大 transcript 的基准脚本。

排查渲染问题时,PI_TUI_WRITE_LOG 可以把原始 ANSI 流记录下来。官方建议的手动验收清单是:窄宽度、宽字符、resize、主题切换、焦点切换,以及 regular 和 fullscreen 两种模式都要试。

11. 常见错误与设计原则

几种典型错误都违反了同一条分层原则。Agent 事件直接写 stdout:绕过统一渲染,工具输出、编辑器和流式文本互相覆盖,所有可见内容都应先进入组件状态。每个 token 清空整屏:能跑,但长内容闪烁、破坏 scrollback,应该先合并渲染请求,再做差分。用字符串长度算列宽:英文 demo 看不出问题,一输入中文就错位。组件持有 Agent 客户端:渲染、网络和生命周期耦合在一起;组件只接收数据,由应用层订阅 Agent 并更新组件。忘记恢复终端:崩溃后 shell 停在 raw mode;用 finally、exit 处理器和幂等的 stop() 兜底。在 render() 里做昂贵计算:渲染在交互路径上,token 统计、会话扫描、文件访问要在事件处理阶段做完,组件只格式化结果;昂贵的版式和高亮按宽度和内容缓存,在 invalidate() 里清除。

如果自己从零做,推荐顺序是:静态 render(width): string[] → Container 纵向组合 → Terminal 启停与恢复 → 全量重绘 → 焦点与最小编辑器 → 16 ms 渲染合并 → 简单差分 → 事件适配层(按 id 定位组件)。Kitty 键盘协议、图片、overlay 栈、全屏视口、主题热更新都来自真实需求,等需求出现再加。

常见追问

为什么不用 Ink(React for CLI)这类现成框架? render(width): string[] 这个契约足够小,差分直接在行上做,不需要虚拟 DOM 和 reconciler;Agent 场景的瓶颈在长消息的 Markdown/高亮,这些靠组件内部缓存解决,比通用 diff 更可控。依赖也只有三个小包。

全屏模式的代价是什么?为什么还保留 regular? 程序接管视口后,滚动、选择、复制、搜索、链接都得自己实现,而且受终端鼠标上报质量影响(比如 iTerm2 快速滚动、Ghostty 的链接悬停提示)。regular 模式把这些交还给终端,在 tmux、远程会话或偏好原生 scrollback 时更稳。两个渲染器实现同一个接口,切换不影响组件。

主屏幕模式下为什么"变化在视口之上"就要全量重绘? 终端 scrollback 里的内容程序改不了,光标也移不过去,只能清屏重画。这是主屏幕模式的固有限制,也是全屏模式改由程序拥有视口的动机之一。

快照式 message_update 会不会让长消息越来越慢? 会,所以 1.5 把复用下沉到了组件和 Markdown 层:只重渲染变化的最后一块,工具参数增量解析。协议仍然是快照,正确性不依赖增量。

扩展想画自定义 UI,该怎么接? 优先用 ctx.ui.select/confirm/input/notify/setWidget/custom(),它们自动拿到当前主题和快捷键;确实需要自定义渲染时才写组件,但不要自己再起一个 TUI 实例(见 扩展系统)。

源码入口(v1.5):packages/tui/src/tui.ts、packages/tui/src/tui-main-screen.ts、packages/tui/src/tui-alt-screen.ts、packages/tui/src/terminal.ts、packages/coding-agent/src/modes/interactive/interactive-mode.ts、packages/coding-agent/src/modes/interactive/components/assistant-message.ts