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
159 lines
4.0 KiB
Go
159 lines
4.0 KiB
Go
package main
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// findSubagentsDir searches ~/.claude/projects for a session ID prefix
|
||
// and returns the subagents directory path.
|
||
func findSubagentsDir(sessionPrefix string) (string, error) {
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
return "", fmt.Errorf("cannot determine home directory: %w", err)
|
||
}
|
||
|
||
projectsRoot := filepath.Join(home, ".claude", "projects")
|
||
|
||
// Walk project dirs looking for a session dir matching the prefix
|
||
projectDirs, err := os.ReadDir(projectsRoot)
|
||
if err != nil {
|
||
return "", fmt.Errorf("cannot read %s: %w", projectsRoot, err)
|
||
}
|
||
|
||
for _, pd := range projectDirs {
|
||
if !pd.IsDir() {
|
||
continue
|
||
}
|
||
projectPath := filepath.Join(projectsRoot, pd.Name())
|
||
sessionDirs, err := os.ReadDir(projectPath)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, sd := range sessionDirs {
|
||
if !sd.IsDir() {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(sd.Name(), sessionPrefix) {
|
||
subagents := filepath.Join(projectPath, sd.Name(), "subagents")
|
||
if info, err := os.Stat(subagents); err == nil && info.IsDir() {
|
||
return subagents, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return "", fmt.Errorf("no subagents directory found for session %q", sessionPrefix)
|
||
}
|
||
|
||
func main() {
|
||
sessionFlag := flag.String("s", "", "Session ID prefix. Searches ~/.claude/projects for matching subagents directory.")
|
||
listSessions := flag.Bool("ls", false, "List all sessions and exit.")
|
||
flag.Usage = func() {
|
||
fmt.Fprintf(os.Stderr, "Usage: agent-tui [flags] [directory]\n\n")
|
||
fmt.Fprintf(os.Stderr, "Tail Claude Code subagent JSONL logs with a terminal UI.\n\n")
|
||
fmt.Fprintf(os.Stderr, " agent-tui -s 1f02 Find session by ID prefix\n")
|
||
fmt.Fprintf(os.Stderr, " agent-tui ./subagents/ Watch a specific directory\n")
|
||
fmt.Fprintf(os.Stderr, " agent-tui -ls ./subagents/ List sessions in directory\n\n")
|
||
fmt.Fprintf(os.Stderr, "Flags:\n")
|
||
flag.PrintDefaults()
|
||
}
|
||
flag.Parse()
|
||
|
||
var dir string
|
||
|
||
if *sessionFlag != "" && flag.NArg() == 0 {
|
||
// Session ID mode: find the subagents dir automatically
|
||
found, err := findSubagentsDir(*sessionFlag)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
dir = found
|
||
fmt.Fprintf(os.Stderr, "Found: %s\n", dir)
|
||
} else {
|
||
// Directory mode
|
||
dir = "."
|
||
if flag.NArg() > 0 {
|
||
dir = flag.Arg(0)
|
||
}
|
||
|
||
// Verify directory exists
|
||
info, err := os.Stat(dir)
|
||
if err != nil || !info.IsDir() {
|
||
fmt.Fprintf(os.Stderr, "Error: %q is not a valid directory\n", dir)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
// Discover sessions
|
||
sessions, err := DiscoverSessions(dir)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error scanning sessions: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
if len(sessions) == 0 {
|
||
fmt.Fprintf(os.Stderr, "No JSONL files found in %q\n", dir)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// List sessions mode
|
||
if *listSessions {
|
||
fmt.Printf("Sessions in %s:\n\n", dir)
|
||
for i, s := range sessions {
|
||
marker := " "
|
||
if i == 0 {
|
||
marker = "→ "
|
||
}
|
||
fmt.Printf("%s%-8s %d agents %4d events %s – %s\n",
|
||
marker,
|
||
shortSessionID(s.ID),
|
||
s.AgentCount,
|
||
s.EventCount,
|
||
s.FirstSeen.Format("15:04:05"),
|
||
s.LastSeen.Format("15:04:05"),
|
||
)
|
||
for agentID, slug := range s.Agents {
|
||
fmt.Printf(" agent %s (%s)\n", agentID[:min(7, len(agentID))], slug)
|
||
}
|
||
}
|
||
fmt.Printf("\n(→ = most recent, used by default)\n")
|
||
return
|
||
}
|
||
|
||
// Select session — when using -s to find the dir, there's typically
|
||
// only one session per subagents dir, so just use the first
|
||
activeSession := sessions[0].ID
|
||
|
||
watcher := NewWatcher(dir)
|
||
if err := watcher.Start(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error starting watcher: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
m := newModel(dir, sessions, activeSession)
|
||
m.watcher = watcher
|
||
|
||
// Load initial events in bulk (already sorted by timestamp)
|
||
for _, evt := range watcher.InitialEvents() {
|
||
m.handleNewEvent(evt)
|
||
}
|
||
|
||
p := tea.NewProgram(
|
||
m,
|
||
tea.WithAltScreen(),
|
||
tea.WithMouseCellMotion(),
|
||
)
|
||
|
||
if _, err := p.Run(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|