TUI 交互模式
InteractiveMode 是 pi 的默认运行模式——全屏 TUI,在终端里渲染对话、工具执行、流式输出、状态栏、编辑器、技能选择器、模型选择器等几十个组件。这个文件 5493 行,是整个 pi-mono 里最长的单文件,因为它把所有 UI 交互逻辑(键绑定、命令分发、事件渲染、扩展 UI 集成、auto-compaction、auto-retry、图片粘贴、文件拖拽)都集中在一个类里。本文只讲入口和渲染主干,细节命令处理在各自 handle*Command 方法里。
职责
- UI 装配:
init()里挂 header / chatContainer / pendingMessagesContainer / statusContainer / editorContainer / footer 等容器到TUI,设焦点到editor,启动ui.start()。见packages/coding-agent/src/modes/interactive/interactive-mode.ts:559-650。 - 编辑器提交分发:
defaultEditor.onSubmit是 UI 主入口,识别/斜杠命令(/settings、/model、/export、/import、/fork、/new、/compact等),非命令文本走session.prompt。见packages/coding-agent/src/modes/interactive/interactive-mode.ts:2441-2625。 - AgentSessionEvent 渲染:
handleEventswitch 处理agent_start、queue_update、assistant_message、tool_call、tool_result、compaction、error等十几种事件类型,同步更新 UI 组件。见packages/coding-agent/src/modes/interactive/interactive-mode.ts:2627-2980。 - 键绑定:
setupKeyHandlers注册 Ctrl+C、Ctrl+D、Ctrl+Z、Alt+Enter(followUp)、Ctrl+P(cycle model)、Ctrl+T(cycle thinking)等,见packages/coding-agent/src/modes/interactive/interactive-mode.ts:2352-2417。 - bash 模式:
!前缀触发handleBashCommand,执行结果通过BashExecutionComponent渲染;!!前缀设excludeFromContext: true。见packages/coding-agent/src/modes/interactive/interactive-mode.ts:5364-5450。 - 扩展 UI 集成:
rebindSession重新调session.bindExtensions提供uiContext,扩展可以注入 widget、dialog、status,见packages/coding-agent/src/modes/interactive/interactive-mode.ts:1812-1870。
设计动机
为什么 5493 行不拆?因为这个类是状态机中心——几十个字段(streamingComponent、pendingTools、autoCompactionLoader、retryLoader、retryCountdown、pendingBashComponents、skillCommands)在同一事件流里互相影响,拆成多个小类后状态会散落,跨类同步会更难。pi 的选择是把所有 UI 状态集中在一个类,通过 handleEvent 这个大 switch 统一处理,代码长但状态流转清晰。
isExpandable 这个小工具函数(packages/coding-agent/src/modes/interactive/interactive-mode.ts:142-144)是渲染层的鸭子类型检查:任何有 setExpanded 方法的组件都能被折叠/展开。这让 ExpandableText、header、工具输出共用一套展开逻辑,不需要继承同一个抽象基类。
defaultEditor.onSubmit 和 this.editor.onSubmit 的区分:defaultEditor 是固定实例,this.editor 是当前激活的编辑器(可能是扩展注入的自定义编辑器)。setupEditorSubmitHandler 只在 defaultEditor 上挂 handler,但 handler 内部读 this.editor 拿当前文本,这样自定义编辑器切换进来也能复用同一套命令分发。
关键文件
packages/coding-agent/src/modes/interactive/interactive-mode.ts:142-160—isExpandable鸭子类型检查与ExpandableText折叠文本组件。packages/coding-agent/src/modes/interactive/interactive-mode.ts:213-226—InteractiveModeOptions:migratedProviders、modelFallbackMessage、initialMessage、initialImages、initialMessages、verbose。packages/coding-agent/src/modes/interactive/interactive-mode.ts:228-360—class InteractiveMode字段声明,包括 streamingComponent、pendingTools、autoCompactionLoader、retryLoader 等。packages/coding-agent/src/modes/interactive/interactive-mode.ts:559-650—init方法:加载 fd/rg、挂 UI 容器、setupEditorSubmitHandler、ui.start()。packages/coding-agent/src/modes/interactive/interactive-mode.ts:692-730—run方法:init + 异步检查版本/包更新/tmux + 显示启动警告。packages/coding-agent/src/modes/interactive/interactive-mode.ts:2441-2540—defaultEditor.onSubmit前半段:/settings、/model、/export、/import、/share、/copy、/name、/session、/fork、/clone、/tree、/login、/logout、/new、/compact。packages/coding-agent/src/modes/interactive/interactive-mode.ts:2627-2665—handleEvent开头与agent_start、queue_update分支。packages/coding-agent/src/modes/interactive/interactive-mode.ts:3334-3364—handleFollowUp,Alt+Enter 在流式中走streamingBehavior: "followUp",非流式退化成普通 onSubmit。
defaultEditor.onSubmit 是命令分发的核心,大量 if (text === "/xxx") 串联:
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:2441-2470
this.defaultEditor.onSubmit = async (text: string) => {
text = text.trim();
if (!text) return;
// Handle commands
if (text === "/settings") {
this.showSettingsSelector();
this.editor.setText("");
return;
}
if (text === "/scoped-models") {
this.editor.setText("");
await this.showModelsSelector();
return;
}
if (text === "/model" || text.startsWith("/model ")) {
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
this.editor.setText("");
await this.handleModelCommand(searchTerm);
return;
}
// ... 后续几十个命令分支 ...Alt+Enter 在流式时排队 followUp,非流式时退化成普通提交:
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:3352-3363
if (this.session.isStreaming) {
this.editor.addToHistory?.(text);
this.editor.setText("");
await this.session.prompt(text, { streamingBehavior: "followUp" });
this.updatePendingMessagesDisplay();
this.ui.requestRender();
}
// If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
else if (this.editor.onSubmit) {
this.editor.setText("");
this.editor.onSubmit(text);
}数据流
UI 输入到事件渲染的双向流:
边界与失败
- 未初始化时收到事件:
handleEvent开头检查isInitialized,未初始化则await this.init(),防止事件早于 UI 装配到达,见packages/coding-agent/src/modes/interactive/interactive-mode.ts:2628-2630。 - 死终端检测:
isDeadTerminalError检查EIO/EPIPE/ENOTCONN错误码,捕获后不再尝试渲染,避免雪崩,见packages/coding-agent/src/modes/interactive/interactive-mode.ts:167-175。 - Anthropic 订阅 auth 警告:检测到
sk-ant-oat前缀的 API key 时显示一次警告「订阅 auth 计费方式不同」,通过anthropicSubscriptionWarningShown标志防重复,见packages/coding-agent/src/modes/interactive/interactive-mode.ts:177-182。 - auto-retry 转义处理:
agent_start事件里清理上一次 retry 的 escapeHandler 和 countdownLoader,保证 retry 状态不会泄漏到下一轮,见packages/coding-agent/src/modes/interactive/interactive-mode.ts:2641-2653。 - 自定义编辑器切换:
this.editor可以从defaultEditor切换到扩展提供的编辑器,但onSubmit/onChange等回调仍然指向defaultEditor的实现,保证行为一致,见packages/coding-agent/src/modes/interactive/interactive-mode.ts:2182-2227。
小结
InteractiveMode 是 pi 的 TUI 中心,5493 行集中管理所有 UI 状态和命令分发。defaultEditor.onSubmit 是入口,handleEvent 是渲染主干。装配它的 runtime 看 会话 switch/fork/import,事件源 AgentSession 看 AgentSession 编排层,非交互模式看 print 与 rpc 模式。