Files
agent-tui/main.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

159 lines
4.0 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 (
"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)
}
}