终端应用需要一个DOM
agent-tui 是一个开源工具,它为终端应用提供了类似浏览器的结构化查询接口,使AI代理能够像操作网页元素一样操作终端屏幕,支持稳定的引用、状态等待和屏幕区域标识。
在构建C1的软件工厂Squire时,团队遇到了一个略显荒诞的问题:AI工具也是为人类构建的。Squire可以给代理分配工作,但Claude Code、Codex、Pi以及类似的AI框架都以终端应用的形式呈现。它们的实时界面是为人类设计的TUI:一个提示符、流式响应、批准界面、文件变更面板以及等待下一个指令的光标。另一个代理可以向这个界面输入内容,但它仍然需要知道响应是否结束、是否出现了批准界面,或者光标是否已经返回到提示符。
这就是agent-tui所解决的问题。它在PTY上运行目标程序,在守护进程中保持终端状态,将渲染的屏幕作为文本或带有稳定引用的结构大纲暴露出来,并允许客户端进行快照、按键和等待命名的屏幕状态。它为终端应用提供了与浏览器自动化同样有用的可查询表面。
agent-tui是开源的,采用Apache-2.0许可证发布于GitHub。其设计源于团队在agent-browser上的经验:赋予代理可查询的内容,而非一堆像素。agent-tui将此理念应用于终端应用。
一个常见的Squire模式是编排代理:一个编码框架接收任务,然后驱动另一个框架完成工作。在这个演示中,OpenAI的Codex使用agent-tui驱动Pi框架进行真实的终端会话。agent-tui启动外部的Codex TUI,等待@codex.input,输入任务并按下回车。然后Codex运行一系列命令,启动第二个agent-tui守护进程来围绕Pi。Pi端只是另一个agent-tui会话。结果是在两个实时屏幕上:一个显示Codex接收任务,另一个显示Pi在嵌套会话中回答。
Vercel的AI SDK将框架代理CLI视为特定于提供商的表面,而不是一个通用的包装器。agent-tui采用相同的方法处理终端屏幕:保留每个应用的形状,然后暴露代理可以查询的部分。
为什么终端应用需要结构?大多数有用的终端程序是为人类而非机器构建的。htop、vim、lazygit、psql、语言REPL以及较新的代理CLI(如Claude Code和Codex)各有不同的任务。它们共享一个自动化问题:实时界面是终端会话。它拥有一个PTY,重绘单元格网格,并期望人们推断出变化。有些工具提供批处理模式,但许多并不提供。即使存在批处理模式,它通常也是与需要观察、中断或引导的人类会话不同的界面。
代理可以轻松地向终端写入字节。难点在于知道这些字节落地后发生了什么。全屏程序可能会原地重绘、移动光标、进入备用屏幕、更新一个字段,并且从不打印一行干净的"就绪"状态。
通常的选择会与终端形成糟糕的契约。转义序列解析将字节流视为API。渲染文本抓取会丢弃状态。按键之间休眠将问题抛给调度器,这意味着脚本在CI缓慢或某个提示符进入未匹配状态时会失效。
给终端一个DOM。Vim是一个很好的压力测试:它是一个全屏编辑器,而不是打印行的命令。在这里,agent-tui通过引用而不是休眠来驱动真实的Vim会话。在记录中,左侧窗格发出agent-tui命令,右侧窗格是Vim PTY。驱动等待缓冲区,读取模式,进入插入模式,写入hello world,保存hello-world.txt,并检查文件内容。
原始的终端文本不适合这个任务。agent-tui暴露了一个大纲:一个带有角色和稳定引用的屏幕区域树。引用是屏幕上某物的名称。它允许代理说"Vim模式指示器"而不是"第24行第1列"。@vim.mode是持久的。无论值是普通、插入还是其他,它都命名屏幕的同一部分。引用可以使用小型选择器语言进行查询。内置的vim和shell适配器会发出命名的引用,如@vim.mode和@shell.prompt。TOML清单可以教agent-tui另一个应用的区域,而无需添加Rust代码。即使没有适配器,通用适配器仍然将屏幕分组为粗粒度区域并赋予引用。
团队参考了浏览器自动化的模式,agent-browser已经很好地服务于他们的代理。浏览器代理不应点击像素(412, 308);而应点击按钮。终端代理不应依赖固定行,而agent-tui可以命名模式、提示符、焦点窗格或表格。
等待屏幕状态。快照只有在知道何时拍摄时才有用。sleep 0.2是对调度、终端重绘和被测程序的猜测。在快速机器上太长,在负载过重的运行器上太短。更糟糕的是,它与关心的状态无关。wait子命令与屏幕状态绑定。它可以等待引用出现、引用消失、选择器值、正则表达式、事件序列或子进程退出。对于尚未命名的引用屏幕,客户端可以拍摄快照,保留其屏幕哈希,发送输入,并等待渲染网格发生变化。
以下是完整的vim编辑过程,没有任何休眠:
- spawn启动vim
- wait等待缓冲区存在
- press i进入插入模式
- wait等待模式变为插入
- type输入文字
- press
<Esc>离开插入 - wait等待模式变回普通
- press
:wq保存退出
每个命令等待下一个可观察的转换。按键后,脚本不会假设vim已准备好接收文本。它等待直到解析的模式是插入。离开插入后,它等待直到模式再次变为普通。
引用避免了常见的误报。正则表达式可以在缓冲区中匹配文字单词"insert"。等待@vim.mode[value=insert]监视Vim解析的模式字段。它不是查看任意的屏幕文本。
也可以等待消失。wait --ref '@vim.cmdline[focused]' --gone阻塞直到命令提示符关闭。对于终端测试,这通常是"可能完成"和"UI状态改变"之间的区别。
回退到渲染文本。并非每个应用都有适配器或有用的状态信号。htop是一个很好的例子:它没有JSON模式,有用的输出通常就是渲染的屏幕。agent-tui可以spawn htop,使用wait --idle 500等待屏幕停止变化,然后以文本模式快照。对于没有更好信号的应用,使用--idle;对于有结构的应用,使用引用和选择器。
那么tmux和expect呢?明显的疑问是这是否只是tmux send-keys加上capture-pane,或者是对expect的封装。它们很有用,但停留在更低的层次。tmux capture-pane提供渲染网格的文本,但不提供角色、命名区域或持久句柄。tmux send-keys可以写入输入,但对后续屏幕状态没有意见。expect是面向行的,适合打印提示符和行的程序,不适合全屏ncurses应用。agent-tui位于字节流之上和代理之下,读取终端状态,为屏幕各部分分配名称,并允许调用者等待这些名称。
当stdout足够时使用stdout。并非每个命令都需要实时终端。如果程序已经有非交互模式,正确的接口仍然是stdin、stdout、stderr和退出码。run子命令用于应返回数据的命令。它为代理提供类型化、带日志的包装,同时PTY自动化专注于实时屏幕。run也面向暴露非交互模式的AI CLI,因此一个模型的答案可以成为另一个步骤的输入,而无需屏幕抓取提示符。ask是该路径的简短封装。
实时AI CLI会话有两种路径。对于一次性答案,使用数据路径。对于人类终端会话,使用PTY路径。提供商清单将提示符和响应暴露为屏幕区域。适配器从渲染单元格命名提示符和响应,而不读取提供商转录API。知道流式答案在跨所有AI CLI中何时结束仍然需要提供商特定的事件或侧信道。这种分离很重要,因为它保持两种情况的分离:当子进程已经是数据产生进程时使用run,当子进程是交互式屏幕时使用spawn、snapshot、press和wait。
捕获工件。实时会话也会产生文件。守护进程将每个窗格记录为asciicast-v3格式,与asciinema生态兼容。agent-tui使用相同的cast作为测试输入。replay不启动原始程序,而是将记录的输出字节送入新的终端引擎,并比较渲染的快照。演示会话可以成为回归测试输入。对于屏幕截图,snapshot可以将当前网格渲染为PNG,可选的注释和边框用于文档。