Files
agent-tui/event.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

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 "•"
}
}