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
111 lines
2.4 KiB
Go
111 lines
2.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// SessionInfo holds metadata about a discovered session.
|
|
type SessionInfo struct {
|
|
ID string
|
|
AgentCount int
|
|
EventCount int
|
|
FirstSeen time.Time
|
|
LastSeen time.Time
|
|
Agents map[string]string // agentID -> slug
|
|
}
|
|
|
|
// DiscoverSessions scans all JSONL files in a directory and groups them by sessionId.
|
|
// Returns sessions sorted by LastSeen descending (most recent first).
|
|
func DiscoverSessions(dir string) ([]SessionInfo, error) {
|
|
matches, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sessions := make(map[string]*SessionInfo)
|
|
|
|
for _, path := range matches {
|
|
scanFileForSessions(path, sessions)
|
|
}
|
|
|
|
// Convert to slice and sort by LastSeen descending
|
|
result := make([]SessionInfo, 0, len(sessions))
|
|
for _, s := range sessions {
|
|
s.AgentCount = len(s.Agents)
|
|
result = append(result, *s)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].LastSeen.After(result[j].LastSeen)
|
|
})
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// scanFileForSessions reads a JSONL file and registers sessions found.
|
|
// It reads the first and last few lines for speed on large files.
|
|
func scanFileForSessions(path string, sessions map[string]*SessionInfo) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 512*1024), 512*1024)
|
|
|
|
for scanner.Scan() {
|
|
var evt struct {
|
|
SessionID string `json:"sessionId"`
|
|
AgentID string `json:"agentId"`
|
|
Slug string `json:"slug"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil {
|
|
continue
|
|
}
|
|
if evt.SessionID == "" {
|
|
continue
|
|
}
|
|
|
|
s, ok := sessions[evt.SessionID]
|
|
if !ok {
|
|
s = &SessionInfo{
|
|
ID: evt.SessionID,
|
|
Agents: make(map[string]string),
|
|
}
|
|
sessions[evt.SessionID] = s
|
|
}
|
|
|
|
s.EventCount++
|
|
|
|
ts := parseTimestamp(evt.Timestamp)
|
|
if !ts.IsZero() {
|
|
if s.FirstSeen.IsZero() || ts.Before(s.FirstSeen) {
|
|
s.FirstSeen = ts
|
|
}
|
|
if ts.After(s.LastSeen) {
|
|
s.LastSeen = ts
|
|
}
|
|
}
|
|
|
|
if evt.AgentID != "" {
|
|
if _, exists := s.Agents[evt.AgentID]; !exists {
|
|
s.Agents[evt.AgentID] = evt.Slug
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// shortSessionID returns the first 8 chars of a session UUID.
|
|
func shortSessionID(id string) string {
|
|
if len(id) > 8 {
|
|
return id[:8]
|
|
}
|
|
return id
|
|
}
|