Initial commit: agent-tui

Bubble Tea TUI for tailing Claude Code subagent JSONL logs.

Features:
- Tabbed interface with one tab per agent, sorted by most recent activity
- Markdown rendering via glamour (Dracula theme)
- Session discovery and filtering with -s flag
- Auto-discovers subagents dir from session ID prefix
- Live tailing with follow mode, mouse scroll support
- Agent name resolution from team config files
- Tail-from-bottom: only reads last 200 lines per file on startup
This commit is contained in:
Chris Davies
2026-02-13 13:48:30 -05:00
commit 927ecb83fe
12 changed files with 2016 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
agent-tui
*.skill

106
PLAN.md Normal file
View File

@@ -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 .
```

242
event.go Normal file
View File

@@ -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 <teammate-message> 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 "•"
}
}

42
go.mod Normal file
View File

@@ -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
)

82
go.sum Normal file
View File

@@ -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=

158
main.go Normal file
View File

@@ -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)
}
}

570
model.go Normal file
View File

@@ -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)
}

177
render.go Normal file
View File

@@ -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, "<teammate-message") {
summary := extractTeammateSummary(contentStr)
wrapped := wrapText(summary, detailWidth, contPrefix)
lines := []string{
fmt.Sprintf(" %s %s %s %s",
timeStr, agentTag,
taskStyle.Render("← TASK:"),
wrapped[0]),
}
for _, cont := range wrapped[1:] {
lines = append(lines, contPrefix+cont)
}
return lines
}
wrapped := wrapText(contentStr, detailWidth, contPrefix)
lines := []string{
fmt.Sprintf(" %s %s %s %s",
timeStr, agentTag,
taskStyle.Render("← user:"),
wrapped[0]),
}
for _, cont := range wrapped[1:] {
lines = append(lines, contPrefix+cont)
}
return lines
}
// Tool results — skip, they're noise
return nil
}
func renderAssistantEvent(e *Event, timeStr, agentTag string, colorIdx int, showTokens bool, detailWidth int) []string {
var msg messageEnvelope
if err := json.Unmarshal(e.Message, &msg); err != nil {
return nil
}
var blocks []contentBlock
if err := json.Unmarshal(msg.Content, &blocks); err != nil {
return nil
}
// Continuation line prefix: spaces to align under the detail text
contPrefix := " " + strings.Repeat(" ", 8+2+7+2+4) + " "
var lines []string
for _, b := range blocks {
switch b.Type {
case "text":
text := strings.TrimSpace(b.Text)
if text == "" {
continue
}
// Render markdown with glamour
rendered := renderMarkdown(text, detailWidth)
mdLines := strings.Split(rendered, "\n")
// First line gets the header (timestamp + agent + icon)
header := fmt.Sprintf(" %s %s %s ",
timeStr, agentTag,
textStyle.Render("▪"))
if len(mdLines) > 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
}

110
session.go Normal file
View File

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

76
styles.go Normal file
View File

@@ -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)
}

117
teams.go Normal file
View File

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

334
watcher.go Normal file
View File

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