TUI 設計取捨
TUI 為何以 bubbletea 單一 package 實作,以及何時該重新檢討。
引 pardn chiu:「bubbletea 的設計不是要拆成互相 reference 的獨立模組——拆了會讓 lifecycle 一團亂。我現在沒餘力處理它。」 此模組刻意保持不切分。
TUI 住在單一 package(internal/runtime/tui),不拆成子 package。internal/runtime/tui/ 底下每個檔案都遵循此原則。
為何選 bubbletea(而非 tview / tcell)
先前的 TUI 用 rivo/tview,被替換的原因:
- Inline scrollback:bubbletea 的
tea.Println寫出的行會捲進 input box 上方 terminal 原生 buffer。tview 佔據整個螢幕,無法與 shell scrollback 共存。 - lipgloss styling primitives:border、padding、foreground/background 組合乾淨。tview style 為 tag-based,跨 component 重用較難。
- bubbles 生態系:
textarea、spinner、cursor是即插即用的 component,與 charm-bracelet 風格其餘部分一致。
代價是 bubbletea 為 The Elm Architecture 的 Go port——其 tea.Model 介面設計上即為 monolithic。
為何單一 package
tea.Model 要求 Update(tea.Msg) (tea.Model, tea.Cmd) 為 model type 上的 method。Method 必須與 type 住在同一 package。這強制:
- 所有
Update邏輯與 model 同 package - 拆成子 package 需要在第三個(root)package 建 wrapper,並匯出每一個 model field 讓子 package 能讀寫 state
- 目前
popupState、commandPickerState、viewMode等unexportedtype 必須變成 exported,形成一個internal/runtime/tui外部永遠不會消費的「API」 send()與program atomic.Pointer[tea.Program]要嘛移進子 package(root 透過 setter API 設定),要嘛留在 root 並強迫 handler import root,這會造成第二個 cycle
真正 Go 風格的 TUI 會建 per-domain widget package(各自持有 state struct、render method 與 event handler),bubbletea 僅作為 event loop。該重構為 600-800 LOC 的重寫,切成 4 個 phase。對目前約 11.2k 非測試 LOC(internal/runtime/tui)、由單一開發者維護的 TUI,收益不足以正當化成本。
何時重新檢視
當任一條件成立時,切換為 per-domain widget package:
- TUI 超過約 3k LOC,且 code review 持續卡在「這該歸屬哪裡」
- 多位開發者常態觸碰 TUI 並互相踩 state
- 特定 widget 需針對凍結 state 做獨立 unit test——目前不實例化整個
Model就辦不到
[!NOTE] 本文件由 Claude 讀完完整原始碼後自動生成。