Files
agent-tui/model.go
Chris Davies 927ecb83fe Initial commit: agent-tui
Bubble Tea TUI for tailing Claude Code subagent JSONL logs.

Features:
- Tabbed interface with one tab per agent, sorted by most recent activity
- Markdown rendering via glamour (Dracula theme)
- Session discovery and filtering with -s flag
- Auto-discovers subagents dir from session ID prefix
- Live tailing with follow mode, mouse scroll support
- Agent name resolution from team config files
- Tail-from-bottom: only reads last 200 lines per file on startup
2026-02-13 13:48:30 -05:00

571 lines
13 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 main
import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// agentInfo tracks metadata about a discovered agent.
type agentInfo struct {
id string
slug string
name string // team agent name (from config or initial message)
colorIdx int
lastSeen time.Time
}
type model struct {
// Directory being watched
dir string
// File watcher
watcher *Watcher
// Session tracking
sessions []SessionInfo // all sessions, sorted most recent first
activeSession string // currently displayed session ID
sessionIdx int // index into sessions slice
// All events received, in order (unfiltered)
events []Event
// Agent tracking (for current session only)
agents []agentInfo // sorted by most recent activity
agentIndex map[string]int // agentID -> index into agents
agentLines map[string][]string // agentID -> rendered lines for that agent
agentNames map[string]string // agentID -> resolved name (from team config)
// Stable color assignment (doesn't change when tabs reorder)
agentColorMap map[string]int // agentID -> color index
nextColor int
// All rendered lines (for "All" tab, current session)
allLines []string
// Viewport for scrollable content
vp viewport.Model
// UI state
activeTabID string // agent ID of selected tab ("" = All tab)
follow bool // auto-scroll to bottom
showTokens bool
width int
height int
ready bool
}
func newModel(dir string, sessions []SessionInfo, activeSession string) model {
sessionIdx := 0
for i, s := range sessions {
if s.ID == activeSession {
sessionIdx = i
break
}
}
// Try to load agent names from team config files
agentNames := loadTeamAgentNames()
return model{
dir: dir,
sessions: sessions,
activeSession: activeSession,
sessionIdx: sessionIdx,
agentIndex: make(map[string]int),
agentLines: make(map[string][]string),
agentNames: agentNames,
agentColorMap: make(map[string]int),
follow: true,
showTokens: false,
}
}
// waitForEvent returns a tea.Cmd that waits for the next event from the watcher.
func (m model) waitForEvent() tea.Cmd {
return func() tea.Msg {
evt, ok := <-m.watcher.Events()
if !ok {
return nil
}
return evt
}
}
func (m model) Init() tea.Cmd {
// Watcher is already started by main.go before tea.NewProgram.
// Just start listening for events.
return m.waitForEvent()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
if m.watcher != nil {
m.watcher.Stop()
}
return m, tea.Quit
case "f":
m.follow = !m.follow
if m.follow {
m.vp.GotoBottom()
}
case "t":
m.showTokens = !m.showTokens
m.rebuildAllLines()
m.syncViewport()
case "s":
// Next session
if len(m.sessions) > 1 {
m.sessionIdx = (m.sessionIdx + 1) % len(m.sessions)
m.switchSession(m.sessions[m.sessionIdx].ID)
}
case "S":
// Previous session
if len(m.sessions) > 1 {
m.sessionIdx = (m.sessionIdx - 1 + len(m.sessions)) % len(m.sessions)
m.switchSession(m.sessions[m.sessionIdx].ID)
}
case "left", "h":
tabIdx := m.activeTabIndex()
if tabIdx > 0 {
tabIdx--
if tabIdx == 0 {
m.activeTabID = ""
} else {
m.activeTabID = m.agents[tabIdx-1].id
}
m.syncViewport()
}
case "right", "l":
tabIdx := m.activeTabIndex()
if tabIdx < len(m.agents) {
tabIdx++
m.activeTabID = m.agents[tabIdx-1].id
m.syncViewport()
}
case "0", "a":
m.activeTabID = ""
m.syncViewport()
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
n := int(msg.String()[0] - '0')
if n <= len(m.agents) {
m.activeTabID = m.agents[n-1].id
m.syncViewport()
}
case "G", "end":
m.follow = true
m.vp.GotoBottom()
case "g", "home":
m.follow = false
m.vp.GotoTop()
}
case tea.MouseMsg:
// Let viewport handle mouse scrolling
wasAtBottom := m.vp.AtBottom()
var cmd tea.Cmd
m.vp, cmd = m.vp.Update(msg)
cmds = append(cmds, cmd)
// If user scrolled up, disable follow
if wasAtBottom && !m.vp.AtBottom() {
m.follow = false
}
// If user scrolled to bottom, enable follow
if m.vp.AtBottom() {
m.follow = true
}
return m, tea.Batch(cmds...)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
headerHeight := 3 // session bar + tab bar + divider
footerHeight := 1 // status bar
vpHeight := m.height - headerHeight - footerHeight
if !m.ready {
m.vp = viewport.New(m.width, vpHeight)
m.vp.MouseWheelEnabled = true
m.vp.MouseWheelDelta = 3
m.ready = true
} else {
m.vp.Width = m.width
m.vp.Height = vpHeight
}
m.rebuildAllLines()
m.syncViewport()
case EventMsg:
m.handleNewEvent(msg.Event)
cmds = append(cmds, m.waitForEvent())
case WatcherErrorMsg:
// Could display error, for now just ignore
}
// Pass remaining messages to viewport (keyboard scrolling)
if m.ready {
wasAtBottom := m.vp.AtBottom()
var cmd tea.Cmd
m.vp, cmd = m.vp.Update(msg)
cmds = append(cmds, cmd)
if wasAtBottom && !m.vp.AtBottom() {
m.follow = false
}
}
return m, tea.Batch(cmds...)
}
// switchSession changes the active session and rebuilds the view.
func (m *model) switchSession(sessionID string) {
m.activeSession = sessionID
m.activeTabID = ""
// Reset agent tracking
m.agents = nil
m.agentIndex = make(map[string]int)
m.agentLines = make(map[string][]string)
m.agentColorMap = make(map[string]int)
m.nextColor = 0
m.allLines = nil
// Re-register agents and rebuild lines from stored events
for i := range m.events {
evt := &m.events[i]
if evt.SessionID != m.activeSession {
continue
}
m.registerAgent(evt)
m.updateAgentActivity(evt.AgentID, evt.parsed.time)
}
m.rebuildAllLines()
m.follow = true
m.syncViewport()
}
func (m *model) registerAgent(evt *Event) {
if evt.AgentID == "" {
return
}
if _, ok := m.agentIndex[evt.AgentID]; !ok {
// Assign a stable color
if _, hasColor := m.agentColorMap[evt.AgentID]; !hasColor {
m.agentColorMap[evt.AgentID] = m.nextColor
m.nextColor++
}
// Resolve name: team config > "You are X" in initial message > slug
name := ""
if n, ok := m.agentNames[evt.AgentID]; ok {
name = n
}
if name == "" {
name = resolveAgentName(evt.AgentID, m.agentNames, m.events)
}
m.agents = append(m.agents, agentInfo{
id: evt.AgentID,
slug: evt.Slug,
name: name,
colorIdx: m.agentColorMap[evt.AgentID],
lastSeen: evt.parsed.time,
})
m.rebuildAgentIndex()
}
}
// updateAgentActivity updates lastSeen and re-sorts tabs by most recent activity.
func (m *model) updateAgentActivity(agentID string, t time.Time) {
idx, ok := m.agentIndex[agentID]
if !ok {
return
}
if t.After(m.agents[idx].lastSeen) {
m.agents[idx].lastSeen = t
}
m.sortAgents()
}
// sortAgents sorts agents by lastSeen descending (most recent first).
func (m *model) sortAgents() {
sort.SliceStable(m.agents, func(i, j int) bool {
return m.agents[i].lastSeen.After(m.agents[j].lastSeen)
})
m.rebuildAgentIndex()
}
// rebuildAgentIndex rebuilds the agentID -> slice index map after sorting.
func (m *model) rebuildAgentIndex() {
m.agentIndex = make(map[string]int, len(m.agents))
for i, a := range m.agents {
m.agentIndex[a.id] = i
}
}
// activeTabIndex returns the current tab index (0 = All, 1+ = agent position).
func (m *model) activeTabIndex() int {
if m.activeTabID == "" {
return 0
}
if idx, ok := m.agentIndex[m.activeTabID]; ok {
return idx + 1
}
return 0
}
func (m *model) handleNewEvent(evt Event) {
m.events = append(m.events, evt)
// Register agent if it belongs to active session
if evt.SessionID == m.activeSession {
m.registerAgent(&evt)
m.updateAgentActivity(evt.AgentID, evt.parsed.time)
// Render this event
colorIdx := 0
if idx, ok := m.agentIndex[evt.AgentID]; ok {
colorIdx = m.agents[idx].colorIdx
}
lines := renderEvent(&evt, colorIdx, m.showTokens, m.width)
// Add to per-agent and all lines
for _, line := range lines {
m.allLines = append(m.allLines, line)
if evt.AgentID != "" {
m.agentLines[evt.AgentID] = append(m.agentLines[evt.AgentID], line)
}
}
m.syncViewport()
}
// Update session metadata if this is a new session we haven't seen
knownSession := false
for _, s := range m.sessions {
if s.ID == evt.SessionID {
knownSession = true
break
}
}
if !knownSession && evt.SessionID != "" {
m.sessions = append([]SessionInfo{{
ID: evt.SessionID,
AgentCount: 1,
EventCount: 1,
FirstSeen: evt.parsed.time,
LastSeen: evt.parsed.time,
Agents: map[string]string{evt.AgentID: evt.Slug},
}}, m.sessions...)
// Adjust sessionIdx since we prepended
m.sessionIdx++
}
}
// rebuildAllLines re-renders all events for the current session.
func (m *model) rebuildAllLines() {
m.allLines = nil
m.agentLines = make(map[string][]string)
for i := range m.events {
evt := &m.events[i]
if evt.SessionID != m.activeSession {
continue
}
colorIdx := 0
if idx, ok := m.agentIndex[evt.AgentID]; ok {
colorIdx = m.agents[idx].colorIdx
}
lines := renderEvent(evt, colorIdx, m.showTokens, m.width)
for _, line := range lines {
m.allLines = append(m.allLines, line)
if evt.AgentID != "" {
m.agentLines[evt.AgentID] = append(m.agentLines[evt.AgentID], line)
}
}
}
}
// syncViewport updates the viewport content from the current active tab.
func (m *model) syncViewport() {
if !m.ready {
return
}
var lines []string
if m.activeTabID == "" {
lines = m.allLines
} else {
lines = m.agentLines[m.activeTabID]
}
content := strings.Join(lines, "\n")
m.vp.SetContent(content)
if m.follow {
m.vp.GotoBottom()
}
}
func (m model) View() string {
if !m.ready {
return "Loading..."
}
// Session bar
sessionBar := m.renderSessionBar()
// Tab bar
tabs := m.renderTabs()
// Viewport
body := m.vp.View()
// Status bar
status := m.renderStatusBar()
return lipgloss.JoinVertical(lipgloss.Left, sessionBar, tabs, body, status)
}
func (m model) renderSessionBar() string {
cur := m.sessions[m.sessionIdx]
label := fmt.Sprintf(" session %s (%d agents, %s %s)",
shortSessionID(cur.ID),
cur.AgentCount,
cur.FirstSeen.Format("15:04:05"),
cur.LastSeen.Format("15:04:05"),
)
nav := ""
if len(m.sessions) > 1 {
nav = fmt.Sprintf(" %d/%d ", m.sessionIdx+1, len(m.sessions))
}
labelWidth := lipgloss.Width(label)
navWidth := lipgloss.Width(nav)
gap := m.width - labelWidth - navWidth
if gap < 0 {
gap = 0
}
return sessionBarStyle.Width(m.width).Render(
label + strings.Repeat(" ", gap) + nav)
}
func (m model) renderTabs() string {
var tabs []string
// "All" tab
if m.activeTabID == "" {
tabs = append(tabs, activeTabStyle.Render("All"))
} else {
tabs = append(tabs, inactiveTabStyle.Render("All"))
}
// Agent tabs (sorted by most recent activity)
for _, agent := range m.agents {
label := m.agentLabel(agent)
isActive := m.activeTabID == agent.id
if isActive {
style := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#282C34")).
Background(agentColors[agent.colorIdx%len(agentColors)]).
Padding(0, 1)
tabs = append(tabs, style.Render(label))
} else {
style := lipgloss.NewStyle().
Foreground(agentColors[agent.colorIdx%len(agentColors)]).
Padding(0, 1)
tabs = append(tabs, style.Render(label))
}
}
tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
// Divider
divider := dimStyle.Render(strings.Repeat("─", m.width))
return tabBar + "\n" + divider
}
// agentLabel returns the display label for an agent tab.
func (m model) agentLabel(agent agentInfo) string {
shortID := agent.id[:min(7, len(agent.id))]
if agent.name != "" {
return agent.name
}
if agent.slug != "" {
parts := strings.SplitN(agent.slug, "-", 4)
if len(parts) >= 3 {
return shortID + " " + parts[0] + "-" + parts[1]
}
}
return shortID
}
func (m model) renderStatusBar() string {
var followStr string
if m.follow {
followStr = followOnStyle.Render("● FOLLOW")
} else {
followStr = followOffStyle.Render("○ PAUSED")
}
var tokensStr string
if m.showTokens {
tokensStr = "tok:on"
} else {
tokensStr = "tok:off"
}
eventCount := len(m.allLines)
agentCount := len(m.agents)
info := fmt.Sprintf(" %d agents │ %d lines │ %s │ %s",
agentCount, eventCount, followStr, tokensStr)
keys := statusKeyStyle.Render("←→") + " tabs " +
statusKeyStyle.Render("s/S") + " session " +
statusKeyStyle.Render("f") + " follow " +
statusKeyStyle.Render("t") + " tokens " +
statusKeyStyle.Render("q") + " quit "
infoWidth := lipgloss.Width(info)
keysWidth := lipgloss.Width(keys)
gap := m.width - infoWidth - keysWidth
if gap < 0 {
gap = 0
}
return statusBarStyle.Width(m.width).Render(
info + strings.Repeat(" ", gap) + keys)
}