03 · 终端渲染器
监听事件流,把 Agent 状态实时输出到终端——用简单的 console.log 做出清晰的交互体验。
渲染器的职责
有了事件流,还需要一个东西把事件变成用户能看懂的输出——终端渲染器。
渲染器的工作很简单:监听事件,输出文字。 它不修改 agent 的任何状态,只是一个纯粹的消费者。
设计原则:
- 只用
console.log,不依赖任何终端 UI 库。 在课程后续章节中,如果需要更丰富的体验(spinner、进度条),可以替换渲染器实现。 - 每个事件一行输出。 保持简洁,不让终端变得混乱。
- 用图标区分类型。 在不支持颜色的终端中也能区分不同事件。
TerminalRenderer 实现
在 src/ui/renderer.ts 中实现渲染器:
import type { AgentEvent, AgentPhase } from "./events";
export class TerminalRenderer {
private currentPhase: AgentPhase = "thinking";
handleEvent(event: AgentEvent): void {
switch (event.type) {
case "agent_start":
this.renderStart(event.task);
break;
case "model_call":
this.currentPhase = event.phase;
this.renderModelCall(event.phase);
break;
case "tool_call_start":
this.renderToolCallStart(event.tool, event.args);
break;
case "tool_call_result":
this.renderToolCallResult(event.tool, event.result, event.approved);
break;
case "permission_ask":
this.renderPermissionAsk(event.tool, event.args, event.reason);
break;
case "todo_update":
this.renderTodoUpdate(event.todos);
break;
case "agent_done":
this.renderDone(event.stats);
break;
}
}
// ... 各个私有渲染方法
}handleEvent 是入口方法,根据事件类型分发到对应的渲染方法。每个渲染方法只负责把自己的事件格式化为一行输出。
各事件的输出样式
agent_start: 截断过长的任务描述,显示任务开始。
>> 开始处理: 搜索所有 TypeScript 文件中的 TODO 注释model_call: 用不同图标表示不同阶段。
... 思考中 (thinking)
> 执行操作 (acting)
? 验证结果 (verifying)
= 完成 (done)tool_call_start: 工具名加上关键参数的预览。
> search: 关键词
> read_file: src/main.ts
> run_command: npm testtool_call_result: 显示工具结果的预览。被拒绝的调用用 x 标记。
+ search: 找到 3 个文件
+ read_file: import { Model } from "./model"
x run_command: 操作被拒绝todo_update: 用方括号图标显示每个步骤的状态。
计划更新:
[x] #1 搜索入口文件
[>] #2 读取文件内容
[ ] #3 分析代码结构agent_done: 显示统计数据。
= 完成 [3次模型调用, 5次工具调用]工具参数的格式化
不同工具的参数结构不同,渲染时只需要提取最关键的信息。formatToolDetail 方法根据工具名决定显示什么:
private formatToolDetail(tool: string, args: Record<string, unknown>): string {
switch (tool) {
case "search":
return String(args.query ?? "");
case "read_file":
return String(args.path ?? "");
case "run_command": {
const cmd = String(args.command ?? "");
return cmd.length > 60 ? `${cmd.slice(0, 60)}...` : cmd;
}
default:
return "";
}
}这样 tool_call_start 事件的输出就有了上下文——不只显示"search",还显示"搜索什么关键词"。
结果的截断处理
工具结果可能非常长(比如 read_file 返回整个文件内容)。渲染器只取结果的第一行,并且限制在 80 个字符以内:
private renderToolCallResult(tool: string, result: string, approved: boolean): void {
if (!approved) {
console.log(` x ${tool}: 操作被拒绝`);
return;
}
const firstLine = result.split("\n")[0] ?? "";
const preview = firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine;
console.log(` + ${tool}: ${preview}`);
}截断是渲染器的责任,不是工具的责任。工具返回完整的结果给模型(模型需要完整信息做判断),渲染器只负责给人类看的预览。
测试渲染器
测试渲染器的方式是 mock console.log,然后检查输出内容:
it("渲染 agent_start 事件", () => {
renderer.handleEvent({ type: "agent_start", task: "测试任务" });
expect(logs[0]).toContain("开始处理");
expect(logs[0]).toContain("测试任务");
});完整的测试文件在 test/ui/renderer.test.ts 中,覆盖了所有事件类型的渲染输出、长文本截断、拒绝标记等场景。
渲染器的设计取舍
为什么不直接在 agent 里 console.log? 把渲染逻辑写在 agent 里会违反单一职责——agent 既要做任务,又要管输出。将来想换渲染方式就得改 agent 代码。用事件 + 渲染器的模式,agent 和 UI 完全解耦。
为什么不用 chalk/ink 等库? 课程现阶段保持零依赖。等后续章节需要更丰富的终端体验时,只需替换渲染器实现,agent 和事件系统不需要任何修改。
为什么跳过 model_response 事件? 模型的文本输出可能很长,在简单渲染器中逐行显示会淹没工具调用信息。如果后续需要显示模型的"思考过程",可以升级渲染器来处理这个事件。
下一节把事件系统、渲染器和 agent 串起来,完成完整的实时交互体验。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。