Files
HomeAgent/cmd/waiter/tui.go
JianFeeeee dc0ba690c6 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).
2026-08-25 01:01:22 +08:00

691 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// 配色(对齐 deveco-code 深色主题12 阶灰阶 + 语义色,无 emoji纯文本标记
// ---------------------------------------------------------------------------
var (
cStep3 = lipgloss.Color("#1e1e1e")
cStep7 = lipgloss.Color("#484848")
cStep11 = lipgloss.Color("#808080")
cStep12 = lipgloss.Color("#eeeeee")
cPrimary = lipgloss.Color("#fab283") // 主色(暖橙)
cAccent = lipgloss.Color("#9d7cd8") // 紫
cGreen = lipgloss.Color("#7fd88f")
cRed = lipgloss.Color("#e06c75")
cYellow = lipgloss.Color("#e5c07b")
cCyan = lipgloss.Color("#56b6c2")
)
var (
styleHeaderBox = lipgloss.NewStyle().Foreground(cStep12).Background(cStep3).Padding(0, 1)
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(cPrimary)
styleDotOn = lipgloss.NewStyle().Foreground(cGreen)
styleDotOff = lipgloss.NewStyle().Foreground(cRed)
styleDim = lipgloss.NewStyle().Foreground(cStep11)
styleUserTag = lipgloss.NewStyle().Bold(true).Foreground(cAccent)
styleAgentTag = lipgloss.NewStyle().Bold(true).Foreground(cPrimary)
styleSysTag = lipgloss.NewStyle().Bold(true).Foreground(cCyan)
styleErrTag = lipgloss.NewStyle().Bold(true).Foreground(cRed)
styleReason = lipgloss.NewStyle().Foreground(cStep11)
styleInputBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cStep7)
styleInputFocus = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cPrimary)
styleStatusBar = lipgloss.NewStyle().Foreground(cStep11).Background(cStep3).Padding(0, 1)
styleSep = lipgloss.NewStyle().Foreground(cStep7)
styleToolPending = lipgloss.NewStyle().Foreground(cYellow)
styleToolOK = lipgloss.NewStyle().Foreground(cGreen)
styleToolFail = lipgloss.NewStyle().Foreground(cRed)
)
// ---------------------------------------------------------------------------
// 消息模型
// ---------------------------------------------------------------------------
type msgKind int
const (
msgUser msgKind = iota
msgAgent
msgReasoning
msgTool
msgSystem
msgError
)
type chatMsg struct {
kind msgKind
text string
tool string // msgTool: 工具名
status string // msgTool: ok/denied/interrupted/error/running
result string // msgTool: 结果预览
}
// ---- tea.Msg ----
type spinnerTickMsg struct{}
type serverLineMsg struct{ line string }
type readerErrMsg struct {
err error
gen int
}
type reconnectDoneMsg struct{ ok bool }
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
type tuiModel struct {
state *State
cfg *Config
vp viewport.Model
input textarea.Model
messages []chatMsg
width int
height int
ready bool
busy bool
spinnerIdx int
history *History
historyIdx int // -1 = 无导航0..n-1 = history.all() 下标(越大越新)
historyDraft string
lines chan string
errs chan readerErrMsg
readerGen int // 当前 reader 世代;重启时递增
readerAlive bool
reconnecting bool
}
func newTuiModel(state *State, cfg *Config, history *History, lines chan string, errs chan readerErrMsg) tuiModel {
ti := textarea.New()
ti.Placeholder = "输入消息,/help 查看命令"
ti.Prompt = ""
ti.CharLimit = -1
ti.SetHeight(1)
ti.ShowLineNumbers = false
ti.Focus()
return tuiModel{
state: state,
cfg: cfg,
input: ti,
vp: viewport.New(80, 20),
history: history,
historyIdx: -1,
lines: lines,
errs: errs,
readerAlive: true,
}
}
func (m tuiModel) Init() tea.Cmd {
return tea.Batch(textarea.Blink, waitServer(m.lines, m.errs))
}
// waitServer 阻塞等待下一行服务器输出或读错误。
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 e
}
}
}
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 {
m.state.Disconnect()
for i := 0; i < 15; i++ {
if err := m.state.Connect(m.cfg); err == nil {
return reconnectDoneMsg{ok: true}
}
time.Sleep(time.Second)
}
return reconnectDoneMsg{ok: false}
}
}
// restartReader 重启读循环 goroutine。
func (m *tuiModel) restartReader() {
m.readerGen++ // 使旧 reader 的迟到错误失效
m.readerAlive = true
go m.readPump(m.readerGen)
}
// readPump 持续读服务器输出并投递到 channel出错时投递带世代号的 err 后退出。
func (m *tuiModel) readPump(gen int) {
for {
line, err := m.state.readLine()
if err != nil {
select {
case m.errs <- readerErrMsg{err: err, gen: gen}:
default:
}
return
}
select {
case m.lines <- line:
case <-time.After(30 * time.Second):
return
}
}
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
if !m.ready {
m.ready = true
m.append(chatMsg{kind: msgSystem,
text: fmt.Sprintf("connected %s://%s", m.modeLabel(), m.addrLabel())})
}
m.layout()
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC, tea.KeyCtrlD:
return m, tea.Quit
case tea.KeyEnter:
return m.handleSubmit()
case tea.KeyUp:
lines := m.history.all()
if len(lines) == 0 {
return m, nil
}
if m.historyIdx == -1 {
m.historyDraft = m.input.Value()
m.historyIdx = len(lines) - 1
} else if m.historyIdx > 0 {
m.historyIdx--
}
m.input.SetValue(lines[m.historyIdx])
return m, nil
case tea.KeyDown:
if m.historyIdx >= 0 {
m.historyIdx++
if m.historyIdx >= len(m.history.all()) {
m.historyIdx = -1
m.input.SetValue(m.historyDraft)
} else {
m.input.SetValue(m.history.all()[m.historyIdx])
}
}
return m, nil
case tea.KeyPgUp:
m.vp.HalfPageUp()
return m, nil
case tea.KeyPgDown:
m.vp.HalfPageDown()
return m, nil
}
case spinnerTickMsg:
if m.busy {
m.spinnerIdx++
return m, spinTick()
}
case serverLineMsg:
m.handleServerLine(msg.line)
var cmds []tea.Cmd
if m.readerAlive {
cmds = append(cmds, waitServer(m.lines, m.errs))
}
return m, tea.Batch(cmds...)
case readerErrMsg:
if msg.gen != m.readerGen {
// 旧 reader 的迟到错误:新 reader 已在运行,忽略
return m, nil
}
m.readerAlive = false
if !m.reconnecting {
m.append(chatMsg{kind: msgError, text: "connection lost: " + msg.err.Error()})
}
return m, nil
case reconnectDoneMsg:
m.reconnecting = false
if msg.ok {
m.restartReader()
m.append(chatMsg{kind: msgSystem, text: "reconnected"})
return m, waitServer(m.lines, m.errs)
}
m.append(chatMsg{kind: msgError, text: "reconnect failed after 15 attempts"})
return m, nil
}
// 其余按键交给输入框
var icmd tea.Cmd
m.input, icmd = m.input.Update(msg)
var vcmd tea.Cmd
m.vp, vcmd = m.vp.Update(msg)
return m, tea.Batch(icmd, vcmd)
}
func (m tuiModel) handleSubmit() (tea.Model, tea.Cmd) {
text := strings.TrimSpace(m.input.Value())
m.input.Reset()
m.historyIdx = -1
if text == "" {
return m, nil
}
// TUI 自己处理的内置命令
switch text {
case "/exit", "/quit":
return m, tea.Quit
case "/clear":
m.messages = nil
m.refreshViewport()
return m, nil
case "/reconnect":
m.append(chatMsg{kind: msgSystem, text: "reconnecting..."})
m.reconnecting = true
return m, m.reconnectCmd()
}
// 其余内置命令:捕获输出进消息区
if strings.HasPrefix(text, "/") {
needReconnect := false
var buf strings.Builder
handled := handleBuiltin(text, m.cfg, m.state, func() { needReconnect = true }, &buf)
if handled {
if out := strings.TrimRight(buf.String(), "\n"); out != "" {
m.append(chatMsg{kind: msgSystem, text: out})
}
if needReconnect {
m.reconnecting = true
return m, m.reconnectCmd()
}
// 即使内置命令也可能触发服务器回复(如 /status所以继续保持监听
if m.readerAlive {
return m, waitServer(m.lines, m.errs)
}
return m, nil
}
}
// 普通消息:发给 agent
m.append(chatMsg{kind: msgUser, text: text})
m.history.add(text)
m.history.save()
m.busy = true
if err := m.state.Send(text); err != nil {
m.busy = false
m.append(chatMsg{kind: msgError, text: "send failed: " + err.Error()})
m.reconnecting = true
return m, m.reconnectCmd()
}
if m.readerAlive {
return m, tea.Batch(waitServer(m.lines, m.errs), m.spinCmd())
}
return m, m.spinCmd()
}
func (m *tuiModel) handleServerLine(line string) {
rl := parseRespLineStruct(line)
switch rl.Type {
case "reasoning":
cm := chatMsg{kind: msgReasoning, text: rl.Content}
// 连续 reasoning 增量合并到最后一条,形成流式效果
if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgReasoning {
m.messages[n-1].text += rl.Content
} else {
m.append(cm)
}
case "tool_call":
m.append(chatMsg{kind: msgTool, tool: rl.Tool, status: rl.Status, result: rl.Result})
case "response":
m.busy = false
m.append(chatMsg{kind: msgAgent, text: rl.Content})
case "error":
m.busy = false
m.append(chatMsg{kind: msgError, text: rl.Error})
default:
// 非 JSON 旧行(旧服务器):当最终输出
m.busy = false
m.append(chatMsg{kind: msgAgent, text: line})
}
}
func (m *tuiModel) append(cm chatMsg) {
m.messages = append(m.messages, cm)
m.refreshViewport()
}
func (m *tuiModel) modeLabel() string {
if m.cfg.Remote != "" {
return "remote"
}
return "local"
}
func (m *tuiModel) addrLabel() string {
if m.cfg.Remote != "" {
return m.cfg.Remote
}
return m.cfg.Socket
}
// ---------------------------------------------------------------------------
// 渲染
// ---------------------------------------------------------------------------
const tagWidth = 5
func (m tuiModel) renderMessage(cm chatMsg, width int) string {
switch cm.kind {
case msgUser:
return hangingIndent(styleUserTag.Render("You"), cm.text, width)
case msgAgent:
return hangingIndent(styleAgentTag.Render("小宅"), cm.text, width)
case msgReasoning:
tail := lastNonEmptyLine(cm.text)
if w := width - tagWidth - 4; w > 8 && lipgloss.Width(tail) > w {
tail = truncateTail(tail, w)
}
return styleDim.Render(" · " + tail)
case msgTool:
var mark string
var st lipgloss.Style
switch cm.status {
case "ok":
mark, st = "[ok]", styleToolOK
case "denied", "interrupted", "error":
mark, st = "[fail]", styleToolFail
default:
mark, st = "[..]", styleToolPending
}
preview := ""
if cm.result != "" {
first := firstLine(cm.result)
if w := width - tagWidth - 24; w > 8 && lipgloss.Width(first) > w {
first = truncateTail(first, w)
}
preview = styleDim.Render(" " + first)
}
return " " + st.Render(fmt.Sprintf("%-6s", mark)) +
styleDim.Render(fmt.Sprintf("%-*s", 18, cm.tool)) + preview
case msgSystem:
return styleSysTag.Render("[sys]") + " " + styleDim.Render(cm.text)
case msgError:
return styleErrTag.Render("[err]") + " " +
lipgloss.NewStyle().Foreground(cRed).Render(cm.text)
}
return ""
}
// hangingIndent 两列布局:首行 "tag body",续行缩进对齐 body。
func hangingIndent(tag, body string, width int) string {
indent := tagWidth + 1
avail := width - indent
if avail < 10 {
avail = 10
}
pad := indent - lipgloss.Width(tag)
if pad < 1 {
pad = 1
}
prefix := tag + strings.Repeat(" ", pad)
var b strings.Builder
first := true
for _, ln := range strings.Split(body, "\n") {
for j, seg := range wordWrap(ln, avail) {
if first && j == 0 {
b.WriteString(prefix)
} else {
b.WriteString(strings.Repeat(" ", indent))
}
b.WriteString(seg)
b.WriteString("\n")
}
first = false
}
return strings.TrimSuffix(b.String(), "\n")
}
// wordWrap 按 display 宽度断行(宽字符按 2 列计)。
func wordWrap(s string, limit int) []string {
if s == "" {
return []string{""}
}
if lipgloss.Width(s) <= limit {
return []string{s}
}
var out []string
var cur strings.Builder
curW := 0
for _, r := range s {
w := runeWidth(r)
if curW+w > limit && cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
curW = 0
}
cur.WriteRune(r)
curW += w
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
func runeWidth(r rune) int {
if r >= 0x1100 && (r <= 0x115F ||
r == 0x2329 || r == 0x232A ||
(r >= 0x2E80 && r <= 0xA4CF) ||
(r >= 0xAC00 && r <= 0xD7A3) ||
(r >= 0xF900 && r <= 0xFAFF) ||
(r >= 0xFE30 && r <= 0xFE4F) ||
(r >= 0xFF00 && r <= 0xFF60) ||
(r >= 0xFFE0 && r <= 0xFFE6) ||
(r >= 0x20000 && r <= 0x3FFFD)) {
return 2
}
return 1
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func lastNonEmptyLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.TrimSpace(lines[i]) != "" {
return lines[i]
}
}
return ""
}
// truncateTail 尾部省略(保留开头)。
func truncateTail(s string, max int) string {
out := ""
w := 0
for _, r := range s {
rw := runeWidth(r)
if w+rw > max-1 {
break
}
out += string(r)
w += rw
}
return out + "…"
}
// ---------------------------------------------------------------------------
// 布局与视图
// ---------------------------------------------------------------------------
const (
headerRows = 1
inputRows = 3 // 圆角边框上下 + 1 行输入
statusRows = 1
gapRows = 2 // 头部与消息区、消息区与输入框之间的空行
minVpHeight = 4
)
func (m *tuiModel) layout() {
h := m.height - headerRows - statusRows - inputRows - gapRows
if h < minVpHeight {
h = minVpHeight
}
m.vp.Width = m.width
m.vp.Height = h
}
func (m *tuiModel) refreshViewport() {
var b strings.Builder
w := m.width - 2
if w < 40 {
w = 40
}
for i, cm := range m.messages {
b.WriteString(m.renderMessage(cm, w))
if i < len(m.messages)-1 {
b.WriteString("\n\n") // 消息间空行分隔
}
}
m.vp.SetContent(b.String())
m.vp.GotoBottom()
}
func (m tuiModel) View() string {
if !m.ready {
return ""
}
// 顶栏
dot := styleDotOn.Render("●")
connText := "connected"
if !m.state.Connected() {
dot = styleDotOff.Render("●")
connText = "disconnected"
}
left := styleTitle.Render("HomeAgent") + " " + dot + " " + connText
right := styleDim.Render(truncMid(m.addrLabel(), maxInt(10, m.width-lipgloss.Width(left)-8)))
header := styleHeaderBox.Width(m.width).MaxWidth(m.width).Render(left + " " + right)
// 输入区
boxStyle := styleInputBox
if m.input.Focused() {
boxStyle = styleInputFocus
}
inputBox := boxStyle.Width(m.width - 2).Render(m.input.View())
statusBar := styleStatusBar.Width(m.width).MaxWidth(m.width).Render(m.statusLine())
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
m.vp.View(),
"",
inputBox,
statusBar,
)
}
func (m tuiModel) statusLine() string {
var leftSeg string
if m.busy {
leftSeg = styleTitle.Render(spinnerFrames[m.spinnerIdx%len(spinnerFrames)] + " thinking...")
} else {
leftSeg = styleDim.Render("enter 发送 · PgUp/PgDn 翻页 · ctrl+c 退出")
}
right := styleDim.Render(connSummary(m.state, m.modeLabel()))
gap := m.width - lipgloss.Width(leftSeg) - lipgloss.Width(right) - 2
if gap < 1 {
gap = 1
}
return leftSeg + strings.Repeat(" ", gap) + right
}
func connSummary(state *State, mode string) string {
s := "● " + mode
if state.Connected() {
return styleDotOn.Render(s)
}
return styleDotOff.Render(s + " disconnected")
}
func truncMid(s string, limit int) string {
if lipgloss.Width(s) <= limit {
return s
}
half := (limit - 1) / 2
r := []rune(s)
if half < 1 {
return "…"
}
return string(r[:half]) + "…" + string(r[len(r)-half:])
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// ---------------------------------------------------------------------------
// 入口
// ---------------------------------------------------------------------------
// runTUI 启动 Bubble Tea 全屏界面。
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) // 初始读循环
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}