UI 层,终端里的 React
终端程序用 React 写界面听起来有些绕,实际方案是 Ink,一个把 React 组件树渲染成终端字符的渲染器。这份源码更进一步,vendor 了一份自己的 Ink 分支放在 src/ink/,外面再包一层主题系统。动机写在用法里,工具渲染对排版有各种定制需求,上游 Ink 的发布节奏跟不上,干脆自己带着改。REPL 主屏是个五千行的 React 组件,持有全部消息状态,终端里滚动的每一行都在它的 state 里,消息列表、加载动画、权限弹窗、输入框,全是这棵组件树的枝叶。
主循环和这块界面怎么配合,靠的是第 2 章埋的伏笔。query 生成器只 yield 消息事件,REPL 里 for await 逐个收到,转手交给 onQueryEvent,它再调 handleMessageFromStream 把事件分拣成对新状态的计算,最后 setMessages 触发 React 重渲染。数据单向流动,事件到状态到界面,界面从不回头改循环。压缩边界这类特殊消息在分拣时有专门分支,决定旧消息是保留可回滚还是清屏。
同一个生成器的另外两个消费方画风完全不同。SDK 的无头模式走 QueryEngine,没有界面,事件流转成结构化的 SDK 消息流吐给宿主程序。子代理消费是第三种,收到消息写 sidechain 记录,攒够结果收工。三个消费方加上 hook 的 agent 型执行,一共四种场景复用同一份循环代码,这份复用是核心层对 UI 零依赖换来的。反方向依赖是允许的,UI 随便 import 核心模块,只要核心不反过来。
界面的丰富表现还有一半功劳在工具自己。第 4 章讲过每个工具带一族渲染方法,工具执行到哪一步、结果长什么样,由工具提供的渲染函数画。进度消息、思考过程、权限弹窗,各有专门的组件族,都在 components 目录下按消息类型组织。权限询问的弹窗是一个典型的 React 交互组件,等用户按键期间,整个消息循环天然停在 yield 点上,用户选完,生成器才继续走。async generator 的暂停语义在这里提供了免费的同步点,界面等待不需要任何额外的锁或者队列协议。
输入侧最后补几句。用户提交新输入时若上一轮还在跑,有一套排队机制把命令挂起,等当前轮结束按序注入,任务通知类的消息也有独立的排队通道。通知到达时若前台正忙,REPL 会现组装一个全新的工具上下文,重新取系统提示词,把通知连同当前消息一起交给一个后台会话处理,系统提示词照例先冻结成快照再传,防的又是缓存失效。这套机制保证了任何时刻只有一个主循环在跑,界面上看到的并发,其实全是被编排过的串行加并发工具。
还有一个构建层面的分流值得一提。源码里散布着按构建口味生效的条件代码,同一份代码编出的内部版和外部版功能集合不同,一些提示性组件只在内部版加载。结合第 12 章的特性开关看,分层是从构建期就开始的。至此十二章主干走完,回头再看第 1 章那张分层图,入口、界面、核心、服务每一层都能对上具体的文件和函数,这份源码也就读顺了。