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
178 lines
4.9 KiB
Go
178 lines
4.9 KiB
Go
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
|
|
}
|