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
243 lines
5.4 KiB
Go
243 lines
5.4 KiB
Go
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 "•"
|
|
}
|
|
}
|