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:
Chris Davies
2026-02-13 13:48:30 -05:00
commit 927ecb83fe
12 changed files with 2016 additions and 0 deletions

158
main.go Normal file
View File

@@ -0,0 +1,158 @@
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)
}
}