feat(waiter): Bubble Tea TUI modernization

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).
This commit is contained in:
JianFeeeee
2026-08-25 01:01:22 +08:00
parent 22de000f23
commit dc0ba690c6
4 changed files with 30 additions and 17 deletions

View File

@ -1,9 +1,9 @@
package main package main
import ( import (
"io"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"strings" "strings"
) )

View File

@ -13,8 +13,8 @@ type History struct {
mu sync.Mutex mu sync.Mutex
} }
func newHistory(path string, max int) History { func newHistory(path string, max int) *History {
return History{path: path, max: max} return &History{path: path, max: max}
} }
func (h *History) load() { func (h *History) load() {

View File

@ -239,11 +239,11 @@ func runCapTest(capName, args string) {
} }
// runLineMode 传统行式 REPL(非 TTY 回退 / TUI 启动失败时使用)。 // 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) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
line := newLineEditor(&history) line := newLineEditor(history)
restore, err := setRawMode(0) restore, err := setRawMode(0)
if err != nil { if err != nil {

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -80,7 +79,7 @@ type serverLineMsg struct{ line string }
type readerErrMsg struct { type readerErrMsg struct {
err error err error
gen int // reader 世代号:旧 reader 迟到的错误会被忽略 gen int
} }
type reconnectDoneMsg struct{ ok bool } type reconnectDoneMsg struct{ ok bool }
@ -109,14 +108,14 @@ type tuiModel struct {
historyDraft string historyDraft string
lines chan string lines chan string
errs chan error errs chan readerErrMsg
readerGen int // 当前 reader 世代;重启时递增 readerGen int // 当前 reader 世代;重启时递增
readerAlive bool readerAlive bool
reconnecting 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 := textarea.New()
ti.Placeholder = "输入消息,/help 查看命令" ti.Placeholder = "输入消息,/help 查看命令"
ti.Prompt = "" ti.Prompt = ""
@ -130,7 +129,7 @@ func newTuiModel(state *State, cfg *Config, history History, lines chan string,
cfg: cfg, cfg: cfg,
input: ti, input: ti,
vp: viewport.New(80, 20), vp: viewport.New(80, 20),
history: &history, history: history,
historyIdx: -1, historyIdx: -1,
lines: lines, lines: lines,
errs: errs, errs: errs,
@ -143,13 +142,13 @@ func (m tuiModel) Init() tea.Cmd {
} }
// waitServer 阻塞等待下一行服务器输出或读错误。 // 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 { return func() tea.Msg {
select { select {
case l := <-lines: case l := <-lines:
return serverLineMsg{l} return serverLineMsg{l}
case e := <-errs: 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{} }) 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 安全)。 // reconnectCmd 后台重连(State 自带锁,goroutine 安全)。
func (m tuiModel) reconnectCmd() tea.Cmd { func (m tuiModel) reconnectCmd() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
@ -197,7 +204,6 @@ func (m *tuiModel) readPump(gen int) {
} }
} }
} }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Update // Update
@ -332,6 +338,10 @@ func (m tuiModel) handleSubmit() (tea.Model, tea.Cmd) {
m.reconnecting = true m.reconnecting = true
return m, m.reconnectCmd() return m, m.reconnectCmd()
} }
// 即使内置命令也可能触发服务器回复(如 /status),所以继续保持监听
if m.readerAlive {
return m, waitServer(m.lines, m.errs)
}
return m, nil return m, nil
} }
} }
@ -348,7 +358,10 @@ func (m tuiModel) handleSubmit() (tea.Model, tea.Cmd) {
m.reconnecting = true m.reconnecting = true
return m, m.reconnectCmd() 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) { func (m *tuiModel) handleServerLine(line string) {
@ -622,7 +635,7 @@ func (m tuiModel) View() string {
func (m tuiModel) statusLine() string { func (m tuiModel) statusLine() string {
var leftSeg string var leftSeg string
if m.busy { if m.busy {
leftSeg = styleTitle.Render(spinnerFrames[m.spinnerIdx%len(spinnerFrames)]+" thinking...") leftSeg = styleTitle.Render(spinnerFrames[m.spinnerIdx%len(spinnerFrames)] + " thinking...")
} else { } else {
leftSeg = styleDim.Render("enter 发送 · PgUp/PgDn 翻页 · ctrl+c 退出") leftSeg = styleDim.Render("enter 发送 · PgUp/PgDn 翻页 · ctrl+c 退出")
} }
@ -666,8 +679,8 @@ func maxInt(a, b int) int {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// runTUI 启动 Bubble Tea 全屏界面。 // runTUI 启动 Bubble Tea 全屏界面。
func runTUI(state *State, cfg *Config, history History) error { func runTUI(state *State, cfg *Config, history *History) error {
m := newTuiModel(state, cfg, history, make(chan string, 128), make(chan error, 8)) m := newTuiModel(state, cfg, history, make(chan string, 128), make(chan readerErrMsg, 8))
m.readerAlive = true m.readerAlive = true
go m.readPump(m.readerGen) // 初始读循环 go m.readPump(m.readerGen) // 初始读循环