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:
333
internal/tui/feed/view.go
Normal file
333
internal/tui/feed/view.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user