Zero 架构 · 第六课
前五课都在讲 agent.Run —— 一个会阻塞、会流式吐字、
靠回调(OnText/OnToolCall…)对外说话的循环。而 Zero 的
交互界面建在 Bubble Tea 上,那是一套 Elm 架构:单线程、消息驱动、
Model → Update → View 严格轮转。这一课只回答一个问题:这两种世界观怎么接到一起?
对齐 mission:一个真实的 coding agent,难点从来不只是「让模型干活」,还有「把一个长时间运行、 随时要用户拍板的后台任务,平滑地画进一个每秒刷新的终端界面」。这一课就是那座桥。
tea.Msg 进 Update,返回新 model」。Update 是单线程的,
绝不能在里面阻塞 —— 阻塞了整个界面就冻住。矛盾很直接:agent 要阻塞,UI 循环不能阻塞。Zero 用三个动作化解。
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:4197、
model.go:4600
options.OnText = func(delta string) { m.sendAgentText(runID, delta) }
// sendAgentText:
m.runtimeMessageSink(agentTextMsg{runID: runID, delta: delta})
OnToolCall→agentRowMsg、OnReasoning→agentReasoningMsg、
OnUsage→agentUsageMsg… 每类流事件都有一个对应的私有 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 的尾音不会污染新画面。这是异步桥接的必备卫生。
单向的「吐字→画面」好办。难的是双向: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 从容继续。
Update。UI 循环全程没有卡 ——
它只是画了个弹窗、收了个按键。真正「等」的是后台那条线,而它等得起。ctx.Done()
那一路则保证:哪怕用户始终不答、或直接取消,goroutine 也能脱身而不泄漏。OnAskUser
(model.go:4248)用的是一模一样的
channel-会合模式,只是载荷从「权限决定」换成「问答答案」。
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 会合)—— 这一对就是整座桥的两种形态。
Cmd/Msg/Batch 到底怎么调度的?」、
「View 返回的是什么、4657 行的 model 怎么组织渲染的?」,
或者「取消一次 run(Ctrl-C)时 ctx 是怎么把后台 goroutine 干净地掐断的?」