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
118 lines
3.4 KiB
Go
118 lines
3.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// teamConfig is the shape of ~/.claude/teams/<name>/config.json
|
|
type teamConfig struct {
|
|
Name string `json:"name"`
|
|
Members []teamMember `json:"members"`
|
|
}
|
|
|
|
type teamMember struct {
|
|
AgentID string `json:"agentId"` // e.g. "backend-agent@lazy-tree"
|
|
Name string `json:"name"` // e.g. "backend-agent"
|
|
}
|
|
|
|
// loadTeamAgentNames scans all team config files and returns a map
|
|
// of agentID prefix -> name. The agentId in team configs has the format
|
|
// "name@team-name", but the agentId in JSONL files is a short hex ID.
|
|
// We can't directly match these, so instead we return all names and
|
|
// try to match by looking at the JSONL filename pattern.
|
|
//
|
|
// Actually, the reliable approach: scan team configs and also try to
|
|
// match via the session. For now, we build a map that can be matched
|
|
// by the short agent ID from JSONL files.
|
|
func loadTeamAgentNames() map[string]string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
result := make(map[string]string)
|
|
|
|
// Scan all team configs
|
|
teamsDir := filepath.Join(home, ".claude", "teams")
|
|
teamDirs, err := os.ReadDir(teamsDir)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
|
|
for _, td := range teamDirs {
|
|
if !td.IsDir() {
|
|
continue
|
|
}
|
|
configPath := filepath.Join(teamsDir, td.Name(), "config.json")
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var cfg teamConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
continue
|
|
}
|
|
for _, member := range cfg.Members {
|
|
// The agentId in config is "name@team" format
|
|
// We store by the name part — will try to match later
|
|
name := member.Name
|
|
if name == "" {
|
|
continue
|
|
}
|
|
// Store the full config agent ID -> name mapping
|
|
result[member.AgentID] = name
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// resolveAgentName tries to find an agent's team name by checking if
|
|
// any team config's member agentId contains this short agent ID as a prefix,
|
|
// or by scanning the initial user event for context clues.
|
|
func resolveAgentName(agentID string, agentNames map[string]string, events []Event) string {
|
|
// Strategy 1: Check the initial user event for this agent.
|
|
// The Task tool's "name" parameter appears in the parent's JSONL, not the agent's own.
|
|
// But the first user event may contain "You are <name>" or the agent name in context.
|
|
for i := range events {
|
|
evt := &events[i]
|
|
if evt.AgentID != agentID || evt.Type != "user" {
|
|
continue
|
|
}
|
|
// Check for Task tool spawn message with agent name in content
|
|
var msg messageEnvelope
|
|
if err := json.Unmarshal(evt.Message, &msg); err != nil {
|
|
continue
|
|
}
|
|
var contentStr string
|
|
if err := json.Unmarshal(msg.Content, &contentStr); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Look for teammate_id in the message — this is who SENT it,
|
|
// and the agent often addresses itself by a role name
|
|
// Check for "You are <name>" or "You are Agent <letter>"
|
|
if idx := strings.Index(contentStr, "You are "); idx >= 0 {
|
|
rest := contentStr[idx+8:]
|
|
// Take the next word or words until a period/newline
|
|
end := strings.IndexAny(rest, ".\n,")
|
|
if end > 0 && end < 40 {
|
|
candidate := strings.TrimSpace(rest[:end])
|
|
// Clean up common patterns
|
|
candidate = strings.TrimPrefix(candidate, "the ")
|
|
if candidate != "" && len(candidate) < 30 {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only check the first user event
|
|
break
|
|
}
|
|
|
|
return ""
|
|
}
|