Files
agent-tui/render.go
Chris Davies 927ecb83fe 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
2026-02-13 13:48:30 -05:00

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
}