From 927ecb83fe7f82d4b5f2930b26232054bd8902ca Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Fri, 13 Feb 2026 13:48:30 -0500 Subject: [PATCH] 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 --- .gitignore | 2 + PLAN.md | 106 ++++++++++ event.go | 242 +++++++++++++++++++++++ go.mod | 42 ++++ go.sum | 82 ++++++++ main.go | 158 +++++++++++++++ model.go | 570 +++++++++++++++++++++++++++++++++++++++++++++++++++++ render.go | 177 +++++++++++++++++ session.go | 110 +++++++++++ styles.go | 76 +++++++ teams.go | 117 +++++++++++ watcher.go | 334 +++++++++++++++++++++++++++++++ 12 files changed, 2016 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 event.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 model.go create mode 100644 render.go create mode 100644 session.go create mode 100644 styles.go create mode 100644 teams.go create mode 100644 watcher.go 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 +}