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