mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
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:
@ -1,9 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) // 初始读循环
|
||||
|
||||
|
||||
Reference in New Issue
Block a user