mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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) // 初始读循环
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user