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
import (
"io"
"encoding/json"
"fmt"
"io"
"strings"
)

View File

@ -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() {

View File

@ -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 {

View File

@ -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) // 初始读循环