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:
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 "•"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user