Zero 架构 · 第六课

TUI:让阻塞的 agent 和单线程 UI 循环相处

前五课都在讲 agent.Run —— 一个会阻塞、会流式吐字、 靠回调(OnText/OnToolCall…)对外说话的循环。而 Zero 的 交互界面建在 Bubble Tea 上,那是一套 Elm 架构:单线程、消息驱动、 Model → Update → View 严格轮转。这一课只回答一个问题:这两种世界观怎么接到一起?

对齐 mission:一个真实的 coding agent,难点从来不只是「让模型干活」,还有「把一个长时间运行、 随时要用户拍板的后台任务,平滑地画进一个每秒刷新的终端界面」。这一课就是那座桥。
配套架构图 边读边看官方图更直观:交互式 TUI 流程图TUI 处理详细序列图(回调 → tea.Msg 的完整往返)、TUI 交互时序(含排队输入)。全部图见课程目录末尾。

核心张力:两种时间观

矛盾很直接:agent 要阻塞,UI 循环不能阻塞。Zero 用三个动作化解。

动作一:agent 跑在一个 tea.Cmd

Bubble Tea 的 Cmd 就是「func() tea.Msg」—— 框架会在后台 goroutine 里执行它,执行完把返回的 Msg 投回 Update。Zero 把整个 agent.Run 包进这样一个 Cmd: model.go:4104

func (m model) runAgentWithOptions(...) tea.Cmd {
    return func() tea.Msg {          // ← 在后台 goroutine 运行
        ...
        result, err := agent.Run(runCtx, prompt, m.provider, options) // loop.go 的循环
        ...
    }
}

提交它的地方在用户回车后:tea.Batch(m.runAgent(...), m.spinner.Tick) model.go:3931 —— agent 在后台跑,spinner 在前台照常转。Update 从不等 agent;它只是发射了一个 Cmd 就返回了。

动作二:回调 → 消息(单向流)

agent 在后台吐字,怎么让画面动?入口在 Run 的启动处 run.go:28 —— 每个从 runtime 冒出来的消息都被灌进 program.Send:

options.RuntimeMessageSink = func(msg tea.Msg) {
    ...
    if program != nil { program.Send(msg) }   // 唯一合法的「外部→UI」通道
}

于是 TUI 把 agent 的每个回调都改写成「造一条 tea.Msg 丢进 sink」。比如流式文本: model.go:4197model.go:4600

options.OnText = func(delta string) { m.sendAgentText(runID, delta) }
// sendAgentText:
m.runtimeMessageSink(agentTextMsg{runID: runID, delta: delta})

OnToolCallagentRowMsgOnReasoningagentReasoningMsgOnUsageagentUsageMsg… 每类流事件都有一个对应的私有 Msg 类型 (model.go:393 起一长串)。 Update 收到后才真正改 model: model.go:1553

case agentTextMsg:
    if msg.runID != m.activeRunID { return m, nil }  // ← 丢弃过期 run 的消息
    m.streamingText += msg.delta
    ...
为什么要 runID 守卫 消息是异步到达的。用户可能已经 Ctrl-C 取消了这次 run、又发起了新的一次;旧 goroutine 在被 ctx 掐断前还会吐出几条残余消息。每条消息都带 runID,Update 一进门就比对 m.activeRunID,不匹配直接丢弃 —— 这样陈旧 run 的尾音不会污染新画面。这是异步桥接的必备卫生。

动作三:要用户拍板时 —— 用 channel 会合

单向的「吐字→画面」好办。难的是双向:agent 要请求权限,必须停下等用户点 「允许 / 拒绝」,拿到答复才能继续。但 agent 在后台 goroutine,用户的点击在 UI 线程 —— 两条 时间线要在此会合。Zero 的做法是一个 channel: model.go:4215

options.OnPermissionRequest = func(ctx, request) (Decision, error) {
    decisionCh := make(chan agent.PermissionDecision, 1)
    m.sendPermissionRequest(runID, request, func(d) { decisionCh <- d })  // 把「问题」丢进UI
    select {
    case decision := <-decisionCh:   // ← agent goroutine 在这里阻塞等答复
        return decision, nil
    case <-ctx.Done():               // ← 但 ctx 取消能把它唤醒,不会永久卡死
        return denyDecision, ctx.Err()
    }
}

读一遍这段时间线:agent goroutine 造一个 permissionRequestMsg(含一个回调闭包) 丢进 sink,然后阻塞<-decisionCh 上。UI 线程照常 Update、 把权限弹窗画出来、等用户按键;用户一选,Update 调用那个闭包,把决定送进 decisionCh。agent goroutine 立刻被唤醒,agent.Run 从容继续。

为什么这样才对 关键在:阻塞发生在 agent goroutine,而不在 Update。UI 循环全程没有卡 —— 它只是画了个弹窗、收了个按键。真正「等」的是后台那条线,而它等得起。ctx.Done() 那一路则保证:哪怕用户始终不答、或直接取消,goroutine 也能脱身而不泄漏。OnAskUser (model.go:4248)用的是一模一样的 channel-会合模式,只是载荷从「权限决定」换成「问答答案」。

顺带一提:入口的 fail-fast

Run 开头先做一件朴素但重要的事:确认 stdin 真是个 TTY。 run.go:21 因为 Bubble Tea 在管道/重定向输入下会永久阻塞等永远不来的事件(想想 echo "" | zero)。 与其挂死,不如立刻退出并指向 headless 路径(zero exec)。「fail closed」—— 凡不是 已验证的终端就拒绝进交互壳。

一句话总结这座桥

阻塞的 agent  ──跑在──▶  tea.Cmd (后台 goroutine)
    │ 回调                    │ 返回值
    ▼                         ▼
RuntimeMessageSink ──▶ program.Send ──▶ Update(msg) ──▶ 新 model ──▶ View
    ▲                                       │
    └────────── channel 会合(权限/问答)◀───┘

单向的流(文本/工具/用量)是「回调造消息、Send 投递、Update 消费」;双向的拍板(权限/问答) 是「goroutine 阻塞在 channel、Update 填答案唤醒它」。整套 UI 状态永远只被 Update 一个 地方修改 —— 后台 goroutine 从不直接碰 model。这就是 Elm 架构和阻塞任务和平共处的全部秘密。

动手回忆

agent.Run 这个会阻塞的循环,在 TUI 里跑在哪?

agent 后台流式吐出的文本,怎么变成画面更新?

agent 请求权限、必须等用户答复时,阻塞发生在哪里?

接下来该读的一手源码

less +14   ../zero/internal/tui/run.go        # Run 入口:TTY 检查 + sink 包 program.Send
less +4104 ../zero/internal/tui/model.go      # runAgentWithOptions:agent 装进 tea.Cmd
less +4197 ../zero/internal/tui/model.go      # 回调→Msg 的改写(OnText/OnToolCall/…)
less +4215 ../zero/internal/tui/model.go      # OnPermissionRequest:channel 会合
less +1553 ../zero/internal/tui/model.go      # Update 消费 agentTextMsg + runID 守卫

读的顺序:先看 run.go 那 60 行,认出 RuntimeMessageSink 就是唯一的 「外部→UI」阀门;再跳到 runAgentWithOptions,看 agent.Run 怎么被包进 func() tea.Msg;最后对读 OnText(单向)和 OnPermissionRequest (双向 channel 会合)—— 这一对就是整座桥的两种形态。

我是你的老师 —— 随时问我。 适合现在追问: 「Bubble Tea 的 Cmd/Msg/Batch 到底怎么调度的?」、 「View 返回的是什么、4657 行的 model 怎么组织渲染的?」, 或者「取消一次 run(Ctrl-C)时 ctx 是怎么把后台 goroutine 干净地掐断的?」