mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
Expose the LLM token-level streaming deltas (EventReasoningDelta /
EventContentDelta) to every client channel and add user-initiated
interrupt (cancel generation / send interrupt message) to all three
frontends, preserving the existing interrupt-injection semantics.
SDK/events:
- EventReasoningDelta, EventContentDelta constants exported in the
public/internal SDK event alias tables.
CLI plugin:
- handleChat subscribes to both delta events and forwards
reasoning_delta / content_delta JSON frames (channel-filtered);
aggregated reasoning/tool_call/response frames still fire as before.
- New /stop (alias /interrupt) builtin injects an interrupt via
InjectInterrupt(cliSource, cliChannel) - matches interceptLoop
semantics: cancels an active stream and re-injects the message as
a [中断消息] for a restarted turn; with no active LLM it behaves
as a plain input.
Waiter client (line mode + TUI):
- streamRender accumulates delta chunks and redraws the current line;
a reset frame (stream abandoned, e.g. user interrupt) flushes the
partial buffer so the next turn does not concatenate onto stale
content. Aggregated frames terminate the delta line and render the
final text (old servers without deltas behave exactly as before).
- TUI merges content_delta into the in-flight agent message and seals
it (final flag) on response/tool_call/error so subsequent deltas
never append to a finished message.
WebUI:
- SSE handler subscribes to the two delta events but does NOT record
them into the replay ring - reconnection replays only aggregated
events (the final truth), avoiding duplicate delta accumulation.
- POST /api/v1/chat/interrupt calls InjectInterrupt(webui, webui)
with optional message; fronted by a Stop button shown only while
a generation is in flight.
dashboard.html / GUI app.js:
- Stop button next to Send (hidden until chatLoading); interruptChat
POSTs /chat/interrupt. Delta listeners append incrementally;
agent_output (aggregated) now replaces (not appends) the in-flight
content and marks _final; reset frames finalize the partial message.
process.go:
- chatStreamWithFallback preserves the context.Canceled/
DeadlineExceeded contract: a user interrupt returns the canceled
error (never a partial-content success) so the existing continue
branch restarts the turn with the [中断消息]. A reset
EventContentDelta is published so connected clients drop stale
partial renderings before the new turn begins.
Verified: /stop 'msg' via waiter triggers 'interrupt from cli/cli' in
interceptLoop; unit TestChatStreamCancelPreservesInterrupt confirms the
canceled error propagates instead of being swallowed.
735 lines
19 KiB
Go
735 lines
19 KiB
Go
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: 结果预览
|
||
final bool // msgAgent: 流式消息已完成(后续 delta 不再追加)
|
||
}
|
||
|
||
// ---- 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 "reasoning_delta":
|
||
// token 级增量:与 reasoning 同样合并到最后一条 reasoning 消息
|
||
if rl.Reset {
|
||
m.messages = []chatMsg{}
|
||
break
|
||
}
|
||
if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgReasoning {
|
||
m.messages[n-1].text += rl.Content
|
||
} else if rl.Content != "" {
|
||
m.append(chatMsg{kind: msgReasoning, text: rl.Content})
|
||
}
|
||
case "content_delta":
|
||
// token 级增量:追加到最后一条 agent 消息(流式生成中的回复)
|
||
if rl.Reset {
|
||
m.sealLastAgent()
|
||
break
|
||
}
|
||
if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgAgent && !m.messages[n-1].final {
|
||
m.messages[n-1].text += rl.Content
|
||
} else if rl.Content != "" {
|
||
m.append(chatMsg{kind: msgAgent, text: rl.Content})
|
||
}
|
||
case "tool_call":
|
||
// 工具调用打断内容流:置 final 防止后续 delta 误追加到旧消息
|
||
m.sealLastAgent()
|
||
m.append(chatMsg{kind: msgTool, tool: rl.Tool, status: rl.Status, result: rl.Result})
|
||
case "response":
|
||
// 聚合最终响应:覆盖/替换 delta 累积的最后一条 agent 消息(内容相同),
|
||
// 或在无 delta 时新建。置 final 标记本轮完成。
|
||
m.busy = false
|
||
if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgAgent && !m.messages[n-1].final {
|
||
if rl.Content != "" {
|
||
m.messages[n-1].text = rl.Content // 以聚合为准(含 stage 插件改写后的最终文本)
|
||
}
|
||
m.messages[n-1].final = true
|
||
} else {
|
||
cm := chatMsg{kind: msgAgent, text: rl.Content, final: true}
|
||
m.append(cm)
|
||
}
|
||
case "error":
|
||
m.busy = false
|
||
m.sealLastAgent()
|
||
m.append(chatMsg{kind: msgError, text: rl.Error})
|
||
default:
|
||
// 非 JSON 旧行(旧服务器):当最终输出
|
||
m.busy = false
|
||
m.append(chatMsg{kind: msgAgent, text: line, final: true})
|
||
}
|
||
m.refreshViewport()
|
||
}
|
||
|
||
// sealLastAgent 将最后一条未完成的 agent 流式消息标记为完成。
|
||
func (m *tuiModel) sealLastAgent() {
|
||
if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgAgent {
|
||
m.messages[n-1].final = true
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|