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)) }