Claude Code 101|12|TUI Observability
Claude Code 的产品体验来自定制终端 UI 和多层可观测系统:流式渲染、权限交互、任务进度、OTel、session tracing 和 Perfetto。
Claude Code 是一个 Agent Runtime,但用户每天接触到的不是 runtime 抽象,而是终端里的交互体验。模型是否正在思考,工具是否正在运行,权限为什么被询问,后台任务有没有进展,上下文是否被压缩,teammate 是否在等待输入,这些中间状态如果不可见,用户就只能面对一个“卡住的命令行”。
与此同时,工程团队面对的是另一类可见性问题:哪次模型请求慢,哪个工具调用失败,哪个 hook 阻塞,哪个 session 成本异常,哪个 exporter 污染 stdout,哪个 span 没有关联到 interaction。复杂 Agent 系统如果没有可观测性,调试会迅速变成猜测。
这一篇把 TUI 和 Observability 放在一起,是因为它们解决同一个问题的两个侧面:runtime event 如何被投影。TUI 面向用户,把事件变成可理解的交互状态;telemetry 面向工程系统,把事件变成可追踪、可聚合、可分析的数据。
工程问题
简单命令行程序可以按顺序打印文本:收到输入,执行函数,输出结果。但 Agent Runtime 不是这种线性程序。一次用户请求可能产生流式 assistant delta、thinking、tool use、tool result、permission request、progress update、compact boundary、background task notification、error recovery 和 telemetry span。
如果把这些事件直接 console.log 出去,会遇到很多问题:
- 流式 token 会和工具进度互相穿插。
- 权限对话需要暂停并接收用户选择。
- 后台任务进度需要更新而不是重复刷屏。
- tombstone 或 compact boundary 需要修改已有 UI 状态。
- JSON stream 或 MCP 协议输出不能被调试日志污染。
- 工程侧需要 trace,而用户侧不需要看到全部 span。
因此 Claude Code 需要两个投影层:一个是终端应用层,把 runtime state 渲染成稳定的 TUI;另一个是 observability 层,把 runtime 行为记录成 telemetry、session tracing 和性能时间线。
概念边界
上一篇 Subagents and Swarm 讨论多 Agent 如何成为多个受治理的执行上下文。TUI Observability 是系列最后一层:当 runtime 已经具备输入编译、Agent Loop、工具、权限、Memory、Resume 和多 Agent,最后必须回答“人和工程系统如何观察它”。
这不是附加功能。对 Agent 产品来说,可观察状态本身就是产品能力。没有 TUI,用户无法信任长时间运行的 Agent;没有 telemetry,开发者无法维护不断扩展的 runtime。
Claude Code 这个案例把用户可见界面与工程可观测数据放在同一条 runtime event 投影链上:终端应用负责让人看见和干预,telemetry / tracing 负责让工程团队维护和演进系统。
机制一:REPL 是终端应用,不是 readline 循环
Claude Code 的交互入口不是简单读取一行输入再打印一段输出。src/screens/REPL.tsx 是一个高度状态化的 React/Ink 应用。它整合 prompt input、message list、permission dialogs、task list、teammate view、IDE integration、command queue、background housekeeping、session hooks、MCP / tools / commands 合并和 telemetry span。
这意味着用户看到的“终端聊天”其实是 runtime state 的持续渲染。输入框、消息流、工具卡片、权限弹窗、后台任务、选择器、错误提示,都由同一个应用状态驱动。
这种架构解决的是交互复杂度。Agent Loop 可能在等待模型流式输出,也可能在等待用户批准工具调用;后台任务可能同时完成;teammate 可能发来 permission request;用户可能输入 slash command 或取消请求。传统 stdout 追加模型无法优雅处理这些并发事件,而 React-style TUI 可以把它们统一成状态变化。
机制二:定制 Ink 把终端变成虚拟屏幕
终端不是浏览器。它没有 DOM,没有天然布局树,没有组件更新机制,光标、选择、resize、alt screen、鼠标追踪都需要自己处理。Claude Code 的 src/ink/ 说明它并不只是使用现成终端输出,而是有定制渲染层:React reconciler、Yoga layout、双 buffer frame、screen diff、alt screen、mouse tracking、selection overlay、search highlight、declared cursor、terminal title/tab status 等。
这类设计的核心目标是把终端当作虚拟屏幕,而不是输出流。renderer 可以维护 previous screen 和 next screen,通过 diff 更新变化区域;可以在 resize 或 SIGCONT 后重绘;可以处理 selection 和 search highlight;可以避免 contaminated frame 破坏 UI。
对 Agent 产品来说,这很关键。权限弹窗不能被流式 token 冲走,后台进度不应该每秒刷出一堆新行,搜索和选择需要稳定屏幕模型,任务列表需要就地更新。没有虚拟屏幕抽象,复杂 Agent UI 会退化成日志瀑布。
机制三:runtime event 到 UI state 的投影
Agent Loop 产生的是事件,不是最终文本。TUI 的职责是把事件投影到 UI state。src/screens/REPL.tsx 中围绕 query event、message 更新、任务状态、permission request 和 telemetry 的逻辑说明,UI 层需要理解事件语义,而不是盲目打印。
几个典型事件的处理方式不同:
- assistant delta 需要更新正在流式输出的消息。
- tool_use 需要创建工具卡片,并可能关联 permission 状态。
- tool_result 需要附着到对应 tool_use,而不是新增无关消息。
- progress event 往往是 ephemeral,应替换上一条进度而不是无限追加。
- tombstone 表示旧消息需要删除或隐藏。
- compact boundary 表示上下文历史发生结构变化,需要特殊展示。
- background task notification 需要进入任务区域或消息流。
这说明流式 UI 不是“打印 token”。它是事件归并、状态更新和屏幕 diff 的组合。Agent 的可用性很大程度取决于这些中间态是否被正确投影。
机制四:权限交互是 UI 与 runtime 的同步点
权限系统在前文已经讨论过风险建模,但在 TUI 里它表现为交互同步点:模型想执行某个工具,runtime 不能继续,直到用户批准、拒绝或选择持久规则。
src/components/permissions/PermissionRequest.js、src/screens/REPL.tsx 中的 permission queue、src/hooks/toolPermission/handlers/swarmWorkerHandler.ts 和 leader permission bridge 相关模块说明,权限请求不仅来自主 Agent,也可能来自 swarm worker 或 teammate。UI 必须能展示请求来源、工具意图、风险信息和可选动作,并把用户决定返回正确的执行上下文。
这类交互不能简单 prompt()。因为权限请求可能发生在流式输出期间,可能有多个 worker 等待,也可能需要同步到远端或 pane 中的 teammate。TUI 层必须成为 runtime 的协作部件,而不是输出终端。
机制五:Telemetry 是多层系统,不只是埋点
Claude Code 的可观测性不只是记录“用户点了什么”。src/utils/telemetry/、src/utils/telemetryAttributes.ts 和 src/utils/telemetry/sessionTracing.ts 展示了多层 telemetry 结构:产品事件、OpenTelemetry logs / metrics / traces、session tracing、interaction span、llm_request span、tool span、hook span,以及不同 exporter。
Agent Runtime 需要回答的问题比普通 CLI 多:
- 模型请求耗时多久,失败在哪里。
- 工具调用耗时多久,是否被权限阻塞。
- 用户等待时间花在模型、工具、hook 还是 UI。
- 后台任务消耗多少 token,执行了多少工具。
- 某个 session 的错误是否集中在特定 provider 或 model。
- compact、resume、subagent 是否引入性能退化。
这些问题需要 trace 级别的关联,而不是孤立日志。Session tracing 用 AsyncLocalStorage 一类机制把 interaction、llm_request、tool、hook 等 span 串起来,让工程团队能看到一次用户交互内部的时间线。
机制六:协议输出不能被 telemetry 污染
CLI 产品有一个浏览器应用较少遇到的问题:stdout 可能是用户界面,也可能是机器协议。Claude Code 支持 formatted output、JSON stream、SDK 或其他集成场景时,stdout 上的任何额外日志都可能破坏协议。
草稿中提到某些 formatted output 场景会移除 console exporter,这背后的原则很重要:observability 不能改变产品语义。Telemetry exporter 必须知道当前输出通道是否可写调试信息,必要时把日志发到 stderr、文件、OTel collector 或完全关闭 console 输出。
这也是 CLI Agent 与 web app 的差异。Web app 的 console log 很少破坏 API 响应;CLI 的 stdout 往往就是 API 响应。Agent Runtime 如果要作为自动化工具被调用,必须严格隔离人类 UI、机器输出和工程 telemetry。
机制七:Redaction 与 cardinality 是可观测性的治理层
Agent telemetry 很容易采集到敏感信息:用户 prompt、文件路径、代码片段、工具参数、错误栈、账号标识。与此同时,metrics 系统又害怕高基数字段:session.id、account uuid、完整路径、动态工具参数都会让指标成本和查询质量失控。
src/utils/telemetryAttributes.ts 和 telemetry 相关配置体现了这类治理:prompt 默认 redacted,某些属性是否进入 metrics 受配置控制,session、version、account 等字段需要区分 trace 价值和 metrics 成本。
这说明可观测性不是“越多越好”。成熟 Agent 要在调试价值、用户隐私、数据成本和协议稳定性之间做取舍。尤其是 Coding Agent,输入和工具参数经常包含私有代码、内部服务名或凭据片段,默认最小化采集才是安全基线。
机制八:Perfetto 与本地时间线分析
除了远端 telemetry,复杂 runtime 还需要本地性能时间线。草稿中提到 Perfetto tracing / Chrome Trace Event 格式,这类能力适合分析一次交互内部的细粒度时序:UI render、模型请求、工具执行、hook、文件 IO、后台任务是否互相阻塞。
远端 metrics 告诉你“整体变慢了”,本地 trace 告诉你“慢在哪里”。对终端 Agent 来说,性能问题可能来自很多层:模型流式延迟、工具进程、文件扫描、React render、screen diff、telemetry exporter、MCP server、permission 等待。没有时间线,优化只能靠猜。
TypeScript-style pseudocode
下面用伪代码抽象 runtime event 的双重投影:
type RuntimeEvent =
| AssistantDelta
| ToolUseStarted
| ToolUseFinished
| PermissionRequested
| PermissionResolved
| BackgroundTaskProgress
| ContextCompacted
| ErrorRecovered
type ProjectionContext = {
ui: TerminalUiState
screen: VirtualScreen
tracer: SessionTracer
telemetry: TelemetrySink
outputMode: 'interactive' | 'json' | 'quiet'
}
async function projectRuntimeEvent(event: RuntimeEvent, ctx: ProjectionContext) {
const span = ctx.tracer.startSpan(eventToSpanName(event), redact(event.attributes))
try {
switch (event.type) {
case 'assistant_delta':
ctx.ui.messages.updateStreaming(event.messageId, event.delta)
break
case 'tool_use_started':
ctx.ui.tools.add({ id: event.toolUseId, name: event.toolName, status: 'running' })
break
case 'tool_use_finished':
ctx.ui.tools.finish(event.toolUseId, event.resultSummary)
break
case 'permission_requested':
ctx.ui.permissions.enqueue(event.request)
await ctx.ui.permissions.waitForDecision(event.request.id)
break
case 'background_task_progress':
ctx.ui.tasks.replaceEphemeralProgress(event.taskId, event.progress)
break
case 'context_compacted':
ctx.ui.messages.insertBoundary(event.summary)
break
}
if (ctx.outputMode === 'interactive') {
ctx.screen.renderDiff(ctx.ui)
}
ctx.telemetry.record(event.name, redact(event.attributes), {
avoidStdout: ctx.outputMode !== 'interactive',
controlCardinality: true,
})
} finally {
span.end()
}
}
这个伪代码表达了三个原则:UI 更新和 telemetry 记录来自同一 runtime event;敏感属性在进入观测系统前要 redaction;输出模式决定 telemetry 是否能触碰 stdout。
工程启发
第一,把 Agent UI 设计成状态投影,而不是日志输出。只要系统存在流式输出、工具调用、权限、后台任务和 compact,stdout 追加就会失效。虚拟屏幕、消息归并和事件语义是必要基础设施。
第二,让中间状态可见。用户等待时最需要知道 Agent 在做什么:思考、读文件、跑命令、等权限、等后台任务,还是 compact。可见状态能显著提高信任,也能减少用户误取消。
第三,telemetry 要围绕 interaction 建模。孤立的工具耗时或模型耗时价值有限;把一次用户请求内部的模型、工具、hook、UI 和后台任务串成 trace,才能定位真实瓶颈。
第四,CLI 输出通道必须分层。人类 TUI、机器可读 stdout、stderr 日志、OTel exporter、trace 文件不能混用。任何 console exporter 都要知道当前是否处于协议输出模式。
第五,默认 redaction,控制 cardinality。Agent 处理的内容天然敏感,可观测性要先安全再丰富。对 metrics 来说,高基数字段不是细节,而是系统稳定性问题。
第六,多 Agent 会放大可观测性需求。一个主会话已经复杂;加入 background task、subagent、teammate 和 swarm worker 后,如果没有任务列表、permission bridge 状态和 trace 关联,用户与开发者都会失去全局视图。
小结
Claude Code 的 TUI Observability 可以概括为一套 runtime event projection 系统。REPL 和定制 Ink 把事件投影成终端里的交互状态;telemetry、session tracing 和 Perfetto 把事件投影成工程可分析的数据;redaction、cardinality 和 stdout 隔离则保证观测系统不破坏隐私、成本和协议语义。
至此,Claude Code 101 的后半部分形成闭环:工具连接真实工程边界,Memory 管理长期知识,Session Resume 恢复运行时状态,Subagents and Swarm 扩展执行上下文,TUI Observability 让复杂 runtime 对用户和工程团队可见。一个成熟 Coding Agent 不是 CLI + LLM API + tools,而是一套可持久、可治理、可恢复、可观察的 Agent Runtime。