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 模式。