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:
117
teams.go
Normal file
117
teams.go
Normal file
@@ -0,0 +1,117 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user