yao/agent/context/tui.go
Max af981dc0b0 Update dependencies and enhance logging in the Assistant module
- Add new indirect dependencies including various Charmbracelet packages for improved UI handling.
- Enhance logging in the Assistant module by adding tool completion and start logging for better traceability of tool calls.
- Modify context handling in the RequestLogger to support a stack-based assistant ID management, improving the logging structure for agent requests.
- Implement event service integration for better trace management and debugging capabilities.
2026-02-23 19:22:31 +08:00

799 lines
20 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 context
import (
"fmt"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
tuiProgram *tea.Program
tuiProgramMu sync.RWMutex
)
// SetTUIProgram sets the global TUI program (called from start.go after HTTP READY)
func SetTUIProgram(p *tea.Program) {
tuiProgramMu.Lock()
tuiProgram = p
tuiProgramMu.Unlock()
}
// GetTUIProgram returns the global TUI program (nil if not in TUI mode)
func GetTUIProgram() *tea.Program {
tuiProgramMu.RLock()
defer tuiProgramMu.RUnlock()
return tuiProgram
}
// SendTUI sends a message to the TUI program if available
func SendTUI(msg tea.Msg) {
if p := GetTUIProgram(); p != nil {
p.Send(msg)
}
}
// TUILogWriter implements io.Writer to bridge gou DevWriter -> TUI AppLogMsg
type TUILogWriter struct {
Program *tea.Program
}
func (w *TUILogWriter) Write(p []byte) (n int, err error) {
content := strings.TrimRight(string(p), "\n")
if content == "" {
return len(p), nil
}
w.Program.Send(AppLogMsg{Content: content})
return len(p), nil
}
// ─── Styles ───────────────────────────────────────────────────────────────────
var (
boxRunning = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("33")).
PaddingLeft(1).PaddingRight(1)
boxDone = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")).
PaddingLeft(1).PaddingRight(1)
boxFailed = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("31")).
PaddingLeft(1).PaddingRight(1)
boxAppLog = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")).
PaddingLeft(1).PaddingRight(1)
sRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("33"))
sDone = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
sFailed = lipgloss.NewStyle().Foreground(lipgloss.Color("31"))
sDim = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
sBold = lipgloss.NewStyle().Bold(true)
sYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("33"))
sRed = lipgloss.NewStyle().Foreground(lipgloss.Color("31"))
sBlue = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
sMagenta = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
sTree = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
)
// ─── Data ─────────────────────────────────────────────────────────────────────
// RequestPanel represents a single top-level agent request
type RequestPanel struct {
RequestID string
ShortID string
AssistantID string
StartTime time.Time
EndTime time.Time // set when done/failed, freezes elapsed display
Status PanelStatus
Nodes []TreeNode
ParentID string
Collapsed bool
viewRow int // Y offset of the header line (for mouse click)
}
// TreeNode represents a step within a request panel
type TreeNode struct {
Kind NodeKind
Label string
Status NodeStatus
Detail string
Children []*TreeNode
StartTime time.Time
EndTime time.Time
Collapsed bool
}
// AgentTUIModel is the bubbletea Model for agent request visualization
type AgentTUIModel struct {
panels []*RequestPanel
panelIndex map[string]int // requestID -> index in panels (first registration wins)
appLogs []AppLogEntry
appLogExpand bool
appLogRow int // Y offset of app log header
cursor int
width int
height int
scrollOffset int
autoFollow bool // auto-scroll to bottom when new content arrives
mouseOn bool
quitting bool
}
// NewAgentTUIModel creates a new TUI model
func NewAgentTUIModel() AgentTUIModel {
return AgentTUIModel{
panels: []*RequestPanel{},
panelIndex: map[string]int{},
appLogs: []AppLogEntry{},
width: 80,
height: 24,
autoFollow: true,
}
}
func (m AgentTUIModel) Init() tea.Cmd {
return tickCmd()
}
func tickCmd() tea.Cmd {
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// ─── Update ───────────────────────────────────────────────────────────────────
func (m AgentTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
case tea.MouseMsg:
return m.handleMouse(msg)
case AgentEventMsg:
return m.handleAgentEvent(msg), nil
case AppLogMsg:
m.appLogs = append(m.appLogs, AppLogEntry{
Content: msg.Content,
Time: time.Now(),
})
return m, nil
case TickMsg:
return m, tickCmd()
}
return m, nil
}
func (m AgentTUIModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
topPanels := m.topLevelPanels()
total := len(topPanels) + 1 // +1 for app log
viewH := m.viewHeight()
switch msg.String() {
case "q", "ctrl+c":
m.quitting = true
return m, tea.Quit
// Scrolling
case "j", "down":
m.scrollOffset++
m.autoFollow = false
case "k", "up":
if m.scrollOffset > 0 {
m.scrollOffset--
}
m.autoFollow = false
case "pgdown", "ctrl+d":
m.scrollOffset += viewH / 2
m.autoFollow = false
case "pgup", "ctrl+u":
m.scrollOffset -= viewH / 2
if m.scrollOffset < 0 {
m.scrollOffset = 0
}
m.autoFollow = false
case "G", "end":
m.autoFollow = true
case "g", "home":
m.scrollOffset = 0
m.autoFollow = false
// Cursor navigation for panel selection (wraps around)
case "tab":
m.cursor = (m.cursor + 1) % total
m.scrollToCursor(topPanels)
case "shift+tab":
m.cursor = (m.cursor - 1 + total) % total
m.scrollToCursor(topPanels)
case "enter", " ":
if m.cursor < len(topPanels) {
topPanels[m.cursor].Collapsed = !topPanels[m.cursor].Collapsed
} else {
m.appLogExpand = !m.appLogExpand
}
case "c":
m.appLogExpand = !m.appLogExpand
case "a":
for _, p := range m.panels {
p.Collapsed = false
}
m.appLogExpand = true
case "A":
for _, p := range m.panels {
p.Collapsed = true
}
m.appLogExpand = false
case "m":
m.mouseOn = !m.mouseOn
if m.mouseOn {
return m, tea.EnableMouseCellMotion
}
return m, tea.DisableMouse
}
return m, nil
}
func (m AgentTUIModel) viewHeight() int {
h := m.height - 2 // reserve for status bar
if h < 4 {
h = 4
}
return h
}
func (m *AgentTUIModel) scrollToCursor(topPanels []*RequestPanel) {
targetRow := 0
if m.cursor < len(topPanels) {
targetRow = topPanels[m.cursor].viewRow
} else {
targetRow = m.appLogRow
}
viewH := m.viewHeight()
if targetRow < m.scrollOffset {
m.scrollOffset = targetRow
} else if targetRow >= m.scrollOffset+viewH {
m.scrollOffset = targetRow - viewH + 3
}
}
func (m AgentTUIModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch {
case msg.Button == tea.MouseButtonWheelUp:
m.scrollOffset -= 3
if m.scrollOffset < 0 {
m.scrollOffset = 0
}
m.autoFollow = false
return m, nil
case msg.Button == tea.MouseButtonWheelDown:
m.scrollOffset += 3
m.autoFollow = false
return m, nil
}
if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionRelease {
return m, nil
}
y := msg.Y + m.scrollOffset
// Check app log header
if y == m.appLogRow {
m.appLogExpand = !m.appLogExpand
return m, nil
}
// Check panel headers
for _, p := range m.panels {
if p.ParentID != "" {
continue
}
if y == p.viewRow {
p.Collapsed = !p.Collapsed
return m, nil
}
}
return m, nil
}
// ─── Agent Events ─────────────────────────────────────────────────────────────
func (m *AgentTUIModel) handleAgentEvent(msg AgentEventMsg) tea.Model {
switch msg.Event {
case EventRequestStart:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
// Delegate sub-call: same requestID, different assistantID.
// Add as a tree node inside the existing panel instead of creating a new one.
p := m.panels[idx]
p.Nodes = append(p.Nodes, TreeNode{
Kind: NodeA2A,
Label: msg.AssistantID,
Status: NodeRunning,
StartTime: time.Now(),
})
return m
}
panel := &RequestPanel{
RequestID: msg.RequestID,
ShortID: shortID(msg.RequestID),
AssistantID: msg.AssistantID,
StartTime: time.Now(),
Status: PanelRunning,
ParentID: msg.ParentID,
}
m.panelIndex[msg.RequestID] = len(m.panels)
m.panels = append(m.panels, panel)
case EventRequestEnd:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
p := m.panels[idx]
// Only mark panel done if the ending assistantID matches the panel's original assistantID
// (delegate sub-calls End with a different assistantID, they update their tree node instead)
if msg.AssistantID == p.AssistantID || msg.AssistantID == "" {
if errVal, has := msg.Data["error"]; has && errVal != nil {
p.Status = PanelFailed
} else {
p.Status = PanelSuccess
}
p.EndTime = time.Now()
p.Collapsed = true
// Finalize any still-running child nodes (e.g. hook interrupted mid-execution)
finalStatus := NodeDone
if p.Status == PanelFailed {
finalStatus = NodeFailed
}
for i := range p.Nodes {
if p.Nodes[i].Status == NodeRunning {
p.Nodes[i].Status = finalStatus
p.Nodes[i].EndTime = p.EndTime
}
}
} else {
// Delegate sub-call finished: mark its tree node as done
for i := len(p.Nodes) - 1; i >= 0; i-- {
if p.Nodes[i].Kind == NodeA2A && p.Nodes[i].Label == msg.AssistantID && p.Nodes[i].Status == NodeRunning {
p.Nodes[i].Status = NodeDone
p.Nodes[i].EndTime = time.Now()
break
}
}
}
}
case EventLLMCall:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
m.panels[idx].Nodes = append(m.panels[idx].Nodes, TreeNode{
Kind: NodeLLM, Label: "LLM", Status: NodeRunning, StartTime: time.Now(),
})
}
case EventLLMDone:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
p := m.panels[idx]
for i := len(p.Nodes) - 1; i >= 0; i-- {
if p.Nodes[i].Kind == NodeLLM && p.Nodes[i].Status == NodeRunning {
p.Nodes[i].Status = NodeDone
p.Nodes[i].EndTime = time.Now()
if d, has := msg.Data["detail"]; has {
p.Nodes[i].Detail = fmt.Sprintf("%v", d)
}
break
}
}
}
case EventToolCall:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
name := dataStr(msg.Data, "name")
m.panels[idx].Nodes = append(m.panels[idx].Nodes, TreeNode{
Kind: NodeTool, Label: name, Status: NodeRunning, StartTime: time.Now(),
})
}
case EventToolDone:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
name := dataStr(msg.Data, "name")
p := m.panels[idx]
for i := len(p.Nodes) - 1; i >= 0; i-- {
if p.Nodes[i].Kind == NodeTool && p.Nodes[i].Label == name && p.Nodes[i].Status == NodeRunning {
p.Nodes[i].Status = NodeDone
p.Nodes[i].EndTime = time.Now()
break
}
}
}
case EventHook:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
name := dataStr(msg.Data, "name")
m.panels[idx].Nodes = append(m.panels[idx].Nodes, TreeNode{
Kind: NodeHook, Label: name, Status: NodeRunning, StartTime: time.Now(),
})
}
case EventHookDone:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
p := m.panels[idx]
for i := len(p.Nodes) - 1; i >= 0; i-- {
if p.Nodes[i].Kind == NodeHook && p.Nodes[i].Status == NodeRunning {
p.Nodes[i].Status = NodeDone
p.Nodes[i].EndTime = time.Now()
break
}
}
}
case EventA2AStart:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
target := dataStr(msg.Data, "target")
m.panels[idx].Nodes = append(m.panels[idx].Nodes, TreeNode{
Kind: NodeA2A, Label: target, Status: NodeRunning, StartTime: time.Now(),
})
}
case EventA2ADone:
if idx, ok := m.panelIndex[msg.RequestID]; ok {
target := dataStr(msg.Data, "target")
p := m.panels[idx]
for i := len(p.Nodes) - 1; i >= 0; i-- {
if p.Nodes[i].Kind == NodeA2A && p.Nodes[i].Status == NodeRunning && (target == "" || p.Nodes[i].Label == target) {
p.Nodes[i].Status = NodeDone
p.Nodes[i].EndTime = time.Now()
break
}
}
}
}
return m
}
// ─── View ─────────────────────────────────────────────────────────────────────
func (m AgentTUIModel) View() string {
if m.quitting {
return ""
}
boxW := m.width - 2
if boxW < 40 {
boxW = 40
}
// Render full content
var sb strings.Builder
row := 0
topIdx := 0
for _, panel := range m.panels {
if panel.ParentID != "" {
continue
}
selected := (topIdx == m.cursor)
rendered := m.renderPanelBox(panel, boxW, selected, &row)
sb.WriteString(rendered)
sb.WriteString("\n")
row++
topIdx++
}
// App Log
m.appLogRow = row
sb.WriteString(m.renderAppLogBox(boxW, topIdx == m.cursor, &row))
fullContent := sb.String()
lines := strings.Split(fullContent, "\n")
totalLines := len(lines)
viewH := m.viewHeight()
// Auto-follow: snap to bottom
if m.autoFollow {
m.scrollOffset = totalLines - viewH
}
// Clamp scroll offset
maxScroll := totalLines - viewH
if maxScroll < 0 {
maxScroll = 0
}
if m.scrollOffset > maxScroll {
m.scrollOffset = maxScroll
}
if m.scrollOffset < 0 {
m.scrollOffset = 0
}
// Slice visible lines
end := m.scrollOffset + viewH
if end > totalLines {
end = totalLines
}
visible := lines[m.scrollOffset:end]
// Build output
var out strings.Builder
out.WriteString(strings.Join(visible, "\n"))
// Status bar with scroll indicator
mouseLabel := "off"
if m.mouseOn {
mouseLabel = "on"
}
scrollInfo := ""
if totalLines > viewH {
pct := 100
if maxScroll > 0 {
pct = m.scrollOffset * 100 / maxScroll
}
scrollInfo = fmt.Sprintf(" [%d%%]", pct)
}
followLabel := ""
if m.autoFollow {
followLabel = " AUTO"
}
hint := sDim.Render(fmt.Sprintf(" j/k:scroll tab:select space:toggle a/A:all G:bottom g:top m:mouse(%s)%s%s q:quit",
mouseLabel, scrollInfo, followLabel))
out.WriteString("\n" + hint)
return out.String()
}
func (m AgentTUIModel) renderPanelBox(panel *RequestPanel, boxW int, selected bool, row *int) string {
// Record header row for mouse
panel.viewRow = *row
elapsed := m.panelElapsed(panel)
icon, statusText, style := panelStatusDisplay(panel.Status, elapsed)
// Title line
collapser := "▾"
if panel.Collapsed {
collapser = "▸"
}
cursor := " "
if selected {
cursor = ""
}
title := fmt.Sprintf("%s %s %s %s %s",
sDim.Render(cursor),
sDim.Render(collapser),
sBold.Render(panel.ShortID),
panel.AssistantID,
style.Render(icon+" "+statusText),
)
if panel.Collapsed {
box := boxForStatus(panel.Status).Width(boxW)
result := box.Render(title)
*row += strings.Count(result, "\n") + 1
return result
}
// Build body
var body strings.Builder
body.WriteString(title + "\n")
for _, node := range panel.Nodes {
body.WriteString(m.renderTreeNode(node, " ", false, panel))
}
// Render fork children (different requestID, parentID matches)
children := m.childPanels(panel.RequestID)
for i, child := range children {
isLast := (i == len(children)-1)
body.WriteString(m.renderChildSummary(child, " ", isLast))
}
box := boxForStatus(panel.Status).Width(boxW)
result := box.Render(body.String())
*row += strings.Count(result, "\n") + 1
return result
}
func (m AgentTUIModel) renderTreeNode(node TreeNode, prefix string, isChild bool, panel *RequestPanel) string {
panelEnded := panel != nil && panel.Status != PanelRunning
displayNode := node
if panelEnded && displayNode.Status == NodeRunning {
displayNode.Status = NodeFailed
}
icon, statusText := nodeStatusDisplay(displayNode)
elapsed := m.nodeElapsed(node, panelEnded, panel.EndTime)
label := ""
switch node.Kind {
case NodeHook:
label = sMagenta.Render("Hook: "+node.Label) + " " + statusText
case NodeLLM:
detail := ""
if node.Detail != "" {
detail = " " + sDim.Render("["+node.Detail+"]")
}
label = sBlue.Render("LLM") + " " + statusText + detail
case NodeTool:
label = sTree.Render("├ ") + sYellow.Render(node.Label) + " " + statusText
case NodeA2A:
label = sTree.Render("⤷ ") + sBold.Render(node.Label) + " " + statusText
case NodePhase:
label = node.Label + " " + statusText
default:
label = node.Label + " " + statusText
}
_ = icon
line := prefix + label
if elapsed != "" {
line += " " + sDim.Render(elapsed)
}
return line + "\n"
}
func (m AgentTUIModel) renderChildSummary(panel *RequestPanel, prefix string, isLast bool) string {
elapsed := m.panelElapsed(panel)
icon, statusText, style := panelStatusDisplay(panel.Status, elapsed)
branch := sTree.Render("├─ ")
if isLast {
branch = sTree.Render("└─ ")
}
return fmt.Sprintf("%s%s%s %s %s\n",
prefix, branch,
sBold.Render(panel.ShortID+" "+panel.AssistantID),
style.Render(icon+" "+statusText),
sDim.Render(elapsed),
)
}
func (m AgentTUIModel) renderAppLogBox(boxW int, selected bool, row *int) string {
cursor := " "
if selected {
cursor = ""
}
collapser := "▸"
if m.appLogExpand {
collapser = "▾"
}
count := len(m.appLogs)
title := fmt.Sprintf("%s %s %s (%d)",
sDim.Render(cursor),
sDim.Render(collapser),
sBold.Render("App Output"),
count,
)
if !m.appLogExpand || count == 0 {
result := boxAppLog.Width(boxW).Render(title)
*row += strings.Count(result, "\n") + 1
return result
}
var body strings.Builder
body.WriteString(title + "\n")
start := 0
if count > 50 {
start = count - 50
}
for _, entry := range m.appLogs[start:] {
body.WriteString(" " + entry.Content + "\n")
}
result := boxAppLog.Width(boxW).Render(body.String())
*row += strings.Count(result, "\n") + 1
return result
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func (m AgentTUIModel) topLevelPanels() []*RequestPanel {
var result []*RequestPanel
for _, p := range m.panels {
if p.ParentID == "" {
result = append(result, p)
}
}
return result
}
func (m AgentTUIModel) childPanels(parentRequestID string) []*RequestPanel {
var result []*RequestPanel
for _, p := range m.panels {
if p.ParentID == parentRequestID {
result = append(result, p)
}
}
return result
}
func (m AgentTUIModel) panelElapsed(p *RequestPanel) string {
if p.Status != PanelRunning && !p.EndTime.IsZero() {
return fmtDuration(p.EndTime.Sub(p.StartTime))
}
return fmtDuration(time.Since(p.StartTime))
}
func (m AgentTUIModel) nodeElapsed(n TreeNode, panelEnded bool, panelEndTime time.Time) string {
if n.Status == NodeDone || n.Status == NodeFailed {
if !n.EndTime.IsZero() {
return fmtDuration(n.EndTime.Sub(n.StartTime))
}
}
if n.Status == NodeRunning {
if panelEnded && !panelEndTime.IsZero() {
return fmtDuration(panelEndTime.Sub(n.StartTime))
}
return fmtDuration(time.Since(n.StartTime))
}
return ""
}
func panelStatusDisplay(status PanelStatus, elapsed string) (icon string, text string, style lipgloss.Style) {
switch status {
case PanelRunning:
return "⟳", "running " + elapsed, sRunning
case PanelSuccess:
return "✓", "done " + elapsed, sDone
case PanelFailed:
return "✗", "failed " + elapsed, sFailed
}
return "", "", sDim
}
func nodeStatusDisplay(n TreeNode) (icon string, text string) {
switch n.Status {
case NodePending:
return "…", sDim.Render("…")
case NodeRunning:
return "⟳", sRunning.Render("⟳")
case NodeDone:
return "✓", sDone.Render("✓")
case NodeFailed:
return "✗", sFailed.Render("✗")
}
return "", ""
}
func boxForStatus(status PanelStatus) lipgloss.Style {
switch status {
case PanelRunning:
return boxRunning
case PanelFailed:
return boxFailed
default:
return boxDone
}
}
func dataStr(data map[string]interface{}, key string) string {
if v, ok := data[key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
}
func fmtDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.1fs", d.Seconds())
}