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
571 lines
13 KiB
Go
571 lines
13 KiB
Go
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)
|
||
}
|