Files
gastown/internal/tui/feed/view.go

357 lines
8.4 KiB
Go

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)
// Convoy panel (middle)
convoyPanel := m.renderConvoyPanel()
sections = append(sections, convoyPanel)
// 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 != "" && indent >= 2 {
prefix = strings.Repeat(" ", indent-2) + icon + " "
} else if icon != "" {
prefix = 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 - compact HH:MM format, no brackets
ts := TimestampStyle.Render(e.Time.Format("15:04"))
// 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", "patrol_complete", "merged", "done":
symbolStyle = EventCompleteStyle
case "fail", "merge_failed":
symbolStyle = EventFailStyle
case "delete":
symbolStyle = EventDeleteStyle
case "merge_started":
symbolStyle = EventMergeStartedStyle
case "merge_skipped":
symbolStyle = EventMergeSkippedStyle
case "patrol_started", "polecat_checked":
symbolStyle = EventUpdateStyle
case "polecat_nudged", "escalation_sent", "nudge":
symbolStyle = EventFailStyle // Use red/warning style for nudges and escalations
case "sling", "hook", "spawn", "boot":
symbolStyle = EventCreateStyle
case "handoff", "mail":
symbolStyle = EventUpdateStyle
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
var panelName string
switch m.focusedPanel {
case PanelTree:
panelName = "tree"
case PanelConvoy:
panelName = "convoy"
case 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 "just now"
}
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))
}