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