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

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 ""
}