s10: Runtime Modes — 同一个 core,不同的展示
core 只管产生,怎么展示外层说了算。 Pi 边界:运行方式边界 —— core 产生事件,展示方式是外层的事,换展示不改 core。
问题
前面几节里,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 就 JsonMode,core 一个字都不用改。
这里不是替换 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 是"翻译器"。换展示方式只是换 mode,core 不动——这正是 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.log(s01 起,逐行) |
RuntimeMode.render(可切换展示) |
主循环 / ProviderInput |
— | 不变(纯新增,无 U1 升级) |
焊接点:前面主线产出的 RuntimeEvent[] 交给 RuntimeMode.render;本节 demo 只用 createDemoRuntimeEvents 代替真实事件源。PrintMode / JsonMode 各自 render 同一批事件,core 与展示彻底分开。
接下来
core 会接触本地项目:要加载项目资料,也可能要执行本地动作。这两件事的风险不一样,得分开管。
下一节会把"能不能加载资料"和"能不能执行动作"拆成两个独立的开关。
进入下一节:s11。
Pi 源码溯源:四种 AppMode 和自动分流
教学版两种 mode(Print/Json)消费同一批事件。Pi 的 packages/coding-agent 有四种运行模式,按终端环境自动分流。
源码在哪
packages/coding-agent/src/cli/args.ts:10—AppMode类型packages/coding-agent/src/main.ts:98—resolveAppMode(分流逻辑)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 或管道输入 |
纯文本,跑完退出 | |
| json | --mode json |
结构化 JSON 事件流 |
| rpc | --mode rpc |
JSON-RPC 接口,给编辑器/工具集成 |
教学版的 PrintMode/JsonMode 是 print 和 json 两种的极简版。
自动分流
resolveAppMode(main.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 一个字不用改。