Skip to content

TUI 交互模式

源码版本v0.73.1

InteractiveMode 是 pi 的默认运行模式——全屏 TUI,在终端里渲染对话、工具执行、流式输出、状态栏、编辑器、技能选择器、模型选择器等几十个组件。这个文件 5493 行,是整个 pi-mono 里最长的单文件,因为它把所有 UI 交互逻辑(键绑定、命令分发、事件渲染、扩展 UI 集成、auto-compaction、auto-retry、图片粘贴、文件拖拽)都集中在一个类里。本文只讲入口和渲染主干,细节命令处理在各自 handle*Command 方法里。

职责

  1. UI 装配:init() 里挂 header / chatContainer / pendingMessagesContainer / statusContainer / editorContainer / footer 等容器到 TUI,设焦点到 editor,启动 ui.start()。见 packages/coding-agent/src/modes/interactive/interactive-mode.ts:559-650
  2. 编辑器提交分发:defaultEditor.onSubmit 是 UI 主入口,识别 / 斜杠命令(/settings/model/export/import/fork/new/compact 等),非命令文本走 session.prompt。见 packages/coding-agent/src/modes/interactive/interactive-mode.ts:2441-2625
  3. AgentSessionEvent 渲染:handleEvent switch 处理 agent_startqueue_updateassistant_messagetool_calltool_resultcompactionerror 等十几种事件类型,同步更新 UI 组件。见 packages/coding-agent/src/modes/interactive/interactive-mode.ts:2627-2980
  4. 键绑定: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
  5. bash 模式:! 前缀触发 handleBashCommand,执行结果通过 BashExecutionComponent 渲染;!! 前缀设 excludeFromContext: true。见 packages/coding-agent/src/modes/interactive/interactive-mode.ts:5364-5450
  6. 扩展 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.onSubmitthis.editor.onSubmit 的区分:defaultEditor 是固定实例,this.editor 是当前激活的编辑器(可能是扩展注入的自定义编辑器)。setupEditorSubmitHandler 只在 defaultEditor 上挂 handler,但 handler 内部读 this.editor 拿当前文本,这样自定义编辑器切换进来也能复用同一套命令分发。

关键文件

defaultEditor.onSubmit 是命令分发的核心,大量 if (text === "/xxx") 串联:

typescript
// 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,非流式时退化成普通提交:

typescript
// 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 输入到事件渲染的双向流:

边界与失败

小结

InteractiveMode 是 pi 的 TUI 中心,5493 行集中管理所有 UI 状态和命令分发。defaultEditor.onSubmit 是入口,handleEvent 是渲染主干。装配它的 runtime 看 会话 switch/fork/import,事件源 AgentSessionAgentSession 编排层,非交互模式看 print 与 rpc 模式