fix: bump llm http client timeout 120s→180s for llmsproxy AUTO chain failover

The local llmsproxy AUTO chain tries 6+ slots across 3 tiers sequentially.
Each failed tier incurs busyWait (2s) + upstream timeout, so a full chain
exhaustion can exceed 120s. The llmsproxy logs showed 143 'context canceled'
errors for the homeagent key — the client gave up before the chain finished.

180s gives the chain enough room to complete before the client timeout fires.
Also remove stale backup files under /usr/local/bin/.
This commit is contained in:
dev
2026-08-25 00:34:25 +08:00
parent ece06b0375
commit 22de000f23
6 changed files with 810 additions and 35 deletions

View File

@ -1,15 +1,16 @@
package main
import (
"io"
"encoding/json"
"fmt"
"strings"
)
func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func()) bool {
func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out io.Writer) bool {
switch {
case cmd == "/help":
fmt.Println(`Built-in commands:
fmt.Fprintln(out, `Built-in commands:
/help show this help
/exit, /quit exit waiter
/clear clear screen
@ -44,7 +45,7 @@ Any other text is sent to the agent directly.`)
return true
case cmd == "/clear":
fmt.Print("\033[H\033[2J")
fmt.Fprint(out, "\033[H\033[2J")
return true
case cmd == "/reconnect":
@ -72,7 +73,7 @@ Any other text is sent to the agent directly.`)
case cmd == "/conn list":
if len(cfg.Connections) == 0 {
fmt.Println("no saved connections")
fmt.Fprintln(out, "no saved connections")
}
for _, c := range cfg.Connections {
mark := " "
@ -83,39 +84,39 @@ Any other text is sent to the agent directly.`)
if addr == "" {
addr = c.Socket
}
fmt.Printf(" %s %-15s %s\n", mark, c.Name, addr)
fmt.Fprintf(out, " %s %-15s %s\n", mark, c.Name, addr)
}
return true
case strings.HasPrefix(cmd, "/conn save "):
name := strings.TrimSpace(cmd[11:])
cfg.SaveConnection(name)
fmt.Printf("connection saved as '%s' (default)\n", name)
fmt.Fprintf(out, "connection saved as '%s' (default)\n", name)
return true
case strings.HasPrefix(cmd, "/conn use "):
name := strings.TrimSpace(cmd[10:])
if cfg.SwitchConnection(name) {
fmt.Printf("switched to '%s'\n", name)
fmt.Fprintf(out, "switched to '%s'\n", name)
reconnect()
} else {
fmt.Printf("connection '%s' not found\n", name)
fmt.Fprintf(out, "connection '%s' not found\n", name)
}
return true
case strings.HasPrefix(cmd, "/conn del "):
name := strings.TrimSpace(cmd[10:])
if cfg.DeleteConnection(name) {
fmt.Printf("connection '%s' deleted\n", name)
fmt.Fprintf(out, "connection '%s' deleted\n", name)
} else {
fmt.Printf("connection '%s' not found\n", name)
fmt.Fprintf(out, "connection '%s' not found\n", name)
}
return true
case cmd == "/status":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/status", "")
printJSON(d)
printJSON(out, d)
} else {
state.Send("/status")
}
@ -124,7 +125,7 @@ Any other text is sent to the agent directly.`)
case cmd == "/kernel":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/kernel", "")
printJSON(d)
printJSON(out, d)
} else {
state.Send("/kernel")
}
@ -133,13 +134,13 @@ Any other text is sent to the agent directly.`)
case strings.HasPrefix(cmd, "/settings set "):
parts := strings.SplitN(cmd[14:], " ", 2)
if len(parts) < 2 {
fmt.Println("usage: /settings set <key> <value>")
fmt.Fprintln(out, "usage: /settings set <key> <value>")
return true
}
if rc := state.RemoteConn(); rc != nil {
body := fmt.Sprintf(`{"%s":%q}`, parts[0], parts[1])
rc.DoAPI("PUT", "/api/v1/settings", body)
fmt.Println("ok")
fmt.Fprintln(out, "ok")
} else {
state.Send(cmd[1:])
}
@ -148,7 +149,7 @@ Any other text is sent to the agent directly.`)
case strings.HasPrefix(cmd, "/settings"):
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
printJSON(d)
printJSON(out, d)
} else {
state.Send(cmd[1:])
}
@ -157,7 +158,7 @@ Any other text is sent to the agent directly.`)
case cmd == "/plugin list":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/plugins", "")
printJSON(d)
printJSON(out, d)
} else {
state.Send("/plugin list")
}
@ -168,7 +169,7 @@ Any other text is sent to the agent directly.`)
if rc := state.RemoteConn(); rc != nil {
body := fmt.Sprintf(`{"url":%q}`, url)
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
printJSON(d)
printJSON(out, d)
} else {
state.Send(cmd[1:])
}
@ -178,7 +179,7 @@ Any other text is sent to the agent directly.`)
name := strings.TrimSpace(cmd[15:])
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
printJSON(d)
printJSON(out, d)
} else {
state.Send(cmd[1:])
}
@ -188,7 +189,7 @@ Any other text is sent to the agent directly.`)
name := strings.TrimSpace(cmd[13:])
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
printJSON(d)
printJSON(out, d)
} else {
state.Send(cmd[1:])
}
@ -198,7 +199,7 @@ Any other text is sent to the agent directly.`)
q := strings.TrimSpace(cmd[14:])
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
printJSON(d)
printJSON(out, d)
} else {
state.Send(cmd[1:])
}
@ -208,7 +209,7 @@ Any other text is sent to the agent directly.`)
name := strings.TrimSpace(cmd[18:])
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
printJSON(d)
printJSON(out, d)
} else {
state.Send(cmd[1:])
}
@ -217,7 +218,7 @@ Any other text is sent to the agent directly.`)
case cmd == "/knowledge":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
printJSON(d)
printJSON(out, d)
} else {
state.Send("/knowledge")
}
@ -226,7 +227,7 @@ Any other text is sent to the agent directly.`)
case cmd == "/agents":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/agents", "")
printJSON(d)
printJSON(out, d)
} else {
state.Send("/agents")
}
@ -237,11 +238,11 @@ Any other text is sent to the agent directly.`)
}
}
func printJSON(d map[string]interface{}) {
func printJSON(out io.Writer, d map[string]interface{}) {
if d == nil {
fmt.Println("(no data)")
fmt.Fprintln(out, "(no data)")
return
}
b, _ := json.MarshalIndent(d, "", " ")
fmt.Println(string(b))
fmt.Fprintln(out, string(b))
}

View File

@ -185,6 +185,22 @@ func main() {
runInteractive(state, cfg)
}
// runInteractive 交互入口TTY 下走 Bubble Tea 全屏 TUI非 TTY 回退行式 REPL。
func runInteractive(state *State, cfg *Config) {
history := newHistory(historyPath(), 1000)
history.load()
if isTTYFile(os.Stdin) && isTTYFile(os.Stdout) && colors {
if err := runTUI(state, cfg, history); err != nil {
printlnC(colorRed, fmt.Sprintf("tui: %v", err))
printlnC(colorYellow, "falling back to line mode")
runLineMode(state, cfg, history)
}
return
}
runLineMode(state, cfg, history)
}
func oneshot(state *State, msg string) {
stop := startSpinner("thinking...")
resp, err := state.SendChatStream(msg, func(rl respLine) {
@ -222,13 +238,11 @@ func runCapTest(capName, args string) {
fmt.Println("测试完成")
}
func runInteractive(state *State, cfg *Config) {
// runLineMode 传统行式 REPL非 TTY 回退 / TUI 启动失败时使用)。
func runLineMode(state *State, cfg *Config, history History) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
history := newHistory(historyPath(), 1000)
history.load()
line := newLineEditor(&history)
restore, err := setRawMode(0)
@ -298,7 +312,7 @@ loop:
}
if cmd[0] == '/' {
if handleBuiltin(cmd, cfg, state, reconnect) {
if handleBuiltin(cmd, cfg, state, reconnect, os.Stdout) {
if cmd == "/exit" || cmd == "/quit" {
break loop
}

677
cmd/waiter/tui.go Normal file
View File

@ -0,0 +1,677 @@
package main
import (
"context"
"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 // reader 世代号:旧 reader 迟到的错误会被忽略
}
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 error
readerGen int // 当前 reader 世代;重启时递增
readerAlive bool
reconnecting bool
}
func newTuiModel(state *State, cfg *Config, history History, lines chan string, errs chan error) 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 error) tea.Cmd {
return func() tea.Msg {
select {
case l := <-lines:
return serverLineMsg{l}
case e := <-errs:
return readerErrMsg{e}
}
}
}
func spinTick() tea.Cmd {
return tea.Tick(90*time.Millisecond, func(time.Time) tea.Msg { return spinnerTickMsg{} })
}
// 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()
}
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()
}
return m, nil
}
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 error, 8))
m.readerAlive = true
go m.readPump(m.readerGen) // 初始读循环
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}