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

111 lines
2.4 KiB
Go

package main
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
)
// SessionInfo holds metadata about a discovered session.
type SessionInfo struct {
ID string
AgentCount int
EventCount int
FirstSeen time.Time
LastSeen time.Time
Agents map[string]string // agentID -> slug
}
// DiscoverSessions scans all JSONL files in a directory and groups them by sessionId.
// Returns sessions sorted by LastSeen descending (most recent first).
func DiscoverSessions(dir string) ([]SessionInfo, error) {
matches, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
if err != nil {
return nil, err
}
sessions := make(map[string]*SessionInfo)
for _, path := range matches {
scanFileForSessions(path, sessions)
}
// Convert to slice and sort by LastSeen descending
result := make([]SessionInfo, 0, len(sessions))
for _, s := range sessions {
s.AgentCount = len(s.Agents)
result = append(result, *s)
}
sort.Slice(result, func(i, j int) bool {
return result[i].LastSeen.After(result[j].LastSeen)
})
return result, nil
}
// scanFileForSessions reads a JSONL file and registers sessions found.
// It reads the first and last few lines for speed on large files.
func scanFileForSessions(path string, sessions map[string]*SessionInfo) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 512*1024), 512*1024)
for scanner.Scan() {
var evt struct {
SessionID string `json:"sessionId"`
AgentID string `json:"agentId"`
Slug string `json:"slug"`
Timestamp string `json:"timestamp"`
}
if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil {
continue
}
if evt.SessionID == "" {
continue
}
s, ok := sessions[evt.SessionID]
if !ok {
s = &SessionInfo{
ID: evt.SessionID,
Agents: make(map[string]string),
}
sessions[evt.SessionID] = s
}
s.EventCount++
ts := parseTimestamp(evt.Timestamp)
if !ts.IsZero() {
if s.FirstSeen.IsZero() || ts.Before(s.FirstSeen) {
s.FirstSeen = ts
}
if ts.After(s.LastSeen) {
s.LastSeen = ts
}
}
if evt.AgentID != "" {
if _, exists := s.Agents[evt.AgentID]; !exists {
s.Agents[evt.AgentID] = evt.Slug
}
}
}
}
// shortSessionID returns the first 8 chars of a session UUID.
func shortSessionID(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}