主题
第 11 章:视图映射与终端状态渲染
:::
阅读建议:如果时间有限,聚焦“用户能看到什么、能交互什么”的产品映射逻辑,即可理解 TUI 的核心价值;Bubble Tea 的 Model/Update/View 代码、ANSI 渲染样式适合动手编码时回读。
:::
当你在终端敲下 claude 或者使用类似的高级 CLI Agent 时,屏幕上并不是呆板的滚动日志。你看到的是:流式打字机的效果、随着思考旋转的 Spinner、实时刷新的进度条,甚至还有高亮的语法代码块和交互式的选择菜单。对于工业级 Agent 产品,核心逻辑决定能力上限,终端界面则直接影响可用性与用户信任——它是用户判断“能否信任 Agent 输出”的交互层。
下面结合 Go 与 Bubble Tea 框架,构建一套具备差异化渲染能力的 TUI(Terminal User Interface,终端用户界面) 引擎:在命令行环境中展示 Agent 的实时状态、思考过程与操作结果,让用户时刻掌握全局,而非盯着密集滚动的日志猜测 Agent 在干什么。
11.1 边界界定与架构分层
TUI 与 CLI 常被混为一谈,但职责边界应当分明。终端界面不只是把日志排版得好看,它是用户判断“能否信任 Agent 输出”的交互层:流式思考、工具进度、HITL 审批弹窗,都直接参与信任建立。视图层只负责渲染与用户输入;CLI 工程(cobra 命令树、配置加载、GoReleaser 打包分发)负责二进制如何构建与交付。二者通过 UIBridge 解耦:RunLoop 只发事件,不依赖具体 UI 实现。划清这条边界,同一套 Agent 核心可换 TUI、HTTP 或 Web 前端,而不必重写编排逻辑。
11.2 终端输出流梳理
在 Agent 执行过程中,大模型的 Stream 流和后台并发工具的日志是交织在一起的。如果你用最基础的并发:
go
go func() {
for chunk := range llmStream {
fmt.Print(chunk)
}
}()
go func() {
for log := range bashTool {
fmt.Println(log)
}
}()运行结果是屏幕被撕裂了:模型打字到一半...[工具:依赖安装成功]...模型继续打字...。用户根本看不清句子。
这种“撕裂”的根因在于 TTY 的无帧缓冲、逐字符写入模型。与 GUI 的双缓冲不同,终端输出走的是 stdout 文件描述符,内核将其映射到 PTY 驱动,驱动逐字节/逐行转发到终端模拟器。两个 Goroutine 并发调用 fmt.Print 时,每次 write syscall 的原子性只保证单次 write 调用内的字节序不交错,但两次 write 之间没有任何互斥,输出流在逻辑上被 interleave 成 chunk1_partA + log_line + chunk1_partB。
更深层的问题:终端没有“帧”的概念。GUI 每 16ms 提交一帧完整画面,而 TTY 是流式 append-only,每个字符到达即渲染,光标位置随 write 推进。并发 write 不仅交错文本,还破坏 ANSI 转义序列的完整性(例如一个 Goroutine 写到 \033[31m 的一半,另一个 Goroutine 插入 plain text,导致颜色状态机错乱)。这就是 MVU 架构存在的根本理由:所有输出必须收口到单线程 View 渲染,保证每一“帧”是完整、一致的字符串。
11.3 MVU 架构与视图状态设计
要解决上述输出交错问题,需引入前端开发中成熟的状态驱动理念。在 Go 生态中,Charmbracelet 团队的 Bubble Tea 框架是事实上的标准终端 UI 框架;Claude Code(Ink)、Pi(自研事件驱动 TUI)等产品也采用了类似的单向数据流思路。
Bubble Tea 的核心是 MVU(Model-View-Update) 单向数据流:Model 集中保存全局状态;Update 接收所有并发事件(包装为 Message)并返回新 Model;View 则由框架触发,根据当前 Model 算出一整屏字符串,覆盖终端原有内容,三者之间不允许反向依赖或旁路写入,从而避免并发事件导致界面“撕裂”。
Agent 终端场景下,LLM 流式输出、并发工具日志与 HITL 弹窗交织在同一输出流上,11.2 节已说明 TTY 的无帧特性使并发 write 不可避免地撕裂 ANSI 序列。React 的增量 diff 在此无能为力:DOM 允许局部更新,但 TTY 是 append-only 流。MVU 用三条不变量从根源消除竞态:所有事件经单一 channel 由 runtime 串行调用 Update;Update 返回 Model 副本而不是就地修改,View 读到的永远是完整快照;副作用封装为 Cmd 由 runtime 调度,Update 保持纯函数。Bubble Tea 不做增量 diff,而是每帧生成完整 terminal frame 再覆写,换来的是可预测性:用户看到的每一屏都是同一 Model 快照的投影。
11.3.1 全局视窗状态设计
一个终端 Agent 的界面至少需要三个交互区域:滚动查看历史对话的文本区、等待模型响应时的加载动画、以及供用户输入追问或审批意见的文本框。Bubble Tea 生态已经为这三者提供了标准组件,我们的工作是将它们与业务状态(历史记录、流式缓冲、审批上下文等)组合成一个统一的顶层 Model。
下面的 AgentTerminal 结构体组合了 Bubble Tea 生态中三个现成的 bubbles 子组件:viewport.Model(可滚动文本区域)、spinner.Model(等待动画)和 textinput.Model(单行输入框),再加上自定义的业务状态字段:
go
type AgentTerminal struct {
opts TerminalOptions
viewport viewport.Model
spinner spinner.Model
feedback textinput.Model
history []historyEntry
streamBuffer string
isStreaming bool
approval *approvalState
width int
height int
turn int
tokens int64
cost float64
err error
}
type historyEntry struct {
kind, name, content string
done, success bool
}
type approvalState struct {
toolName, arguments string
risk domain.RiskLevel
respond func(bool, string)
}11.3.2 并发事件统一处理机制
这是 MVU 的核心。不管后台有多少 Goroutine 在干活,它们都要通过 tea.Msg 与主线程通信:
go
// 各种并发源抛出的事件
type LLMStreamMsg struct{ Delta string }
type LLMDoneMsg struct{ FullText string }
type ToolStartMsg struct{ Name string }
type ToolFinishMsg struct{ Name string; Success bool; Output string }
type ApprovalRequestMsg struct {
ToolName, Arguments string
Risk domain.RiskLevel
Respond func(bool, string)
}
type AgentDoneMsg struct{}
type AgentErrorMsg struct{ Err error }
type TurnUpdateMsg struct{ Turn int; Tokens int64; Cost float64 }
func (m AgentTerminal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.viewport.Width = max(20, msg.Width-4)
m.viewport.Height = max(4, msg.Height-5) // 页眉、状态栏与帮助栏
return m, nil
case tea.KeyMsg:
if m.approval != nil {
return m.handleApprovalKey(msg)
}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
case LLMStreamMsg:
m.streamBuffer += msg.Delta
m.isStreaming = true
m.refreshViewport()
return m, nil
case LLMDoneMsg:
m.history = append(m.history, historyEntry{kind: "assistant", content: msg.FullText, done: true, success: true})
m.streamBuffer = ""
m.isStreaming = false
return m, nil
case ToolStartMsg:
m.history = append(m.history, historyEntry{kind: "tool", name: msg.Name})
return m, nil
case ToolFinishMsg:
// finishTool 反向查找同名且未完成的 historyEntry,回填执行状态与输出摘要
m.finishTool(msg.Name, msg.Success, msg.Output)
return m, nil
case ApprovalRequestMsg:
m.approval = &approvalState{msg.ToolName, msg.Arguments, msg.Risk, msg.Respond}
m.feedback.SetValue("")
m.feedback.Focus()
return m, nil
case TurnUpdateMsg:
m.turn, m.tokens, m.cost = msg.Turn, msg.Tokens, msg.Cost
return m, nil
case AgentDoneMsg:
return m, tea.Quit
case AgentErrorMsg:
m.err = msg.Err
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}tea.Program 的 Update 在单 Goroutine 中执行,但 Agent Loop 运行在独立的后台 Goroutine 中,两者的协作模型是生产者-消费者 + 线程安全消息通道。后台 Goroutine 通过 p.Send(msg) 投递事件;多个 Goroutine 可以安全调用 Send,Update 仍逐条串行处理消息。不要依赖某个固定缓冲容量,它不是 Bubble Tea 对外承诺的 API。
主循环的每一 tick:select 监听 channel → 取出一条 Msg → 调用 Update → 调用 View → 将 View 输出写入 stdout。这意味着即使 LLM Stream 以毫秒级频率产生 Delta、同时三个 Tool 并发汇报状态,UI 线程看到的仍是严格串行的 Msg 序列。Update 函数本身不需要加锁,单线程保证消除了所有 data race。Cmd 返回的副作用(如 spinner 定时器)由 runtime 注册,到期后产生新的 TickMsg 再次入队,同样走串行路径。
这种模型的代价是 UI 响应延迟受 Update 耗时约束,如果 Update 中做了重计算(如渲染 500 行 Markdown),会阻塞后续 Msg 的处理。因此 refreshViewport() 只在 history、streamBuffer、工具状态或窗口尺寸变化时执行;普通滚动键不触发 Markdown 重渲染。Agent Loop 侧只负责 p.Send(),不直接操作视图状态。
11.3.3 交互表单与弹窗控制
go
func (m AgentTerminal) handleApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "y", "Y":
m.approval.respond(true, "")
m.approval = nil
m.feedback.Blur()
return m, nil
case "n", "N", "esc":
m.approval.respond(false, "user rejected without specific feedback")
m.approval = nil
m.feedback.Blur()
return m, nil
case "enter":
feedback := strings.TrimSpace(m.feedback.Value())
if feedback == "" { feedback = "user rejected without specific feedback" }
m.approval.respond(false, feedback)
m.approval = nil
m.feedback.Blur()
return m, nil
}
var cmd tea.Cmd
m.feedback, cmd = m.feedback.Update(msg)
return m, cmd
}HITL 审批的键盘处理与第九章“用户附加 Feedback 驱动自愈”形成闭环。按 Y 放行;按 N/Esc 以默认原因拒绝;直接输入原因再按 Enter,则携带具体反馈拒绝。Respond 回调最终唤醒 HITLArbitrator.RequestApproval 的等待端,外层收齐决议后调用 Continue,拒绝项作为 tool observation 回流模型。
反馈输入由 bubbles/textinput 承载;这些文字会直接成为下一 turn 的 tool 消息。同步边界位于 TUI 与 resolveSync 之间,而不是 Execute 内部——工具在人点击批准之前尚未执行。
11.4 渲染引擎与样式高亮
View 函数的职责很单纯:把 Model 投影成字符串。至于如何让这串字符稳定地“贴”在终端上而不闪烁,则交给 ANSI 覆写机制。
ANSI 全屏覆写让 Agent 界面在视觉上接近 GUI 的整屏刷新:程序启动时切换到 Alt Screen (\033[?1049h),退出后(\033[?1049l)shell 历史原样保留,用户不会在 ANSI 残码中找 prompt。每帧渲染前光标回到 (0,0),View 返回的完整字符串逐行写入,旧的 Spinner、进度条、半行流式输出被同一帧覆盖,而不是在 scrollback 里堆叠。Bubble Tea 默认全量重绘而不是 lazygit 式的 diff 重绘:实现更简单,modern terminal 上通常 <16ms 完成一帧,人眼几乎察觉不到闪烁;代价是超长对话时带宽略高,但 Agent 场景下帧内容变化频繁,diff 的收益未必明显。
全量重绘的关键约束:View 必须是纯函数,View(model) → string,相同 Model 产生相同输出。任何依赖外部 mutable state 的 View 都会导致帧间不一致。输出字符串中的 \n 推进光标到下一行,\033[K(清除至行尾)处理新帧比旧帧短时的残留字符,Bubble Tea 框架在 Write 前自动追加行清除序列。
go
func (m AgentTerminal) View() string {
if m.approval != nil {
return m.approvalView() // 居中的双线边框模态框,包含 feedback textinput
}
header := headerStyle.Render("GO-CODER") + mutedStyle.Render(
fmt.Sprintf(" %s · %s · %s", m.opts.Model, m.opts.WorkDir, m.opts.Permission))
status := statusStyle.Width(max(1, m.width-2)).Render(
fmt.Sprintf("TURN %d TOKENS %d COST $%.6f", m.turn, m.tokens, m.cost))
help := mutedStyle.Render("↑/↓ scroll • g/G top/bottom • q quit")
return lipgloss.NewStyle().Padding(0, 1).Render(
header + "\n" + m.viewport.View() + "\n" + status + "\n" + help)
}11.4.1 会话历史渲染方案
本实现把模型回复与工具卡统一保存为 []historyEntry。模型回复经 Markdown 渲染;工具条目始终显示文字状态 RUNNING、SUCCESS 或 FAILED,同时配合图标与颜色,避免只靠红绿区分。工具输出在 UI 层截断为摘要,完整结果仍保存在 AgentContext 中供下一轮推理使用。
11.4.2 代码块与 Markdown 语法高亮实现
Agent 的输出通常夹杂大量代码块,修改建议、diff 片段、命令输出。纯文本渲染会让代码与自然语言混为一团,严重降低可读性。glamour 库将 Markdown 源码经 goldmark 解析为 AST,再遍历节点生成带 ANSI SGR 转义序列的终端富文本。对于 fenced code block (围栏代码块),glamour 内部调用 chroma 语法高亮引擎,根据语言标识符(如 ```go、```python)选择对应的 Lexer 做词法着色。
go
rendered, err := glamour.Render(entry.Content, "dark")
if err != nil {
rendered = entry.Content
}“dark” 是 glamour 内置的暗色主题,适配大多数深色终端配色方案;可替换为 “light”、“notty”(纯文本,适合管道输出)或自定义 JSON 样式文件。glamour 的渲染输出是定宽字符串,每行已包含 ANSI 颜色码和空白 padding,直接写入 viewport 即可。需注意:glamour 对中文等宽字符的列宽计算依赖 go-runewidth,若终端字体的 CJK 宽度与库假设不一致(如某些 Nerd Font),代码块右边界可能错位,此时可通过 glamour.WithWordWrap(0) 关闭内置折行,由外层 lipgloss 统一处理。
11.4.3 自适应宽度的折行处理
终端窗口宽度千差万别,从 80 列的 SSH 远程到 200+ 列的超宽屏。如果渲染内容不适配实际列宽,要么右侧截断丢失信息,要么长行回绕导致代码缩进错乱。lipgloss 的 Width(n) 约束配合 Bubble Tea 的 WindowSizeMsg 实现自适应。
go
case tea.WindowSizeMsg:
m.width = msg.Width
m.viewport.Width = max(20, msg.Width-4)
m.viewport.Height = max(4, msg.Height-5)每当用户拖拽终端窗口或通过 tmux 分屏改变尺寸,Bubble Tea 推送 WindowSizeMsg,Update 更新 viewport 尺寸,下一帧 View 使用新宽度重新渲染。lipgloss 的 Style.Width(m.width) 对自然语言文本做单词边界折行,保证不在单词中间断开;对代码块则保留原始缩进,仅在超出列宽时做字符级截断并追加省略标记。两种策略通过 Style.MaxWidth() 与 glamour 的 WordWrap 参数分别控制,自然语言注重可读性,代码注重结构完整性。
go
func (m AgentTerminal) renderHistory() string {
var sb strings.Builder
for _, entry := range m.history {
switch entry.kind {
case "assistant":
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(max(20, m.viewport.Width-2)),
)
if err != nil { sb.WriteString(entry.content); continue }
rendered, err := renderer.Render(entry.content)
if err != nil {
rendered = entry.content
}
sb.WriteString(rendered + "\n")
case "tool":
label := "RUNNING"
if entry.done && entry.success { label = "SUCCESS" }
if entry.done && !entry.success { label = "FAILED" }
content := entry.content
if len(content) > 800 {
content = content[:800] + "…"
}
sb.WriteString(toolCardStyle.Render(
fmt.Sprintf("%s · %s\n%s", label, entry.name, content)) + "\n\n")
}
}
// 正在流式输出的内容
if m.isStreaming && m.streamBuffer != "" {
sb.WriteString(m.renderMarkdown(m.streamBuffer))
sb.WriteString(m.spinner.View())
}
return sb.String()
}LLM 返回的 Markdown 不能直接 fmt.Print,代码块、标题、引用需要转成带 ANSI 颜色的终端文本,还要适配窄屏列宽,并在百行对话中只显示可见窗口。这条链路通常分三步走:先用 glamour 把 Markdown 解析为 goldmark AST 并生成富文本 ANSI 串(此时输出可能宽达数百列);再用 lipgloss 施加 width、padding、border,把无界文本折行到终端宽度;最后交给 viewport 做滑动窗口,SetContent() 持有全部逻辑高度,YOffset 决定当前可见片段,GotoBottom() 在流式输出时把视口钉在底部。数据流为 Markdown → glamour.Render() → lipgloss.Style.Width(w).Render() → viewport.SetContent() → viewport.View();viewport 不重算 glamour,只对已渲染字符串切片,滚动时开销可控。
11.5 循环与视图桥接
RunLoop 在后台 Goroutine 中驱动“推理→工具→状态推进”循环,而 Bubble Tea 的事件循环跑在主线程上独占 TTY。两者不能共享同一线程——RunLoop 会阻塞几十秒等待 LLM 响应,若在主线程执行将冻结所有 UI 刷新。因此集成的核心问题是:如何跨 Goroutine 将 Agent 状态变更安全地投递给 UI?
答案是:通过 p.Send() 单向投递消息。我们在 RunLoop 的关键节点埋入事件发射点:
go
type UIBridge struct {
program *tea.Program
}
func (b *UIBridge) OnStreamDelta(delta string) {
b.program.Send(LLMStreamMsg{Delta: delta})
}
func (b *UIBridge) OnStreamDone(fullText string) {
b.program.Send(LLMDoneMsg{FullText: fullText})
}
func (b *UIBridge) OnToolStart(name string) {
b.program.Send(ToolStartMsg{Name: name})
}
func (b *UIBridge) OnToolFinish(name string, success bool, output string) {
b.program.Send(ToolFinishMsg{Name: name, Success: success, Output: output})
}
func (b *UIBridge) OnTurnUpdate(turn int, tokens int64, cost float64) {
b.program.Send(TurnUpdateMsg{Turn: turn, Tokens: tokens, Cost: cost})
}
func (b *UIBridge) OnAgentError(err error) {
b.program.Send(AgentErrorMsg{Err: err})
}UIBridge 实现了 Agent Loop 与 UI 主线程之间的生命周期桥接。组合根先构造 tea.Program 与 Bridge,再启动 Agent goroutine,最后在主 goroutine 调用 p.Run();Program 创建时消息通道已经就绪。成功和失败都必须投递终态消息,否则 Agent 已结束而 TUI 仍会停在 event loop 中。
Agent 报错时的退出路径是:RunLoop 返回 error → bridge.OnAgentError(err) → p.Send(AgentErrorMsg{Err: err}) → Update 保存错误并返回 tea.Quit。成功路径发送 AgentDoneMsg。HITL 反向链路由组合根转发:读取 gate.Approvals(),包装成带 Respond 回调的 ApprovalRequestMsg;用户的键盘决议经回调送回仲裁器,RunLoop 才从 Paused 继续。Agent 与 TUI 都不直接修改对方状态,如图 11-1 所示。

在 RunLoop 中使用 StreamChat 而不是 Chat,逐 Token 桥接到 UI:
go
// RunLoopStreaming 是带流式 UI 桥接的 ReAct 循环,与 RunLoop 共享完整八阶段防护
func (a *Agent) RunLoopStreaming(ctx context.Context, agentCtx *AgentContext, bridge UIBridge) error {
for {
// 阶段零:物理中止检查
if err := ctx.Err(); err != nil {
agentCtx.Transition = Transition{Reason: "Context_Canceled", Detail: err.Error()}
return err
}
if agentCtx.TurnCount >= agentCtx.MaxTurns {
agentCtx.Transition = Transition{Reason: "Max_Turns_Exceeded"}
return ErrMaxTurns
}
// 阶段一:上下文卫生
agentCtx.CurrentState = StateCompacting
_ = a.memory.CompressIfNeeded(ctx, agentCtx)
// 阶段二:输出触顶恢复
if agentCtx.OutputTruncated {
agentCtx.AppendMessage(Message{
Role: "user",
Content: "[SYSTEM] Your previous output was truncated. Continue where you left off.",
})
agentCtx.OutputTruncated = false
}
// 阶段三:调用大模型(流式)
agentCtx.CurrentState = StateThinking
stream, err := a.llm.StreamChat(ctx, domain.ChatRequest{
Messages: agentCtx.GetMessages(),
Tools: a.tools.GetDefinitions(),
})
if err != nil {
if a.handleAPIError(agentCtx, err) { continue }
return err
}
// 流式消费:逐 Token 推送到 UI
var fullText strings.Builder
var toolCalls []domain.ToolCall
var finishReason string
var usage domain.Usage
for evt := range stream {
if evt.Err != nil {
return evt.Err
}
if evt.Delta != "" {
fullText.WriteString(evt.Delta)
bridge.OnStreamDelta(evt.Delta)
}
if evt.ToolCall != nil {
toolCalls = append(toolCalls, *evt.ToolCall)
}
if evt.Usage != nil {
usage = *evt.Usage
}
if evt.Done {
finishReason = evt.FinishReason
break
}
}
bridge.OnStreamDone(fullText.String())
agentCtx.AppendMessage(Message{
Role: "assistant", Content: fullText.String(), ToolCalls: toolCalls,
})
// 阶段四:检查输出是否被截断
if finishReason == "length" {
agentCtx.OutputTruncated = true
agentCtx.EscalationAttempts++
if agentCtx.EscalationAttempts > 3 {
return errors.New("output repeatedly truncated, cannot recover")
}
agentCtx.TurnCount++
continue
}
agentCtx.EscalationAttempts = 0
// 阶段五:是否需要执行工具?
if len(toolCalls) == 0 {
agentCtx.CurrentState = StateFinished
return nil
}
// 阶段六:执行工具(通知 UI)
agentCtx.CurrentState = StateExecuting
for _, tc := range toolCalls {
bridge.OnToolStart(tc.Name)
}
results := a.tools.ExecuteBatch(ctx, toolCalls)
hasError := false
for i, tr := range results {
bridge.OnToolFinish(toolCalls[i].Name, !tr.IsError, tr.Output)
agentCtx.AppendMessage(Message{
Role: "tool", Content: tr.Output, ToolCallID: tr.ToolCallID, IsError: tr.IsError,
})
if tr.IsError { hasError = true }
}
// 阶段七:熔断检测
if hasError {
agentCtx.ConsecutiveFails++
if agentCtx.ConsecutiveFails >= 3 {
agentCtx.CurrentState = StateWaitingHITL
agentCtx.AppendMessage(Message{
Role: "system",
Content: "SYSTEM ALERT: 3 consecutive failures. Take a different strategy.",
})
}
} else {
agentCtx.ConsecutiveFails = 0
}
if detectLoopDeath(agentCtx.GetMessages()) {
return ErrLoopDeadlock
}
// 阶段八:状态推进 + 持久化
agentCtx.TurnCount++
bridge.OnTurnUpdate(agentCtx.TurnCount, agentCtx.TokensUsed, calculateCost(agentCtx.TokensUsed))
_ = agentCtx.PersistCheckpoint()
}
}RunLoopStreaming 与第六章的 RunLoop 共享完整的八阶段防护,两条路径只有调用大模型的方式不同(Chat vs StreamChat),其余阶段行为一致。第九章之后,阶段六在 ExecuteBatch 前会无阻塞 Decide:需要人审则 Run 返回 Paused,外层 settle 把 TUI 问完再 Continue;上面摘录为可读性仍画出执行主路径。流式路径通过 StreamEvent.FinishReason 字段(由第五章 SSE 解析器传递)获得输出截断信号,从而复用触顶恢复(阶段二/四)、连续失败熔断(阶段七)和死循环检测等防护逻辑。若省略这些防护,流式模式下 Agent 遇到 finish_reason: “length” 时会静默丢失内容,或在同义反复中无限循环。
StreamChat 的 event 可携带 Delta、ToolCall、Usage、Provider、Err、FinishReason 或 Done。消费循环分别累计正文、工具调用和最终 Usage;任何 Err 都立即终止本轮。Done 携带的 FinishReason 决定后续分支。Usage 与实际 Provider 一起进入 RecordCost,即使 Fallback 换了供应商也按真实模型计费,再由 TurnUpdateMsg 更新状态栏。
背压处理在本架构中由 Go channel 逐级传递:stream 是一个 <-chan StreamEvent,LLM provider 的 HTTP 读取 Goroutine 是生产者,RunLoop 是消费者。UI 处理慢时,p.Send() 可能阻塞,继而让 RunLoop 暂停消费 stream,最终通过 TCP receive window 向上游施加背压。这里不假设 Bubble Tea 内部队列的具体容量。用户按 Ctrl+C 时,ctx.Done() 取消 HTTP 请求并关闭 response body,RunLoop 随显式 StreamEvent.Err 退出。
calculateCost 的 Token → USD 映射基于 provider 的定价表:按 input/output token 分别计价(例如 Claude Sonnet input $3/M tokens, output $15/M tokens)。agentCtx.TokensUsed 通常追踪的是累计 total tokens (input + output),简化实现可用 blended rate (加权平均单价);精确实现应分别累计 inputTokens 和 outputTokens,各自乘以单价后相加。TurnUpdateMsg 中的 Cost 字段使 UI 状态栏实时展示 burn rate,帮助用户感知“这一轮的思考有多贵”,这是 Agent 产品 transparency 的关键指标。
11.6 视口滚动与超长流处理
如果大模型输出了一段 500 行的超长代码,整个终端的可视区会迅速超出承载范围。通过 bubbles/viewport,我们限制文本的最大渲染高度。当文本超长时:
- 界面下方固定不动。
- 文本在视口内部“往上顶”。
- 用户可以在大模型正在输出的时候,按 PageUp/PageDown 或滚动鼠标查看历史对话。
go
func NewAgentTerminal(opts TerminalOptions) AgentTerminal {
vp := viewport.New(80, 18)
vp.MouseWheelEnabled = true
feedback := textinput.New()
feedback.Placeholder = "可选:输入拒绝原因后按 Enter"
feedback.CharLimit = 500
sp := spinner.New()
sp.Spinner = spinner.Dot
return AgentTerminal{
opts: opts, viewport: vp, feedback: feedback, spinner: sp,
width: 80, height: 24,
}
}Stick-to-bottom 是 Agent 产品 TUI 的关键交互策略。新内容到达前先记录 followOutput := m.viewport.AtBottom();刷新内容后只有 followOutput 为真才调用 GotoBottom()。因此默认跟随最新输出,而用户一旦上滚查看历史,后续 Delta 不会把视口强行拉回底部。
实现上,viewport 需提供 AtBottom() 判定:YOffset >= contentHeight - viewportHeight - threshold。Update 中仅在 AtBottom() 为 true 时调用 GotoBottom()。LLMDoneMsg 处理完成流式输出后,同样检查此条件。这种“默认跟随、手动打断”的策略,在多数长会话中效果良好;不过 threshold 取值与终端行高因环境而异,部分窄屏模拟器上 AtBottom() 判定可能需要微调,否则用户刚上滚一行就被强制拉回底部。
这种解耦设计使得底层 Agent Loop 可在不改动核心逻辑的前提下,封装为 HTTP 后端或 Web 前端。TUI 只是其中一种视图适配层。
本章小结:
终端交互层常被低估为“美化日志”,实则是 Agent 产品建立用户信任的前线:MVU 把 LLM 流式输出、并发工具与 HITL 弹窗收口到单线程 View,终结 TTY 撕裂;UIBridge 让 RunLoop 通过 p.Send() 无侵入投递事件,同一套核心可换 TUI 或 Web 视图。流式打字机效果与 Stick-to-bottom 策略,使用户在长任务中仍能感知进展,而不是盲等。
至此,状态机、工具箱、记忆压缩与终端交互四层已齐备;下一章将为这套系统打开可观测性黑盒,并在第十三章完成最终组装。