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
This commit is contained in:
570
model.go
Normal file
570
model.go
Normal file
@@ -0,0 +1,570 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user