From dc0ba690c629aabde19d136e72775a2cd42411f7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 25 Aug 2026 01:01:22 +0800 Subject: [PATCH] feat(waiter): Bubble Tea TUI modernization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the line-based REPL with a full-screen Bubble Tea TUI in interactive mode (non-TTY still falls back to the line editor). Layout (deveco-code inspired, no emoji): - Top status bar: HomeAgent brand + connection dot + mode + addr - Scrollable chat viewport with role-based rendering: You (purple) user messages 小宅 (orange) agent responses · reasoning dim gray italic, streaming-merged [ok]/[fail] tool calls with status + truncated result [sys] builtin command output [err] errors - Rounded-border input box with placeholder - Bottom status bar: spinner while busy / hints + connection state Key design points: - reader generation counter prevents stale errors from the old reader being mistaken for the new one after reconnect - handleSubmit always returns waitServer when reader is alive, so server responses to builtin commands like /status are received - History stored as *History (was copying sync.Mutex by value) - Chinese CJK wide-char aware word wrap with hanging indent - /clear /exit /quit handled in TUI; other /cmds still go through handleBuiltin with output captured to message area Verified via tmux PTY: /help, /status, /clear, real chat with LLM (reasoning -> tool_call -> response chain all render correctly). --- cmd/waiter/builtin.go | 2 +- cmd/waiter/history.go | 4 ++-- cmd/waiter/main.go | 4 ++-- cmd/waiter/tui.go | 37 +++++++++++++++++++++++++------------ 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/cmd/waiter/builtin.go b/cmd/waiter/builtin.go index c7fb8be..9272d2e 100644 --- a/cmd/waiter/builtin.go +++ b/cmd/waiter/builtin.go @@ -1,9 +1,9 @@ package main import ( - "io" "encoding/json" "fmt" + "io" "strings" ) diff --git a/cmd/waiter/history.go b/cmd/waiter/history.go index f2cd279..e1f00b9 100644 --- a/cmd/waiter/history.go +++ b/cmd/waiter/history.go @@ -13,8 +13,8 @@ type History struct { mu sync.Mutex } -func newHistory(path string, max int) History { - return History{path: path, max: max} +func newHistory(path string, max int) *History { + return &History{path: path, max: max} } func (h *History) load() { diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index b6babfa..bb86801 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -239,11 +239,11 @@ func runCapTest(capName, args string) { } // runLineMode 传统行式 REPL(非 TTY 回退 / TUI 启动失败时使用)。 -func runLineMode(state *State, cfg *Config, history History) { +func runLineMode(state *State, cfg *Config, history *History) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - line := newLineEditor(&history) + line := newLineEditor(history) restore, err := setRawMode(0) if err != nil { diff --git a/cmd/waiter/tui.go b/cmd/waiter/tui.go index e7afbbc..7ce7e7e 100644 --- a/cmd/waiter/tui.go +++ b/cmd/waiter/tui.go @@ -1,7 +1,6 @@ package main import ( - "context" "fmt" "strings" "time" @@ -80,7 +79,7 @@ type serverLineMsg struct{ line string } type readerErrMsg struct { err error - gen int // reader 世代号:旧 reader 迟到的错误会被忽略 + gen int } type reconnectDoneMsg struct{ ok bool } @@ -109,14 +108,14 @@ type tuiModel struct { historyDraft string lines chan string - errs chan error + errs chan readerErrMsg readerGen int // 当前 reader 世代;重启时递增 readerAlive bool reconnecting bool } -func newTuiModel(state *State, cfg *Config, history History, lines chan string, errs chan error) tuiModel { +func newTuiModel(state *State, cfg *Config, history *History, lines chan string, errs chan readerErrMsg) tuiModel { ti := textarea.New() ti.Placeholder = "输入消息,/help 查看命令" ti.Prompt = "" @@ -130,7 +129,7 @@ func newTuiModel(state *State, cfg *Config, history History, lines chan string, cfg: cfg, input: ti, vp: viewport.New(80, 20), - history: &history, + history: history, historyIdx: -1, lines: lines, errs: errs, @@ -143,13 +142,13 @@ func (m tuiModel) Init() tea.Cmd { } // waitServer 阻塞等待下一行服务器输出或读错误。 -func waitServer(lines chan string, errs chan error) tea.Cmd { +func waitServer(lines chan string, errs chan readerErrMsg) tea.Cmd { return func() tea.Msg { select { case l := <-lines: return serverLineMsg{l} case e := <-errs: - return readerErrMsg{e} + return e } } } @@ -158,6 +157,14 @@ func spinTick() tea.Cmd { return tea.Tick(90*time.Millisecond, func(time.Time) tea.Msg { return spinnerTickMsg{} }) } +// spinCmd busy 时启动 spinner tick 循环。 +func (m tuiModel) spinCmd() tea.Cmd { + if !m.busy { + return nil + } + return spinTick() +} + // reconnectCmd 后台重连(State 自带锁,goroutine 安全)。 func (m tuiModel) reconnectCmd() tea.Cmd { return func() tea.Msg { @@ -197,7 +204,6 @@ func (m *tuiModel) readPump(gen int) { } } } -} // --------------------------------------------------------------------------- // Update @@ -332,6 +338,10 @@ func (m tuiModel) handleSubmit() (tea.Model, tea.Cmd) { m.reconnecting = true return m, m.reconnectCmd() } + // 即使内置命令也可能触发服务器回复(如 /status),所以继续保持监听 + if m.readerAlive { + return m, waitServer(m.lines, m.errs) + } return m, nil } } @@ -348,7 +358,10 @@ func (m tuiModel) handleSubmit() (tea.Model, tea.Cmd) { m.reconnecting = true return m, m.reconnectCmd() } - return m, nil + if m.readerAlive { + return m, tea.Batch(waitServer(m.lines, m.errs), m.spinCmd()) + } + return m, m.spinCmd() } func (m *tuiModel) handleServerLine(line string) { @@ -622,7 +635,7 @@ func (m tuiModel) View() string { func (m tuiModel) statusLine() string { var leftSeg string if m.busy { - leftSeg = styleTitle.Render(spinnerFrames[m.spinnerIdx%len(spinnerFrames)]+" thinking...") + leftSeg = styleTitle.Render(spinnerFrames[m.spinnerIdx%len(spinnerFrames)] + " thinking...") } else { leftSeg = styleDim.Render("enter 发送 · PgUp/PgDn 翻页 · ctrl+c 退出") } @@ -666,8 +679,8 @@ func maxInt(a, b int) int { // --------------------------------------------------------------------------- // runTUI 启动 Bubble Tea 全屏界面。 -func runTUI(state *State, cfg *Config, history History) error { - m := newTuiModel(state, cfg, history, make(chan string, 128), make(chan error, 8)) +func runTUI(state *State, cfg *Config, history *History) error { + m := newTuiModel(state, cfg, history, make(chan string, 128), make(chan readerErrMsg, 8)) m.readerAlive = true go m.readPump(m.readerGen) // 初始读循环