Files
analysis_claude_code/learn-pi-agent/s10_runtime_modes

s10: Runtime Modes — 同一个 core不同的展示

core 只管产生,怎么展示外层说了算。 Pi 边界:运行方式边界 —— core 产生事件,展示方式是外层的事,换展示不改 core。

上一节s09s10下一节s11


问题

前面几节里core 一产生结果就直接打印出来——展示方式写死在代码里。

但"展示"这件事,不同场景要的不一样:给人看,要人类可读的文本;给别的程序看,要结构化的 JSON以后可能还要 GUI 渲染。如果展示方式写死在 core 里,每换一种就得复制或改动 core。

core 只该管产生什么,怎么展示应该分离出去。


解决方案

core 把要做的事变成一批 RuntimeEvent,外层用一个 RuntimeMode 决定怎么展示。

createDemoRuntimeEvents()  →  RuntimeEvent[]  →  RuntimeMode.render()

同一个 core、同一批事件接不同的 mode 就有不同输出:

mode 展示成
PrintMode 人类可读文本(只打印 message
JsonMode 结构化 JSON每事件一行

[R7 收获] 回想 s01那时候 core 不直接 console.log,而是走了一层 Output.log。那是一个最小的"输出抽象"种子。s10 把它正式化、可切换了——同一个 core 的事件,想打印就 PrintMode想 JSON 就 JsonModecore 一个字都不用改。

这里不是替换 s01-s09 的 runEventedToolLoop。为了让本节输出短一点demo 用 createDemoRuntimeEvents() 造一批最小事件;真正的主线里,这批事件来自前面已经累积出来的 core。


工作原理

先准备一批事件。 createDemoRuntimeEvents 把输入变成一批最小 RuntimeEvent。它只是本节的演示事件源不是新的主循环。

export function createDemoRuntimeEvents(input: string): RuntimeEvent[] {
  return [
    { type: "message", content: `收到:${input}` },
    { type: "done" },
  ];
}

mode 消费事件。 RuntimeMode 只有一个方法 render。PrintMode 挑出 message 打印文本JsonMode 把每个事件序列化成 JSON。

export type RuntimeMode = { render(events: RuntimeEvent[]): void };

export class PrintMode implements RuntimeMode {
  render(events) {
    for (const event of events) {
      if (event.type === "message") console.log(event.content);
    }
  }
}

export class JsonMode implements RuntimeMode {
  render(events) {
    for (const event of events) console.log(JSON.stringify(event));
  }
}

这一节真正建立的是运行方式边界core 产生事件,展示是外层 mode 的事。RuntimeEvent 是 core 对外的"输出语言"mode 是"翻译器"。换展示方式只是换 modecore 不动——这正是 s01 那层 Output 抽象要长成的样子。


试一下

运行:

npm run s10

输出类似:

s10: Runtime Modes

[print mode]
收到你好mini Pi

[json mode]
{"type":"message","content":"收到你好mini Pi"}
{"type":"done"}

观察重点:两种输出来自同一批事件——[print mode] 只显示了 message 内容,[json mode] 把每个事件都序列化了,包括 done


接入主线

s10 在 s09 上累积。相对 s09 的变更:

组件 s09 s10
新增类/函数 createDemoRuntimeEvents(演示事件源)/ PrintMode / JsonMode
新增类型 RuntimeMode
输出抽象 Output.logs01 起,逐行) RuntimeMode.render(可切换展示)
主循环 / ProviderInput 不变(纯新增,无 U1 升级)

焊接点:前面主线产出的 RuntimeEvent[] 交给 RuntimeMode.render;本节 demo 只用 createDemoRuntimeEvents 代替真实事件源。PrintMode / JsonMode 各自 render 同一批事件core 与展示彻底分开。


接下来

core 会接触本地项目:要加载项目资料,也可能要执行本地动作。这两件事的风险不一样,得分开管。

下一节会把"能不能加载资料"和"能不能执行动作"拆成两个独立的开关。

进入下一节:s11


Pi 源码溯源:四种 AppMode 和自动分流

教学版两种 modePrint/Json消费同一批事件。Pi 的 packages/coding-agent 有四种运行模式,按终端环境自动分流。

源码在哪

  • packages/coding-agent/src/cli/args.ts:10AppMode 类型
  • packages/coding-agent/src/main.ts:98resolveAppMode(分流逻辑)
  • packages/coding-agent/src/main.ts:768 — 各模式入口
  • packages/coding-agent/src/modes/print-mode.ts — print 模式

四种模式

type AppMode = "interactive" | "print" | "json" | "rpc";
模式 什么时候用 怎么输出
interactive stdin 和 stdout 都是 TTY TUI 差分渲染(pi-tui
print --print 或管道输入 纯文本,跑完退出
json --mode json 结构化 JSON 事件流
rpc --mode rpc JSON-RPC 接口,给编辑器/工具集成

教学版的 PrintMode/JsonMode 是 print 和 json 两种的极简版。

自动分流

resolveAppModemain.ts:98)的判定顺序:

function resolveAppMode(parsed, stdinIsTTY, stdoutIsTTY): AppMode {
  if (parsed.mode === "rpc") return "rpc";      // 显式 rpc 最优先
  if (parsed.mode === "json") return "json";    // 显式 json
  if (parsed.print || !stdinIsTTY || !stdoutIsTTY) return "print";  // 管道自动 print
  return "interactive";                          // 默认交互
}

关键设计:管道自动降级到 print。把 pi 接到管道(echo hi | pi)时,它检测到 stdin 不是 TTY自动用 print 模式——不会傻乎乎起一个 TUI。教学版没有这个自动检测。

TUI 用差分渲染

interactive 模式(main.ts:770)用 @earendil-works/pi-tui,这是个专门的终端 UI 库,做差分渲染(只重绘变化的部分)——流式输出时不会闪烁。教学版的 mode 只是 console.log没有渲染层。

一句话

教学版的 RuntimeMode 立的是"core 产事件、外层决定展示"。Pi 把它坐实成四种 AppMode + 管道自动降级 + TUI 差分渲染。同一个 agent core接 TTY 是交互式、接管道是 print、接工具是 json/rpc——core 一个字不用改。