commit 927ecb83fe7f82d4b5f2930b26232054bd8902ca Author: Chris Davies Date: Fri Feb 13 13:48:30 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82c2a22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +agent-tui +*.skill diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..84e5cd7 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,106 @@ +# agent-tui — Implementation Plan + +Go + Bubble Tea terminal UI for watching Claude Code subagent JSONL logs in real-time. + +## Architecture + +``` +agent-tui/ +├── main.go # CLI entry, arg parsing, tea.NewProgram +├── model.go # Bubble Tea model: Init, Update, View +├── event.go # JSONL event structs + parser +├── watcher.go # fsnotify directory watcher + line tailer +├── render.go # Render events as styled terminal lines +└── styles.go # Lipgloss styles, agent color palette +``` + +## Layout: Tabbed View + +``` +┌─[ All ]─[ a39dd48 flickering-toasting-babbage ]─[ a91c770 ... ]──────┐ +│ │ +│ 16:57:14 a39dd48 ◀ TASK: Clean up backend root config files │ +│ 16:57:16 a39dd48 💬 I'll start by reading all four files... │ +│ 16:57:17 a39dd48 📖 Read ~/shopped-monorepo/.../package.json │ +│ 16:57:17 a39dd48 ↩ result: .../package.json │ +│ 16:57:18 a39dd48 ✏️ Edit ~/shopped-monorepo/.../package.json │ +│ 16:57:48 a39dd48 📝 Write ~/shopped-monorepo/.../.env │ +│ 16:58:21 a39dd48 🔧 SendMessage │ +│ 16:58:24 a39dd48 💬 All four backend root config files done... │ +│ │ +│ 16:56:39 a91c770 ◀ TASK: Rename @claude-agent/shared → @shopped... │ +│ 16:56:42 a91c770 💬 I'll work through this systematically... │ +│ 16:56:43 a91c770 📖 Read ~/shopped-monorepo/.../package.json │ +│ ... │ +├────────────────────────────────────────────────────────────────────────┤ +│ 2 agents │ 298 events │ Tab: switch │ f: follow │ q: quit │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +## Input + +**Mouse** (enabled via `tea.WithMouseCellMotion()`): +- Scroll wheel up/down to scroll the event stream +- Scrolling up automatically pauses follow mode +- Click a tab to switch to it + +**Keyboard:** + +| Key | Action | +|-----------|-------------------------------| +| 1-9 | Switch to agent tab N | +| 0 / a | Switch to "All" tab | +| ←/→ | Prev/next tab | +| j/k ↑/↓ | Scroll up/down | +| G / End | Jump to bottom + follow | +| g / Home | Jump to top | +| f | Toggle follow mode | +| p | Toggle showing progress events| +| t | Toggle showing token usage | +| q / Ctrl+C| Quit | + +## Event Types Rendered + +| Type | Icon | Detail shown | +|-----------------|------|-------------------------------------| +| User task msg | ◀ | Summary from teammate-message | +| Assistant text | 💬 | Truncated message text | +| Read | 📖 | File path | +| Write | 📝 | File path | +| Edit | ✏️ | File path | +| Grep | 🔍 | Pattern | +| Glob | 📂 | Pattern | +| Bash | ⚡ | Command (truncated) | +| Task | 🤖 | Description | +| SendMessage | 📨 | Recipient + summary | +| Other tool | 🔧 | Tool name | +| Tool result | ↩ | File path or size (dimmed) | +| Progress | (hidden by default, toggle with p) | + +## Dependencies + +- github.com/charmbracelet/bubbletea +- github.com/charmbracelet/lipgloss +- github.com/charmbracelet/bubbles (viewport) +- github.com/fsnotify/fsnotify + +## Key Design Decisions + +1. **Watcher as tea.Cmd**: File watcher runs in a goroutine, sends `EventMsg` via a channel that gets picked up by a `tea.Cmd` subscriber. New files detected via fsnotify trigger new tailers. + +2. **Viewport for scrolling**: Use `bubbles/viewport` for the event stream. Auto-scroll when at bottom (follow mode). Pause auto-scroll when user scrolls up. + +3. **Agent discovery**: Each agent gets assigned a color from a palette on first seen. Agent list is built dynamically from events. + +4. **Home dir shortening**: Paths starting with $HOME are displayed as `~/...` for readability. + +5. **Token usage**: Shown as dimmed line under assistant events (toggleable with `t`). + +## Usage + +```bash +agent-tui /path/to/subagents/ + +# Or from the subagents directory: +agent-tui . +``` diff --git a/event.go b/event.go new file mode 100644 index 0000000..9be7282 --- /dev/null +++ b/event.go @@ -0,0 +1,242 @@ +package main + +import ( + "encoding/json" + "os" + "regexp" + "strings" + "time" +) + +// Event represents a single JSONL event from a Claude Code subagent. +type Event struct { + Type string `json:"type"` // "user", "assistant", "progress" + AgentID string `json:"agentId"` + Slug string `json:"slug"` + SessionID string `json:"sessionId"` + Timestamp string `json:"timestamp"` + Message json.RawMessage `json:"message"` + Data json.RawMessage `json:"data"` + + // Parsed fields (not from JSON directly) + parsed parsedEvent +} + +type parsedEvent struct { + time time.Time + lines []renderedLine + agentIndex int +} + +// renderedLine is a single display line produced from an event. +type renderedLine struct { + text string +} + +// messageEnvelope is the shape of .message in user/assistant events. +type messageEnvelope struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Model string `json:"model"` + Usage *usageInfo `json:"usage"` +} + +type usageInfo struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` +} + +// contentBlock is one item in the assistant message content array. +type contentBlock struct { + Type string `json:"type"` // "text", "tool_use" + Text string `json:"text"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +// toolResultBlock is one item in a user tool_result array. +type toolResultBlock struct { + Type string `json:"type"` // "tool_result" + ToolUseID string `json:"tool_use_id"` + IsError bool `json:"is_error"` +} + +// progressData is the shape of .data for progress events. +type progressData struct { + Type string `json:"type"` + HookEvent string `json:"hookEvent"` + HookName string `json:"hookName"` +} + +var homeDir string +var teammateRe = regexp.MustCompile(`summary="([^"]*)"`) + +func init() { + homeDir, _ = os.UserHomeDir() +} + +func shortenPath(p string) string { + if homeDir != "" && strings.HasPrefix(p, homeDir) { + return "~" + p[len(homeDir):] + } + return p +} + +func parseTimestamp(ts string) time.Time { + t, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { + t, err = time.Parse("2006-01-02T15:04:05.000Z", ts) + if err != nil { + return time.Time{} + } + } + return t.Local() +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.TrimSpace(s) + // Count runes + runes := []rune(s) + if len(runes) > n { + return string(runes[:n]) + "..." + } + return s +} + +// wrapText wraps a string to fit within maxWidth, returning multiple lines. +// Continuation lines are indented by the given prefix string. +func wrapText(s string, maxWidth int, contPrefix string) []string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.TrimSpace(s) + if maxWidth <= 0 { + return []string{s} + } + + runes := []rune(s) + if len(runes) <= maxWidth { + return []string{s} + } + + var lines []string + for len(runes) > 0 { + w := maxWidth + if len(lines) > 0 { + // continuation lines are shorter because of the indent prefix + w = maxWidth + } + if w <= 0 { + w = 1 + } + if len(runes) <= w { + lines = append(lines, string(runes)) + break + } + + // Try to break at a space within the last 20% of the line + breakAt := w + searchFrom := w - w/5 + if searchFrom < 1 { + searchFrom = 1 + } + for i := w; i >= searchFrom; i-- { + if runes[i] == ' ' { + breakAt = i + break + } + } + + lines = append(lines, string(runes[:breakAt])) + runes = runes[breakAt:] + // Skip leading space on next line + for len(runes) > 0 && runes[0] == ' ' { + runes = runes[1:] + } + } + return lines +} + +// parseEvent extracts structured info from a raw Event for rendering. +func parseEvent(e *Event) { + e.parsed.time = parseTimestamp(e.Timestamp) +} + +// getToolDetail extracts a short description from a tool_use input. +func getToolDetail(toolName string, input json.RawMessage) string { + var m map[string]json.RawMessage + if err := json.Unmarshal(input, &m); err != nil { + return "" + } + + getString := func(key string) string { + raw, ok := m[key] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "" + } + return s + } + + switch toolName { + case "Read", "Write", "Edit": + return shortenPath(getString("file_path")) + case "Grep": + p := getString("pattern") + if p != "" { + return "/" + p + "/" + } + case "Glob": + return getString("pattern") + case "Bash": + cmd := getString("command") + return "$ " + truncate(cmd, 60) + case "Task": + return getString("description") + case "SendMessage": + recipient := getString("recipient") + msgType := getString("type") + if recipient != "" { + return msgType + " -> " + recipient + } + return msgType + } + return "" +} + +// extractTeammateSummary pulls the summary attribute from a tag. +func extractTeammateSummary(content string) string { + matches := teammateRe.FindStringSubmatch(content) + if len(matches) > 1 { + return matches[1] + } + return "teammate message" +} + +// toolIcon returns a terminal icon for a given tool name. +func toolIcon(name string) string { + switch name { + case "Read": + return "▸" + case "Write": + return "◂" + case "Edit": + return "△" + case "Grep": + return "◇" + case "Glob": + return "◈" + case "Bash": + return "$" + case "Task": + return "⊞" + case "SendMessage": + return "→" + default: + return "•" + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..83dbbc1 --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module github.com/cgrdavies/agent-tui + +go 1.25.6 + +require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cb1f7c0 --- /dev/null +++ b/go.sum @@ -0,0 +1,82 @@ +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..570fa45 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..afe4981 --- /dev/null +++ b/model.go @@ -0,0 +1,570 @@ +package main + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// agentInfo tracks metadata about a discovered agent. +type agentInfo struct { + id string + slug string + name string // team agent name (from config or initial message) + colorIdx int + lastSeen time.Time +} + +type model struct { + // Directory being watched + dir string + + // File watcher + watcher *Watcher + + // Session tracking + sessions []SessionInfo // all sessions, sorted most recent first + activeSession string // currently displayed session ID + sessionIdx int // index into sessions slice + + // All events received, in order (unfiltered) + events []Event + + // Agent tracking (for current session only) + agents []agentInfo // sorted by most recent activity + agentIndex map[string]int // agentID -> index into agents + agentLines map[string][]string // agentID -> rendered lines for that agent + agentNames map[string]string // agentID -> resolved name (from team config) + + // Stable color assignment (doesn't change when tabs reorder) + agentColorMap map[string]int // agentID -> color index + nextColor int + + // All rendered lines (for "All" tab, current session) + allLines []string + + // Viewport for scrollable content + vp viewport.Model + + // UI state + activeTabID string // agent ID of selected tab ("" = All tab) + follow bool // auto-scroll to bottom + showTokens bool + width int + height int + ready bool +} + +func newModel(dir string, sessions []SessionInfo, activeSession string) model { + sessionIdx := 0 + for i, s := range sessions { + if s.ID == activeSession { + sessionIdx = i + break + } + } + + // Try to load agent names from team config files + agentNames := loadTeamAgentNames() + + return model{ + dir: dir, + sessions: sessions, + activeSession: activeSession, + sessionIdx: sessionIdx, + agentIndex: make(map[string]int), + agentLines: make(map[string][]string), + agentNames: agentNames, + agentColorMap: make(map[string]int), + follow: true, + showTokens: false, + } +} + +// waitForEvent returns a tea.Cmd that waits for the next event from the watcher. +func (m model) waitForEvent() tea.Cmd { + return func() tea.Msg { + evt, ok := <-m.watcher.Events() + if !ok { + return nil + } + return evt + } +} + +func (m model) Init() tea.Cmd { + // Watcher is already started by main.go before tea.NewProgram. + // Just start listening for events. + return m.waitForEvent() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + if m.watcher != nil { + m.watcher.Stop() + } + return m, tea.Quit + + case "f": + m.follow = !m.follow + if m.follow { + m.vp.GotoBottom() + } + + case "t": + m.showTokens = !m.showTokens + m.rebuildAllLines() + m.syncViewport() + + case "s": + // Next session + if len(m.sessions) > 1 { + m.sessionIdx = (m.sessionIdx + 1) % len(m.sessions) + m.switchSession(m.sessions[m.sessionIdx].ID) + } + + case "S": + // Previous session + if len(m.sessions) > 1 { + m.sessionIdx = (m.sessionIdx - 1 + len(m.sessions)) % len(m.sessions) + m.switchSession(m.sessions[m.sessionIdx].ID) + } + + case "left", "h": + tabIdx := m.activeTabIndex() + if tabIdx > 0 { + tabIdx-- + if tabIdx == 0 { + m.activeTabID = "" + } else { + m.activeTabID = m.agents[tabIdx-1].id + } + m.syncViewport() + } + + case "right", "l": + tabIdx := m.activeTabIndex() + if tabIdx < len(m.agents) { + tabIdx++ + m.activeTabID = m.agents[tabIdx-1].id + m.syncViewport() + } + + case "0", "a": + m.activeTabID = "" + m.syncViewport() + + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + n := int(msg.String()[0] - '0') + if n <= len(m.agents) { + m.activeTabID = m.agents[n-1].id + m.syncViewport() + } + + case "G", "end": + m.follow = true + m.vp.GotoBottom() + + case "g", "home": + m.follow = false + m.vp.GotoTop() + } + + case tea.MouseMsg: + // Let viewport handle mouse scrolling + wasAtBottom := m.vp.AtBottom() + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + cmds = append(cmds, cmd) + + // If user scrolled up, disable follow + if wasAtBottom && !m.vp.AtBottom() { + m.follow = false + } + // If user scrolled to bottom, enable follow + if m.vp.AtBottom() { + m.follow = true + } + return m, tea.Batch(cmds...) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + headerHeight := 3 // session bar + tab bar + divider + footerHeight := 1 // status bar + vpHeight := m.height - headerHeight - footerHeight + + if !m.ready { + m.vp = viewport.New(m.width, vpHeight) + m.vp.MouseWheelEnabled = true + m.vp.MouseWheelDelta = 3 + m.ready = true + } else { + m.vp.Width = m.width + m.vp.Height = vpHeight + } + m.rebuildAllLines() + m.syncViewport() + + case EventMsg: + m.handleNewEvent(msg.Event) + cmds = append(cmds, m.waitForEvent()) + + case WatcherErrorMsg: + // Could display error, for now just ignore + } + + // Pass remaining messages to viewport (keyboard scrolling) + if m.ready { + wasAtBottom := m.vp.AtBottom() + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + cmds = append(cmds, cmd) + + if wasAtBottom && !m.vp.AtBottom() { + m.follow = false + } + } + + return m, tea.Batch(cmds...) +} + +// switchSession changes the active session and rebuilds the view. +func (m *model) switchSession(sessionID string) { + m.activeSession = sessionID + m.activeTabID = "" + + // Reset agent tracking + m.agents = nil + m.agentIndex = make(map[string]int) + m.agentLines = make(map[string][]string) + m.agentColorMap = make(map[string]int) + m.nextColor = 0 + m.allLines = nil + + // Re-register agents and rebuild lines from stored events + for i := range m.events { + evt := &m.events[i] + if evt.SessionID != m.activeSession { + continue + } + m.registerAgent(evt) + m.updateAgentActivity(evt.AgentID, evt.parsed.time) + } + + m.rebuildAllLines() + m.follow = true + m.syncViewport() +} + +func (m *model) registerAgent(evt *Event) { + if evt.AgentID == "" { + return + } + if _, ok := m.agentIndex[evt.AgentID]; !ok { + // Assign a stable color + if _, hasColor := m.agentColorMap[evt.AgentID]; !hasColor { + m.agentColorMap[evt.AgentID] = m.nextColor + m.nextColor++ + } + + // Resolve name: team config > "You are X" in initial message > slug + name := "" + if n, ok := m.agentNames[evt.AgentID]; ok { + name = n + } + if name == "" { + name = resolveAgentName(evt.AgentID, m.agentNames, m.events) + } + + m.agents = append(m.agents, agentInfo{ + id: evt.AgentID, + slug: evt.Slug, + name: name, + colorIdx: m.agentColorMap[evt.AgentID], + lastSeen: evt.parsed.time, + }) + m.rebuildAgentIndex() + } +} + +// updateAgentActivity updates lastSeen and re-sorts tabs by most recent activity. +func (m *model) updateAgentActivity(agentID string, t time.Time) { + idx, ok := m.agentIndex[agentID] + if !ok { + return + } + if t.After(m.agents[idx].lastSeen) { + m.agents[idx].lastSeen = t + } + m.sortAgents() +} + +// sortAgents sorts agents by lastSeen descending (most recent first). +func (m *model) sortAgents() { + sort.SliceStable(m.agents, func(i, j int) bool { + return m.agents[i].lastSeen.After(m.agents[j].lastSeen) + }) + m.rebuildAgentIndex() +} + +// rebuildAgentIndex rebuilds the agentID -> slice index map after sorting. +func (m *model) rebuildAgentIndex() { + m.agentIndex = make(map[string]int, len(m.agents)) + for i, a := range m.agents { + m.agentIndex[a.id] = i + } +} + +// activeTabIndex returns the current tab index (0 = All, 1+ = agent position). +func (m *model) activeTabIndex() int { + if m.activeTabID == "" { + return 0 + } + if idx, ok := m.agentIndex[m.activeTabID]; ok { + return idx + 1 + } + return 0 +} + +func (m *model) handleNewEvent(evt Event) { + m.events = append(m.events, evt) + + // Register agent if it belongs to active session + if evt.SessionID == m.activeSession { + m.registerAgent(&evt) + m.updateAgentActivity(evt.AgentID, evt.parsed.time) + + // Render this event + colorIdx := 0 + if idx, ok := m.agentIndex[evt.AgentID]; ok { + colorIdx = m.agents[idx].colorIdx + } + + lines := renderEvent(&evt, colorIdx, m.showTokens, m.width) + + // Add to per-agent and all lines + for _, line := range lines { + m.allLines = append(m.allLines, line) + if evt.AgentID != "" { + m.agentLines[evt.AgentID] = append(m.agentLines[evt.AgentID], line) + } + } + + m.syncViewport() + } + + // Update session metadata if this is a new session we haven't seen + knownSession := false + for _, s := range m.sessions { + if s.ID == evt.SessionID { + knownSession = true + break + } + } + if !knownSession && evt.SessionID != "" { + m.sessions = append([]SessionInfo{{ + ID: evt.SessionID, + AgentCount: 1, + EventCount: 1, + FirstSeen: evt.parsed.time, + LastSeen: evt.parsed.time, + Agents: map[string]string{evt.AgentID: evt.Slug}, + }}, m.sessions...) + // Adjust sessionIdx since we prepended + m.sessionIdx++ + } +} + +// rebuildAllLines re-renders all events for the current session. +func (m *model) rebuildAllLines() { + m.allLines = nil + m.agentLines = make(map[string][]string) + + for i := range m.events { + evt := &m.events[i] + if evt.SessionID != m.activeSession { + continue + } + colorIdx := 0 + if idx, ok := m.agentIndex[evt.AgentID]; ok { + colorIdx = m.agents[idx].colorIdx + } + lines := renderEvent(evt, colorIdx, m.showTokens, m.width) + for _, line := range lines { + m.allLines = append(m.allLines, line) + if evt.AgentID != "" { + m.agentLines[evt.AgentID] = append(m.agentLines[evt.AgentID], line) + } + } + } +} + +// syncViewport updates the viewport content from the current active tab. +func (m *model) syncViewport() { + if !m.ready { + return + } + + var lines []string + if m.activeTabID == "" { + lines = m.allLines + } else { + lines = m.agentLines[m.activeTabID] + } + + content := strings.Join(lines, "\n") + m.vp.SetContent(content) + + if m.follow { + m.vp.GotoBottom() + } +} + +func (m model) View() string { + if !m.ready { + return "Loading..." + } + + // Session bar + sessionBar := m.renderSessionBar() + + // Tab bar + tabs := m.renderTabs() + + // Viewport + body := m.vp.View() + + // Status bar + status := m.renderStatusBar() + + return lipgloss.JoinVertical(lipgloss.Left, sessionBar, tabs, body, status) +} + +func (m model) renderSessionBar() string { + cur := m.sessions[m.sessionIdx] + + label := fmt.Sprintf(" session %s (%d agents, %s – %s)", + shortSessionID(cur.ID), + cur.AgentCount, + cur.FirstSeen.Format("15:04:05"), + cur.LastSeen.Format("15:04:05"), + ) + + nav := "" + if len(m.sessions) > 1 { + nav = fmt.Sprintf(" %d/%d ", m.sessionIdx+1, len(m.sessions)) + } + + labelWidth := lipgloss.Width(label) + navWidth := lipgloss.Width(nav) + gap := m.width - labelWidth - navWidth + if gap < 0 { + gap = 0 + } + + return sessionBarStyle.Width(m.width).Render( + label + strings.Repeat(" ", gap) + nav) +} + +func (m model) renderTabs() string { + var tabs []string + + // "All" tab + if m.activeTabID == "" { + tabs = append(tabs, activeTabStyle.Render("All")) + } else { + tabs = append(tabs, inactiveTabStyle.Render("All")) + } + + // Agent tabs (sorted by most recent activity) + for _, agent := range m.agents { + label := m.agentLabel(agent) + + isActive := m.activeTabID == agent.id + if isActive { + style := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#282C34")). + Background(agentColors[agent.colorIdx%len(agentColors)]). + Padding(0, 1) + tabs = append(tabs, style.Render(label)) + } else { + style := lipgloss.NewStyle(). + Foreground(agentColors[agent.colorIdx%len(agentColors)]). + Padding(0, 1) + tabs = append(tabs, style.Render(label)) + } + } + + tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + + // Divider + divider := dimStyle.Render(strings.Repeat("─", m.width)) + + return tabBar + "\n" + divider +} + +// agentLabel returns the display label for an agent tab. +func (m model) agentLabel(agent agentInfo) string { + shortID := agent.id[:min(7, len(agent.id))] + if agent.name != "" { + return agent.name + } + if agent.slug != "" { + parts := strings.SplitN(agent.slug, "-", 4) + if len(parts) >= 3 { + return shortID + " " + parts[0] + "-" + parts[1] + } + } + return shortID +} + +func (m model) renderStatusBar() string { + var followStr string + if m.follow { + followStr = followOnStyle.Render("● FOLLOW") + } else { + followStr = followOffStyle.Render("○ PAUSED") + } + + var tokensStr string + if m.showTokens { + tokensStr = "tok:on" + } else { + tokensStr = "tok:off" + } + + eventCount := len(m.allLines) + agentCount := len(m.agents) + + info := fmt.Sprintf(" %d agents │ %d lines │ %s │ %s", + agentCount, eventCount, followStr, tokensStr) + + keys := statusKeyStyle.Render("←→") + " tabs " + + statusKeyStyle.Render("s/S") + " session " + + statusKeyStyle.Render("f") + " follow " + + statusKeyStyle.Render("t") + " tokens " + + statusKeyStyle.Render("q") + " quit " + + infoWidth := lipgloss.Width(info) + keysWidth := lipgloss.Width(keys) + gap := m.width - infoWidth - keysWidth + if gap < 0 { + gap = 0 + } + + return statusBarStyle.Width(m.width).Render( + info + strings.Repeat(" ", gap) + keys) +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..e6a5f80 --- /dev/null +++ b/render.go @@ -0,0 +1,177 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/glamour" +) + +// renderMarkdown renders markdown text to styled terminal output at the given width. +func renderMarkdown(text string, width int) string { + if width < 20 { + width = 20 + } + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dracula"), + glamour.WithWordWrap(width), + ) + if err != nil { + return text + } + out, err := r.Render(text) + if err != nil { + return text + } + // Glamour adds leading/trailing newlines — trim them + return strings.Trim(out, "\n") +} + +// renderEvent converts an Event into display lines for the viewport. +func renderEvent(e *Event, agentColorIdx int, showTokens bool, width int) []string { + timeStr := dimStyle.Render(e.parsed.time.Format("15:04:05")) + agentTag := agentStyle(agentColorIdx).Render(e.AgentID[:min(7, len(e.AgentID))]) + + // Max width for detail text (account for timestamp + agent + icon + padding) + detailWidth := width - 30 + if detailWidth < 40 { + detailWidth = 40 + } + + switch e.Type { + case "user": + return renderUserEvent(e, timeStr, agentTag, agentColorIdx, detailWidth) + case "assistant": + return renderAssistantEvent(e, timeStr, agentTag, agentColorIdx, showTokens, detailWidth) + case "progress": + // Hidden by default — could add toggle later + return nil + } + return nil +} + +func renderUserEvent(e *Event, timeStr, agentTag string, colorIdx int, detailWidth int) []string { + var msg messageEnvelope + if err := json.Unmarshal(e.Message, &msg); err != nil { + return nil + } + + // Continuation line prefix: spaces to align under the detail text + contPrefix := " " + strings.Repeat(" ", 8+2+7+2+8) + " " + + // Try as string first (teammate message or user text) + var contentStr string + if err := json.Unmarshal(msg.Content, &contentStr); err == nil { + if strings.Contains(contentStr, " 0 { + lines = append(lines, header+mdLines[0]) + } + for _, ml := range mdLines[1:] { + lines = append(lines, contPrefix+ml) + } + + case "tool_use": + icon := toolIcon(b.Name) + detail := getToolDetail(b.Name, b.Input) + detailStr := "" + var contLines []string + if detail != "" { + toolDetailWidth := detailWidth - len(b.Name) - 5 + wrapped := wrapText(detail, toolDetailWidth, contPrefix) + detailStr = " " + dimStyle.Render(wrapped[0]) + for _, cont := range wrapped[1:] { + contLines = append(contLines, contPrefix+dimStyle.Render(cont)) + } + } + lines = append(lines, fmt.Sprintf(" %s %s %s%s", + timeStr, agentTag, + toolStyle.Render(icon+" "+b.Name), + detailStr)) + lines = append(lines, contLines...) + } + } + + // Token usage + if showTokens && msg.Usage != nil && msg.Usage.OutputTokens > 0 { + parts := []string{} + if msg.Usage.OutputTokens > 0 { + parts = append(parts, fmt.Sprintf("out:%d", msg.Usage.OutputTokens)) + } + if msg.Usage.CacheReadInputTokens > 0 { + parts = append(parts, fmt.Sprintf("cache_r:%d", msg.Usage.CacheReadInputTokens)) + } + if msg.Usage.CacheCreationInputTokens > 0 { + parts = append(parts, fmt.Sprintf("cache_w:%d", msg.Usage.CacheCreationInputTokens)) + } + if len(parts) > 0 { + lines = append(lines, fmt.Sprintf(" %s %s %s", + dimStyle.Render(" "), + agentStyle(colorIdx).Render(e.AgentID[:min(7, len(e.AgentID))]), + dimStyle.Render("tokens: "+strings.Join(parts, " | ")))) + } + } + + return lines +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..e800c62 --- /dev/null +++ b/session.go @@ -0,0 +1,110 @@ +package main + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "sort" + "time" +) + +// SessionInfo holds metadata about a discovered session. +type SessionInfo struct { + ID string + AgentCount int + EventCount int + FirstSeen time.Time + LastSeen time.Time + Agents map[string]string // agentID -> slug +} + +// DiscoverSessions scans all JSONL files in a directory and groups them by sessionId. +// Returns sessions sorted by LastSeen descending (most recent first). +func DiscoverSessions(dir string) ([]SessionInfo, error) { + matches, err := filepath.Glob(filepath.Join(dir, "*.jsonl")) + if err != nil { + return nil, err + } + + sessions := make(map[string]*SessionInfo) + + for _, path := range matches { + scanFileForSessions(path, sessions) + } + + // Convert to slice and sort by LastSeen descending + result := make([]SessionInfo, 0, len(sessions)) + for _, s := range sessions { + s.AgentCount = len(s.Agents) + result = append(result, *s) + } + sort.Slice(result, func(i, j int) bool { + return result[i].LastSeen.After(result[j].LastSeen) + }) + + return result, nil +} + +// scanFileForSessions reads a JSONL file and registers sessions found. +// It reads the first and last few lines for speed on large files. +func scanFileForSessions(path string, sessions map[string]*SessionInfo) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 512*1024), 512*1024) + + for scanner.Scan() { + var evt struct { + SessionID string `json:"sessionId"` + AgentID string `json:"agentId"` + Slug string `json:"slug"` + Timestamp string `json:"timestamp"` + } + if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil { + continue + } + if evt.SessionID == "" { + continue + } + + s, ok := sessions[evt.SessionID] + if !ok { + s = &SessionInfo{ + ID: evt.SessionID, + Agents: make(map[string]string), + } + sessions[evt.SessionID] = s + } + + s.EventCount++ + + ts := parseTimestamp(evt.Timestamp) + if !ts.IsZero() { + if s.FirstSeen.IsZero() || ts.Before(s.FirstSeen) { + s.FirstSeen = ts + } + if ts.After(s.LastSeen) { + s.LastSeen = ts + } + } + + if evt.AgentID != "" { + if _, exists := s.Agents[evt.AgentID]; !exists { + s.Agents[evt.AgentID] = evt.Slug + } + } + } +} + +// shortSessionID returns the first 8 chars of a session UUID. +func shortSessionID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..1bfe254 --- /dev/null +++ b/styles.go @@ -0,0 +1,76 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +// Agent color palette — each agent gets assigned the next color. +var agentColors = []lipgloss.Color{ + lipgloss.Color("#61AFEF"), // blue + lipgloss.Color("#98C379"), // green + lipgloss.Color("#E5C07B"), // yellow + lipgloss.Color("#C678DD"), // magenta + lipgloss.Color("#56B6C2"), // cyan + lipgloss.Color("#E06C75"), // red + lipgloss.Color("#D19A66"), // orange + lipgloss.Color("#ABB2BF"), // gray +} + +var ( + // Dim text (timestamps, results, secondary info) + dimStyle = lipgloss.NewStyle().Faint(true) + + // Bold text + boldStyle = lipgloss.NewStyle().Bold(true) + + // Tab styles + activeTabStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#282C34")). + Background(lipgloss.Color("#61AFEF")). + Padding(0, 1) + + inactiveTabStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ABB2BF")). + Padding(0, 1) + + // Session bar + sessionBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E5C07B")). + Background(lipgloss.Color("#2C323C")). + Padding(0, 0) + + // Status bar + statusBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ABB2BF")). + Background(lipgloss.Color("#3E4451")). + Padding(0, 1) + + statusKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E5C07B")). + Background(lipgloss.Color("#3E4451")) + + // Tool use prefix (yellow) + toolStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) + + // Assistant text prefix (green) + textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#98C379")) + + // User/task prefix (blue) + taskStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#61AFEF")) + + // Error style + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) + + // Follow mode indicator + followOnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#98C379")).Bold(true) + followOffStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) +) + +func agentStyle(colorIdx int) lipgloss.Style { + c := agentColors[colorIdx%len(agentColors)] + return lipgloss.NewStyle().Foreground(c).Bold(true) +} + +func agentDimStyle(colorIdx int) lipgloss.Style { + c := agentColors[colorIdx%len(agentColors)] + return lipgloss.NewStyle().Foreground(c).Faint(true) +} diff --git a/teams.go b/teams.go new file mode 100644 index 0000000..ea973d5 --- /dev/null +++ b/teams.go @@ -0,0 +1,117 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// teamConfig is the shape of ~/.claude/teams//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 " 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 " or "You are Agent " + 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 "" +} diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..cff7159 --- /dev/null +++ b/watcher.go @@ -0,0 +1,334 @@ +package main + +import ( + "bufio" + "encoding/json" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/fsnotify/fsnotify" +) + +// EventMsg is sent to the Bubble Tea model when a new event is parsed. +type EventMsg struct { + Event Event + File string +} + +// InitialEventsMsg is sent once with all tail events at startup. +type InitialEventsMsg struct { + Events []Event +} + +// NewFileMsg is sent when a new JSONL file is discovered. +type NewFileMsg struct { + File string +} + +// WatcherErrorMsg is sent when the watcher encounters an error. +type WatcherErrorMsg struct { + Err error +} + +// Default number of lines to read from the end of each file on startup. +const defaultTailLines = 200 + +// Watcher watches a directory for JSONL files and tails them. +type Watcher struct { + dir string + eventCh chan EventMsg + done chan struct{} + + // Initial batch of events from tail, sent once + initialEvents []Event + initialDone chan struct{} + + mu sync.Mutex + tailing map[string]struct{} + fsw *fsnotify.Watcher +} + +// NewWatcher creates a new directory watcher. +func NewWatcher(dir string) *Watcher { + return &Watcher{ + dir: dir, + eventCh: make(chan EventMsg, 256), + done: make(chan struct{}), + initialDone: make(chan struct{}), + tailing: make(map[string]struct{}), + } +} + +// Start begins watching and tailing. +func (w *Watcher) Start() error { + var err error + w.fsw, err = fsnotify.NewWatcher() + if err != nil { + return err + } + + if err := w.fsw.Add(w.dir); err != nil { + w.fsw.Close() + return err + } + + // Read tail of existing files synchronously to build initial batch + matches, _ := filepath.Glob(filepath.Join(w.dir, "*.jsonl")) + var allInitial []Event + fileOffsets := make(map[string]int64) // track where we left off + + for _, path := range matches { + events, endOffset := readTail(path, defaultTailLines) + allInitial = append(allInitial, events...) + fileOffsets[path] = endOffset + } + + // Sort by timestamp + sort.Slice(allInitial, func(i, j int) bool { + return allInitial[i].parsed.time.Before(allInitial[j].parsed.time) + }) + w.initialEvents = allInitial + + // Now start tailing from where we left off (for new events only) + for _, path := range matches { + w.mu.Lock() + w.tailing[path] = struct{}{} + w.mu.Unlock() + go w.tailFileFrom(path, fileOffsets[path]) + } + + close(w.initialDone) + + // Watch for new files + go w.watchLoop() + + return nil +} + +// InitialEvents returns the batch of events read from tails at startup. +// Only valid after Start() returns. +func (w *Watcher) InitialEvents() []Event { + return w.initialEvents +} + +// readTail reads the last n lines from a JSONL file and returns parsed events +// plus the byte offset at EOF (for continuing to tail from). +func readTail(path string, n int) ([]Event, int64) { + f, err := os.Open(path) + if err != nil { + return nil, 0 + } + defer f.Close() + + offset := findTailOffset(f, n) + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return nil, 0 + } + + reader := bufio.NewReader(f) + + // If we seeked to mid-file, skip the first partial line + if offset > 0 { + _, _ = reader.ReadString('\n') + } + + var events []Event + for { + line, err := reader.ReadString('\n') + if err != nil { + break + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + var evt Event + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + parseEvent(&evt) + events = append(events, evt) + } + + // Get current file position (EOF) + endOffset, err := f.Seek(0, io.SeekEnd) + if err != nil { + endOffset = 0 + } + + return events, endOffset +} + +func (w *Watcher) watchLoop() { + defer w.fsw.Close() + + for { + select { + case <-w.done: + return + case event, ok := <-w.fsw.Events: + if !ok { + return + } + if event.Op&(fsnotify.Create|fsnotify.Write) != 0 { + if strings.HasSuffix(event.Name, ".jsonl") { + w.startTail(event.Name) + } + } + case _, ok := <-w.fsw.Errors: + if !ok { + return + } + } + } +} + +func (w *Watcher) startTail(path string) { + w.mu.Lock() + if _, ok := w.tailing[path]; ok { + w.mu.Unlock() + return + } + w.tailing[path] = struct{}{} + w.mu.Unlock() + + // New file discovered after startup — tail from beginning + go w.tailFileFrom(path, 0) +} + +// tailFileFrom tails a file starting from the given byte offset. +func (w *Watcher) tailFileFrom(path string, startOffset int64) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + + if startOffset > 0 { + if _, err := f.Seek(startOffset, io.SeekStart); err != nil { + return + } + } + + basename := filepath.Base(path) + reader := bufio.NewReader(f) + + for { + select { + case <-w.done: + return + default: + } + + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + w.waitForChange(path, f, reader) + continue + } + return + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var evt Event + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + parseEvent(&evt) + + select { + case w.eventCh <- EventMsg{Event: evt, File: basename}: + case <-w.done: + return + } + } +} + +// findTailOffset returns a byte offset to start reading from to get +// approximately the last n lines of the file. +func findTailOffset(f *os.File, n int) int64 { + info, err := f.Stat() + if err != nil { + return 0 + } + size := info.Size() + if size == 0 { + return 0 + } + + bufSize := int64(8192) + newlines := 0 + pos := size + + buf := make([]byte, bufSize) + for pos > 0 { + readSize := bufSize + if pos < readSize { + readSize = pos + } + pos -= readSize + + nRead, err := f.ReadAt(buf[:readSize], pos) + if err != nil && err != io.EOF { + return 0 + } + + for i := nRead - 1; i >= 0; i-- { + if buf[i] == '\n' { + newlines++ + if newlines > n { + return pos + int64(i) + 1 + } + } + } + } + + return 0 +} + +// waitForChange blocks until the file has new data. +func (w *Watcher) waitForChange(path string, f *os.File, reader *bufio.Reader) { + fw, err := fsnotify.NewWatcher() + if err != nil { + return + } + defer fw.Close() + + if err := fw.Add(path); err != nil { + return + } + + for { + select { + case <-w.done: + return + case evt, ok := <-fw.Events: + if !ok { + return + } + if evt.Op&fsnotify.Write != 0 { + return + } + case <-fw.Errors: + return + } + } +} + +// Stop stops the watcher. +func (w *Watcher) Stop() { + close(w.done) +} + +// Events returns the channel that emits parsed events. +func (w *Watcher) Events() <-chan EventMsg { + return w.eventCh +}