Agent Engineering 101|15|Interactive TUI

Interactive TUI 是 Product Shell:它消费 AgentEvent,负责流式 Markdown、工具动画、输入历史、slash commands 和 CJK/IME 光标体验,但不拥有 Agent Runtime。

Agent Engineering 101|15|Interactive TUI

核心结论

配套代码仓库: github.com/llm-101/mini-agent-harness

读完本文,你应该能回答

  • TUI 为什么属于 Product Shell,而不是 Agent Loop?
  • AgentEvent 如何映射成流式文本、工具动画和状态栏?
  • 取消、输入历史、slash commands 如何进入交互层?
  • 为什么 UI 应该消费事件,而不是拥有运行时状态?

本篇在系列中的位置

  • 上一篇:14 Policy Extensions 说明了运行时治理点。
  • 本篇:本文进入产品外壳:TUI 如何消费事件流,而不污染 core runtime。
  • 下一篇:16 Packages and SDK 会把稳定 Harness 边界变成可分享能力。

贯穿例子

本系列会反复使用同一个任务来连接各章:

用户说:“帮我修复这个 repo 里的 failing tests。”

在这个任务里,Interactive TUI 把“正在读文件、测试运行中、工具失败、上下文已压缩、最终通过”等事件变成用户看得见的界面状态。读者要关注的是:UI 消费事件流,不拥有 Agent Runtime。

  • Interactive TUI 是运行时之上的产品外壳。它通过 async generator 消费 AgentEvent,并把事件映射成 ChatMessage 视图模型。
  • 终端 UI 很容易把运行时逻辑、模型调用和渲染状态写在一起。这样 UI 一变,Agent 核心也被迫变化。
  • mini-agent-harness 中,Interactive TUI 只消费 AgentEvent;这让 UI 可以复杂,但 runtime 仍然保持可复用。

定义

Interactive TUI 是运行时之上的产品外壳。它通过 async generator 消费 AgentEvent,并把事件映射成 ChatMessage 视图模型。

为什么要单独看这一层?

用户需要看到长任务的过程,但 UI 细节不应该进入 runtime core。TUI 的作用,是把事件流翻译成可理解的交互体验。

Interactive TUI Product Shell

边界

这一层的职责可以拆成几个稳定部分:

  • Fullscreen Layout:height={rows},messages flexGrow,composer 固定底部
  • Event Consumer:for await harness.stream()
  • Streaming Markdown:stable-prefix 优化增量渲染
  • Tool UI:spinner、完成态、并行分组
  • Input System:自定义 useTextInput 与 history
  • Cursor Parking:ESC[row;colH 改善 CJK/IME

这一层的边界可以用一个问题检验:如果它的内部实现变化,模型适配、工具执行、状态存储和产品外壳是否都不需要跟着重写?如果答案是否定的,说明这个边界还没有真正收束变化。

代码锚点

本篇主要对应这些模块:

  • src/cli/components/App.tsx
  • src/cli/components/MessageList.tsx
  • src/cli/components/Markdown.tsx
  • src/cli/hooks/useTextInput.ts
  • src/cli/hooks/useParkCursor.ts
  • src/cli/print-mode.ts

阅读代码时建议先看类型,再看运行路径。

类型定义告诉你这一层暴露什么 contract;运行路径告诉你这个 contract 在 Agent 执行中何时被消费、何时被写回、何时被产品层看见。

运行流程

Event Stream to Terminal UI

一次典型执行可以概括为:

  1. User Submit:Composer
  2. harness.stream:AgentEvent
  3. State Update:ChatMessage[]
  4. Render:MessageList
  5. Abort:Escape -> AbortController
  • Print Mode:stdout/stderr
  • TUI Mode:Ink UI
  • Future Shell:SDK/RPC/Web

这里最容易被忽略的是“中间态”。生产级 Agent 不是只关心最终答案;它还要在运行过程中展示进度、捕获错误、记录 usage、允许取消,并把可恢复状态写回 session。

读者抓手:Event 如何变成 TUI 状态

AgentEvent TUI 展示 为什么不写进 core
text_delta 流式 Markdown 渲染策略属于产品层
tool_started spinner / tool card 动画不是运行时事实
tool_finished 成功/失败状态 core 只需要记录结果
usage_update token/成本提示 不同 UI 可选择不同呈现
compaction “已压缩上下文”提示 用户体验提示不应污染 session
error 错误面板 / retry 按钮 recovery UI 是产品外壳职责

TUI 是 Harness 的窗口,不是 Harness 的大脑。

可迁移伪实现:事件驱动 TUI

下面的伪代码是机制抽象,不对应真实 API 或文件结构。它只用来说明这一层的控制点:

for await (const event of harness.stream(input, signal)) {
  if (event.type === "message_update") appendAssistantText(event.text);
  if (event.type === "tool_call_start") showToolSpinner(event);
  if (event.type === "tool_call_end") markToolDone(event);
}

这个草图的价值在于说明控制点,而不是提供可复制的库代码。

真正的工程实现还要处理错误、取消、并发、token 预算、日志、权限、序列化和 provider 差异。

工程原则

将这一层从 Agent 系统中拆出来,通常带来四个直接收益。

第一,可替换。

外部系统、模型 provider、工具集合或产品外壳变化时,核心运行时不必整体重写。

第二,可观测。

边界清晰后,事件、trace、usage、错误和状态迁移都有稳定落点。

第三,可恢复。

只要状态写入 session,运行时就可以在进程重启、工具失败或长任务中断后继续推理。

第四,可治理。

权限、脱敏、审批、路径保护和执行策略可以放在稳定 hook 或 runtime boundary 上,而不是只靠 prompt 约束。

和 Agent Harness 的关系

Agent = Model + Harness 这个公式的重点,不是把模型之外的所有东西都称为“工程杂活”。

相反,它提示我们:模型之外存在一套必须被设计的运行时系统。

Interactive TUI 就是这套系统中的一个切面。它不替代模型能力,也不替代产品体验;它让模型能力可以被组织成可执行、可观察、可恢复、可治理的任务流程。

小结

Interactive TUI 的核心价值,是把一类容易扩散的复杂性收束到明确边界中。

mini-agent-harness 中,这个边界被刻意写得较小,方便阅读和教学。但它对应的问题并不小:只要一个 Agent 要长期运行、调用工具、管理上下文、支持 UI、保存状态并处理失败,这个边界就会出现。

下一步可以继续沿着系列计划,把这些边界组合成完整 Agent Harness:模型边界、工具边界、状态边界、上下文边界、扩展边界和产品外壳边界。