feat: Add charmbracelet TUI for gt feed (gt-u7dxq)

- Add bubbletea and bubbles dependencies
- Create internal/tui/feed package with:
  - model.go: Main bubbletea model with agent tree and event stream
  - view.go: Rendering logic with lipgloss styling
  - keys.go: Vim-style key bindings (j/k, tab, /, q)
  - styles.go: Color palette and component styles
  - events.go: Event source from bd activity
- Update gt feed to use TUI by default (--plain for text mode)
- TUI features: agent tree by role, event stream, keyboard nav

Closes gt-be0as, gt-lexye, gt-1uhmj

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-28 16:24:28 -08:00
parent dcd996495a
commit c7da650f94
8 changed files with 1321 additions and 27 deletions

342
internal/tui/feed/events.go Normal file
View File

@@ -0,0 +1,342 @@
package feed
import (
"bufio"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// EventSource represents a source of events
type EventSource interface {
Events() <-chan Event
Close() error
}
// BdActivitySource reads events from bd activity --follow
type BdActivitySource struct {
cmd *exec.Cmd
events chan Event
cancel context.CancelFunc
workDir string
}
// NewBdActivitySource creates a new source that tails bd activity
func NewBdActivitySource(workDir string) (*BdActivitySource, error) {
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "bd", "activity", "--follow")
cmd.Dir = workDir
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, err
}
if err := cmd.Start(); err != nil {
cancel()
return nil, err
}
source := &BdActivitySource{
cmd: cmd,
events: make(chan Event, 100),
cancel: cancel,
workDir: workDir,
}
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if event := parseBdActivityLine(line); event != nil {
select {
case source.events <- *event:
default:
// Drop event if channel full
}
}
}
close(source.events)
}()
return source, nil
}
// Events returns the event channel
func (s *BdActivitySource) Events() <-chan Event {
return s.events
}
// Close stops the source
func (s *BdActivitySource) Close() error {
s.cancel()
return s.cmd.Wait()
}
// bd activity line pattern: [HH:MM:SS] SYMBOL BEAD_ID action · description
var bdActivityPattern = regexp.MustCompile(`^\[(\d{2}:\d{2}:\d{2})\]\s+([+→✓✗⊘📌])\s+(\S+)?\s*(\w+)?\s*·?\s*(.*)$`)
// parseBdActivityLine parses a line from bd activity output
func parseBdActivityLine(line string) *Event {
matches := bdActivityPattern.FindStringSubmatch(line)
if matches == nil {
// Try simpler pattern
return parseSimpleLine(line)
}
timeStr := matches[1]
symbol := matches[2]
beadID := matches[3]
action := matches[4]
message := matches[5]
// Parse time (assume today)
now := time.Now()
t, err := time.Parse("15:04:05", timeStr)
if err != nil {
t = now
} else {
t = time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), 0, now.Location())
}
// Map symbol to event type
eventType := "update"
switch symbol {
case "+":
eventType = "create"
case "→":
eventType = "update"
case "✓":
eventType = "complete"
case "✗":
eventType = "fail"
case "⊘":
eventType = "delete"
case "📌":
eventType = "pin"
}
// Try to extract actor and rig from bead ID
actor, rig, role := parseBeadContext(beadID)
return &Event{
Time: t,
Type: eventType,
Actor: actor,
Target: beadID,
Message: strings.TrimSpace(action + " " + message),
Rig: rig,
Role: role,
Raw: line,
}
}
// parseSimpleLine handles lines that don't match the full pattern
func parseSimpleLine(line string) *Event {
if strings.TrimSpace(line) == "" {
return nil
}
// Try to extract timestamp
var t time.Time
if len(line) > 10 && line[0] == '[' {
if idx := strings.Index(line, "]"); idx > 0 {
timeStr := line[1:idx]
now := time.Now()
if parsed, err := time.Parse("15:04:05", timeStr); err == nil {
t = time.Date(now.Year(), now.Month(), now.Day(),
parsed.Hour(), parsed.Minute(), parsed.Second(), 0, now.Location())
}
}
}
if t.IsZero() {
t = time.Now()
}
return &Event{
Time: t,
Type: "update",
Message: line,
Raw: line,
}
}
// parseBeadContext extracts actor/rig/role from a bead ID
func parseBeadContext(beadID string) (actor, rig, role string) {
if beadID == "" {
return
}
// Agent beads: gt-crew-gastown-joe, gt-witness-gastown, gt-mayor
if strings.HasPrefix(beadID, "gt-crew-") {
parts := strings.Split(beadID, "-")
if len(parts) >= 4 {
rig = parts[2]
actor = strings.Join(parts[2:], "/")
role = "crew"
}
} else if strings.HasPrefix(beadID, "gt-witness-") {
parts := strings.Split(beadID, "-")
if len(parts) >= 3 {
rig = parts[2]
actor = "witness"
role = "witness"
}
} else if strings.HasPrefix(beadID, "gt-refinery-") {
parts := strings.Split(beadID, "-")
if len(parts) >= 3 {
rig = parts[2]
actor = "refinery"
role = "refinery"
}
} else if beadID == "gt-mayor" {
actor = "mayor"
role = "mayor"
} else if beadID == "gt-deacon" {
actor = "deacon"
role = "deacon"
} else if strings.HasPrefix(beadID, "gt-polecat-") {
parts := strings.Split(beadID, "-")
if len(parts) >= 3 {
rig = parts[2]
actor = strings.Join(parts[2:], "-")
role = "polecat"
}
}
return
}
// JSONLSource reads events from a JSONL file (like .events.jsonl)
type JSONLSource struct {
file *os.File
events chan Event
cancel context.CancelFunc
}
// JSONLEvent is the structure of events in .events.jsonl
type JSONLEvent struct {
Timestamp string `json:"timestamp"`
Type string `json:"type"`
Actor string `json:"actor"`
Target string `json:"target"`
Message string `json:"message"`
Rig string `json:"rig"`
Role string `json:"role"`
}
// NewJSONLSource creates a source that tails a JSONL file
func NewJSONLSource(filePath string) (*JSONLSource, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
source := &JSONLSource{
file: file,
events: make(chan Event, 100),
cancel: cancel,
}
go source.tail(ctx)
return source, nil
}
// tail follows the file and sends events
func (s *JSONLSource) tail(ctx context.Context) {
defer close(s.events)
// Seek to end for live tailing
s.file.Seek(0, 2)
scanner := bufio.NewScanner(s.file)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for scanner.Scan() {
line := scanner.Text()
if event := parseJSONLLine(line); event != nil {
select {
case s.events <- *event:
default:
}
}
}
}
}
}
// Events returns the event channel
func (s *JSONLSource) Events() <-chan Event {
return s.events
}
// Close stops the source
func (s *JSONLSource) Close() error {
s.cancel()
return s.file.Close()
}
// parseJSONLLine parses a JSONL event line
func parseJSONLLine(line string) *Event {
if strings.TrimSpace(line) == "" {
return nil
}
var je JSONLEvent
if err := json.Unmarshal([]byte(line), &je); err != nil {
return nil
}
t, err := time.Parse(time.RFC3339, je.Timestamp)
if err != nil {
t = time.Now()
}
return &Event{
Time: t,
Type: je.Type,
Actor: je.Actor,
Target: je.Target,
Message: je.Message,
Rig: je.Rig,
Role: je.Role,
Raw: line,
}
}
// FindBeadsDir finds the beads directory for the given working directory
func FindBeadsDir(workDir string) (string, error) {
// Walk up looking for .beads
dir := workDir
for {
beadsPath := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsPath); err == nil && info.IsDir() {
return beadsPath, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", os.ErrNotExist
}

127
internal/tui/feed/keys.go Normal file
View File

@@ -0,0 +1,127 @@
package feed
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines the key bindings for the feed TUI.
type KeyMap struct {
// Navigation
Up key.Binding
Down key.Binding
PageUp key.Binding
PageDown key.Binding
Top key.Binding
Bottom key.Binding
// Panel switching
Tab key.Binding
ShiftTab key.Binding
FocusTree key.Binding
FocusFeed key.Binding
// Actions
Enter key.Binding
Expand key.Binding
Refresh key.Binding
// Search/Filter
Search key.Binding
Filter key.Binding
ClearFilter key.Binding
// General
Help key.Binding
Quit key.Binding
}
// DefaultKeyMap returns the default key bindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup", "ctrl+u"),
key.WithHelp("pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("pgdown", "ctrl+d"),
key.WithHelp("pgdn", "page down"),
),
Top: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g", "top"),
),
Bottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G", "bottom"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "switch panel"),
),
ShiftTab: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("S-tab", "prev panel"),
),
FocusTree: key.NewBinding(
key.WithKeys("1"),
key.WithHelp("1", "agent tree"),
),
FocusFeed: key.NewBinding(
key.WithKeys("2"),
key.WithHelp("2", "event feed"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "expand/details"),
),
Expand: key.NewBinding(
key.WithKeys("o", "l"),
key.WithHelp("o", "toggle expand"),
),
Refresh: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "refresh"),
),
Search: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "search"),
),
Filter: key.NewBinding(
key.WithKeys("f"),
key.WithHelp("f", "filter"),
),
ClearFilter: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "clear"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
}
// ShortHelp returns key bindings for the short help view.
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.Down, k.Tab, k.Search, k.Filter, k.Quit, k.Help}
}
// FullHelp returns key bindings for the full help view.
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.PageUp, k.PageDown, k.Top, k.Bottom},
{k.Tab, k.FocusTree, k.FocusFeed, k.Enter, k.Expand},
{k.Search, k.Filter, k.ClearFilter, k.Refresh},
{k.Help, k.Quit},
}
}

298
internal/tui/feed/model.go Normal file
View File

@@ -0,0 +1,298 @@
package feed
import (
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
// Panel represents which panel has focus
type Panel int
const (
PanelTree Panel = iota
PanelFeed
)
// Event represents an activity event
type Event struct {
Time time.Time
Type string // create, update, complete, fail, delete
Actor string // who did it (e.g., "gastown/crew/joe")
Target string // what was affected (e.g., "gt-xyz")
Message string // human-readable description
Rig string // which rig
Role string // actor's role
Raw string // raw line for fallback display
}
// Agent represents an agent in the tree
type Agent struct {
ID string
Name string
Role string // mayor, witness, refinery, crew, polecat
Rig string
Status string // running, idle, working, dead
LastEvent *Event
LastUpdate time.Time
Expanded bool
}
// Rig represents a rig with its agents
type Rig struct {
Name string
Agents map[string]*Agent // keyed by role/name
Expanded bool
}
// Model is the main bubbletea model for the feed TUI
type Model struct {
// Dimensions
width int
height int
// Panels
focusedPanel Panel
treeViewport viewport.Model
feedViewport viewport.Model
// Data
rigs map[string]*Rig
events []Event
// UI state
keys KeyMap
help help.Model
showHelp bool
filter string
filterActive bool
err error
// Event source
eventChan <-chan Event
done chan struct{}
}
// NewModel creates a new feed TUI model
func NewModel() Model {
h := help.New()
h.ShowAll = false
return Model{
focusedPanel: PanelTree,
treeViewport: viewport.New(0, 0),
feedViewport: viewport.New(0, 0),
rigs: make(map[string]*Rig),
events: make([]Event, 0, 1000),
keys: DefaultKeyMap(),
help: h,
done: make(chan struct{}),
}
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.listenForEvents(),
tea.SetWindowTitle("GT Feed"),
)
}
// eventMsg is sent when a new event arrives
type eventMsg Event
// tickMsg is sent periodically to refresh the view
type tickMsg time.Time
// listenForEvents returns a command that listens for events
func (m Model) listenForEvents() tea.Cmd {
if m.eventChan == nil {
return nil
}
return func() tea.Msg {
select {
case event, ok := <-m.eventChan:
if !ok {
return nil
}
return eventMsg(event)
case <-m.done:
return nil
}
}
}
// tick returns a command for periodic refresh
func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Update handles messages
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateViewportSizes()
case eventMsg:
m.addEvent(Event(msg))
cmds = append(cmds, m.listenForEvents())
case tickMsg:
cmds = append(cmds, tick())
}
// Update viewports
var cmd tea.Cmd
if m.focusedPanel == PanelTree {
m.treeViewport, cmd = m.treeViewport.Update(msg)
} else {
m.feedViewport, cmd = m.feedViewport.Update(msg)
}
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// handleKey processes key presses
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Quit):
close(m.done)
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.showHelp = !m.showHelp
m.help.ShowAll = m.showHelp
return m, nil
case key.Matches(msg, m.keys.Tab):
if m.focusedPanel == PanelTree {
m.focusedPanel = PanelFeed
} else {
m.focusedPanel = PanelTree
}
return m, nil
case key.Matches(msg, m.keys.FocusTree):
m.focusedPanel = PanelTree
return m, nil
case key.Matches(msg, m.keys.FocusFeed):
m.focusedPanel = PanelFeed
return m, nil
case key.Matches(msg, m.keys.Refresh):
m.updateViewContent()
return m, nil
}
// Pass to focused viewport
var cmd tea.Cmd
if m.focusedPanel == PanelTree {
m.treeViewport, cmd = m.treeViewport.Update(msg)
} else {
m.feedViewport, cmd = m.feedViewport.Update(msg)
}
return m, cmd
}
// updateViewportSizes recalculates viewport dimensions
func (m *Model) updateViewportSizes() {
// Reserve space: header (1) + borders (4) + status bar (1) + help (1-2)
headerHeight := 1
statusHeight := 1
helpHeight := 1
if m.showHelp {
helpHeight = 3
}
borderHeight := 4 // top and bottom borders for both panels
availableHeight := m.height - headerHeight - statusHeight - helpHeight - borderHeight
if availableHeight < 4 {
availableHeight = 4
}
// Split 40% tree, 60% feed
treeHeight := availableHeight * 40 / 100
feedHeight := availableHeight - treeHeight
contentWidth := m.width - 4 // borders and padding
if contentWidth < 20 {
contentWidth = 20
}
m.treeViewport.Width = contentWidth
m.treeViewport.Height = treeHeight
m.feedViewport.Width = contentWidth
m.feedViewport.Height = feedHeight
m.updateViewContent()
}
// updateViewContent refreshes the content of both viewports
func (m *Model) updateViewContent() {
m.treeViewport.SetContent(m.renderTree())
m.feedViewport.SetContent(m.renderFeed())
}
// addEvent adds an event and updates the agent tree
func (m *Model) addEvent(e Event) {
m.events = append(m.events, e)
// Keep max 1000 events
if len(m.events) > 1000 {
m.events = m.events[len(m.events)-1000:]
}
// Update agent tree
if e.Rig != "" {
rig, ok := m.rigs[e.Rig]
if !ok {
rig = &Rig{
Name: e.Rig,
Agents: make(map[string]*Agent),
Expanded: true,
}
m.rigs[e.Rig] = rig
}
if e.Actor != "" {
agent, ok := rig.Agents[e.Actor]
if !ok {
agent = &Agent{
ID: e.Actor,
Name: e.Actor,
Role: e.Role,
Rig: e.Rig,
}
rig.Agents[e.Actor] = agent
}
agent.LastEvent = &e
agent.LastUpdate = e.Time
}
}
m.updateViewContent()
}
// SetEventChannel sets the channel to receive events from
func (m *Model) SetEventChannel(ch <-chan Event) {
m.eventChan = ch
}
// View renders the TUI
func (m Model) View() string {
return m.render()
}

118
internal/tui/feed/styles.go Normal file
View File

@@ -0,0 +1,118 @@
// Package feed provides a TUI for the Gas Town activity feed.
package feed
import "github.com/charmbracelet/lipgloss"
// Color palette
var (
colorPrimary = lipgloss.Color("12") // Blue
colorSuccess = lipgloss.Color("10") // Green
colorWarning = lipgloss.Color("11") // Yellow
colorError = lipgloss.Color("9") // Red
colorDim = lipgloss.Color("8") // Gray
colorHighlight = lipgloss.Color("14") // Cyan
colorAccent = lipgloss.Color("13") // Magenta
)
// Styles for the feed TUI
var (
// Header styles
HeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colorPrimary).
Padding(0, 1)
TitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("15"))
FilterStyle = lipgloss.NewStyle().
Foreground(colorDim)
// Agent tree styles
TreePanelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorDim).
Padding(0, 1)
RigStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colorPrimary)
RoleStyle = lipgloss.NewStyle().
Foreground(colorAccent)
AgentNameStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("15"))
AgentActiveStyle = lipgloss.NewStyle().
Foreground(colorSuccess)
AgentIdleStyle = lipgloss.NewStyle().
Foreground(colorDim)
// Event stream styles
StreamPanelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorDim).
Padding(0, 1)
TimestampStyle = lipgloss.NewStyle().
Foreground(colorDim)
EventCreateStyle = lipgloss.NewStyle().
Foreground(colorSuccess)
EventUpdateStyle = lipgloss.NewStyle().
Foreground(colorPrimary)
EventCompleteStyle = lipgloss.NewStyle().
Foreground(colorSuccess).
Bold(true)
EventFailStyle = lipgloss.NewStyle().
Foreground(colorError).
Bold(true)
EventDeleteStyle = lipgloss.NewStyle().
Foreground(colorWarning)
// Status bar styles
StatusBarStyle = lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(colorDim).
Padding(0, 1)
HelpKeyStyle = lipgloss.NewStyle().
Foreground(colorHighlight).
Bold(true)
HelpDescStyle = lipgloss.NewStyle().
Foreground(colorDim)
// Focus indicator
FocusedBorderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorPrimary).
Padding(0, 1)
// Role icons
RoleIcons = map[string]string{
"mayor": "🎩",
"witness": "👁",
"refinery": "🏭",
"crew": "👷",
"polecat": "😺",
"deacon": "🔔",
}
// Event symbols
EventSymbols = map[string]string{
"create": "+",
"update": "→",
"complete": "✓",
"fail": "✗",
"delete": "⊘",
"pin": "📌",
}
)

333
internal/tui/feed/view.go Normal file
View File

@@ -0,0 +1,333 @@
package feed
import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
// render produces the full TUI output
func (m Model) render() string {
if m.width == 0 || m.height == 0 {
return "Loading..."
}
var sections []string
// Header
sections = append(sections, m.renderHeader())
// Tree panel (top)
treePanel := m.renderTreePanel()
sections = append(sections, treePanel)
// Feed panel (bottom)
feedPanel := m.renderFeedPanel()
sections = append(sections, feedPanel)
// Status bar
sections = append(sections, m.renderStatusBar())
// Help (if shown)
if m.showHelp {
sections = append(sections, m.help.View(m.keys))
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
// renderHeader renders the top header bar
func (m Model) renderHeader() string {
title := TitleStyle.Render("GT Feed")
filter := ""
if m.filter != "" {
filter = FilterStyle.Render(fmt.Sprintf("Filter: %s", m.filter))
} else {
filter = FilterStyle.Render("Filter: all")
}
// Right-align filter
gap := m.width - lipgloss.Width(title) - lipgloss.Width(filter) - 4
if gap < 1 {
gap = 1
}
return HeaderStyle.Render(title + strings.Repeat(" ", gap) + filter)
}
// renderTreePanel renders the agent tree panel with border
func (m Model) renderTreePanel() string {
style := TreePanelStyle
if m.focusedPanel == PanelTree {
style = FocusedBorderStyle
}
return style.Width(m.width - 2).Render(m.treeViewport.View())
}
// renderFeedPanel renders the event feed panel with border
func (m Model) renderFeedPanel() string {
style := StreamPanelStyle
if m.focusedPanel == PanelFeed {
style = FocusedBorderStyle
}
return style.Width(m.width - 2).Render(m.feedViewport.View())
}
// renderTree renders the agent tree content
func (m Model) renderTree() string {
if len(m.rigs) == 0 {
return AgentIdleStyle.Render("No agents active")
}
var lines []string
// Sort rigs by name
rigNames := make([]string, 0, len(m.rigs))
for name := range m.rigs {
rigNames = append(rigNames, name)
}
sort.Strings(rigNames)
for _, rigName := range rigNames {
rig := m.rigs[rigName]
// Rig header
rigLine := RigStyle.Render(rigName + "/")
lines = append(lines, rigLine)
// Group agents by role
byRole := m.groupAgentsByRole(rig.Agents)
// Render each role group
roleOrder := []string{"mayor", "witness", "refinery", "deacon", "crew", "polecat"}
for _, role := range roleOrder {
agents, ok := byRole[role]
if !ok || len(agents) == 0 {
continue
}
icon := RoleIcons[role]
if icon == "" {
icon = "•"
}
// For crew and polecats, show as expandable group
if role == "crew" || role == "polecat" {
lines = append(lines, m.renderAgentGroup(icon, role, agents))
} else {
// Single agents (mayor, witness, refinery)
for _, agent := range agents {
lines = append(lines, m.renderAgent(icon, agent, 2))
}
}
}
}
return strings.Join(lines, "\n")
}
// groupAgentsByRole groups agents by their role
func (m Model) groupAgentsByRole(agents map[string]*Agent) map[string][]*Agent {
result := make(map[string][]*Agent)
for _, agent := range agents {
role := agent.Role
if role == "" {
role = "unknown"
}
result[role] = append(result[role], agent)
}
// Sort each group by name
for role := range result {
sort.Slice(result[role], func(i, j int) bool {
return result[role][i].Name < result[role][j].Name
})
}
return result
}
// renderAgentGroup renders a group of agents (crew or polecats)
func (m Model) renderAgentGroup(icon, role string, agents []*Agent) string {
var lines []string
// Group header
plural := role
if role == "polecat" {
plural = "polecats"
}
header := fmt.Sprintf(" %s %s/", icon, plural)
lines = append(lines, RoleStyle.Render(header))
// Individual agents
for _, agent := range agents {
lines = append(lines, m.renderAgent("", agent, 5))
}
return strings.Join(lines, "\n")
}
// renderAgent renders a single agent line
func (m Model) renderAgent(icon string, agent *Agent, indent int) string {
prefix := strings.Repeat(" ", indent)
if icon != "" {
prefix = strings.Repeat(" ", indent-2) + icon + " "
}
// Name with status indicator
name := agent.Name
// Extract just the short name if it's a full path
if parts := strings.Split(name, "/"); len(parts) > 0 {
name = parts[len(parts)-1]
}
nameStyle := AgentIdleStyle
statusIndicator := ""
if agent.Status == "running" || agent.Status == "working" {
nameStyle = AgentActiveStyle
statusIndicator = " →"
}
// Last activity
activity := ""
if agent.LastEvent != nil {
age := formatAge(time.Since(agent.LastEvent.Time))
msg := agent.LastEvent.Message
if len(msg) > 40 {
msg = msg[:37] + "..."
}
activity = fmt.Sprintf(" [%s] %s", age, msg)
}
line := prefix + nameStyle.Render(name+statusIndicator) + TimestampStyle.Render(activity)
return line
}
// renderFeed renders the event feed content
func (m Model) renderFeed() string {
if len(m.events) == 0 {
return AgentIdleStyle.Render("No events yet")
}
var lines []string
// Show most recent events first (reversed)
start := 0
if len(m.events) > 100 {
start = len(m.events) - 100
}
for i := len(m.events) - 1; i >= start; i-- {
event := m.events[i]
lines = append(lines, m.renderEvent(event))
}
return strings.Join(lines, "\n")
}
// renderEvent renders a single event line
func (m Model) renderEvent(e Event) string {
// Timestamp
ts := TimestampStyle.Render(fmt.Sprintf("[%s]", e.Time.Format("15:04:05")))
// Symbol based on event type
symbol := EventSymbols[e.Type]
if symbol == "" {
symbol = "•"
}
// Style based on event type
var symbolStyle lipgloss.Style
switch e.Type {
case "create":
symbolStyle = EventCreateStyle
case "update":
symbolStyle = EventUpdateStyle
case "complete":
symbolStyle = EventCompleteStyle
case "fail":
symbolStyle = EventFailStyle
case "delete":
symbolStyle = EventDeleteStyle
default:
symbolStyle = EventUpdateStyle
}
styledSymbol := symbolStyle.Render(symbol)
// Actor (short form)
actor := ""
if e.Actor != "" {
parts := strings.Split(e.Actor, "/")
if len(parts) > 0 {
actor = parts[len(parts)-1]
}
if icon := RoleIcons[e.Role]; icon != "" {
actor = icon + " " + actor
}
actor = RoleStyle.Render(actor) + ": "
}
// Message
msg := e.Message
if msg == "" && e.Raw != "" {
msg = e.Raw
}
return fmt.Sprintf("%s %s %s%s", ts, styledSymbol, actor, msg)
}
// renderStatusBar renders the bottom status bar
func (m Model) renderStatusBar() string {
// Panel indicator
panelName := "tree"
if m.focusedPanel == PanelFeed {
panelName = "feed"
}
panel := fmt.Sprintf("[%s]", panelName)
// Event count
count := fmt.Sprintf("%d events", len(m.events))
// Short help
help := m.renderShortHelp()
// Combine
left := panel + " " + count
gap := m.width - lipgloss.Width(left) - lipgloss.Width(help) - 4
if gap < 1 {
gap = 1
}
return StatusBarStyle.Width(m.width).Render(left + strings.Repeat(" ", gap) + help)
}
// renderShortHelp renders abbreviated key hints
func (m Model) renderShortHelp() string {
hints := []string{
HelpKeyStyle.Render("j/k") + HelpDescStyle.Render(":scroll"),
HelpKeyStyle.Render("tab") + HelpDescStyle.Render(":switch"),
HelpKeyStyle.Render("/") + HelpDescStyle.Render(":search"),
HelpKeyStyle.Render("q") + HelpDescStyle.Render(":quit"),
HelpKeyStyle.Render("?") + HelpDescStyle.Render(":help"),
}
return strings.Join(hints, " ")
}
// formatAge formats a duration as a short age string
func formatAge(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh", int(d.Hours()))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}