From 31c4a222bc4d5808f51a1bb61f311dc14670b218 Mon Sep 17 00:00:00 2001 From: gastown/crew/gus Date: Tue, 30 Dec 2025 23:17:41 -0800 Subject: [PATCH] Add convoy dashboard panel to gt feed TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third panel to the feed TUI showing: - In-progress convoys with progress bars (completed/total) - Recently landed convoys (last 24h) with time since landing Features: - Panel cycles with tab: tree -> convoy -> feed - Direct access via 1/2/3 number keys - Auto-refresh every 10 seconds - Styled progress indicators (●●○○) The convoy panel bridges the gap between "WHO is working" (agent tree) and "WHAT is happening" (event feed) by showing "WHAT IS SHIPPING". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/feed.go | 7 +- internal/tui/feed/convoy.go | 342 ++++++++++++++++++++++++++++++++++++ internal/tui/feed/keys.go | 19 +- internal/tui/feed/model.go | 126 ++++++++++--- internal/tui/feed/view.go | 13 +- 5 files changed, 469 insertions(+), 38 deletions(-) create mode 100644 internal/tui/feed/convoy.go diff --git a/internal/cmd/feed.go b/internal/cmd/feed.go index 1008438f..33fb2f97 100644 --- a/internal/cmd/feed.go +++ b/internal/cmd/feed.go @@ -49,12 +49,14 @@ var feedCmd = &cobra.Command{ By default, launches an interactive TUI dashboard with: - Agent tree (top): Shows all agents organized by role with latest activity + - Convoy panel (middle): Shows in-progress and recently landed convoys - Event stream (bottom): Chronological feed you can scroll through - - Vim-style navigation: j/k to scroll, tab to switch panels, q to quit + - Vim-style navigation: j/k to scroll, tab to switch panels, 1/2/3 for panels, q to quit -The feed combines two event sources: +The feed combines multiple event sources: - Beads activity: Issue creates, updates, completions (from bd activity) - GT events: Agent activity like patrol, sling, handoff (from .events.jsonl) + - Convoy status: In-progress and recently-landed convoys (refreshes every 10s) Use --plain for simple text output (wraps bd activity only). @@ -229,6 +231,7 @@ func runFeedTUI(workDir string) error { // Create model and connect event source m := feed.NewModel() m.SetEventChannel(multiSource.Events()) + m.SetTownRoot(townRoot) // Run the TUI p := tea.NewProgram(m, tea.WithAltScreen()) diff --git a/internal/tui/feed/convoy.go b/internal/tui/feed/convoy.go new file mode 100644 index 00000000..94f499b5 --- /dev/null +++ b/internal/tui/feed/convoy.go @@ -0,0 +1,342 @@ +package feed + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// Convoy represents a convoy's status for the dashboard +type Convoy struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Completed int `json:"completed"` + Total int `json:"total"` + CreatedAt time.Time `json:"created_at"` + ClosedAt time.Time `json:"closed_at,omitempty"` +} + +// ConvoyState holds all convoy data for the panel +type ConvoyState struct { + InProgress []Convoy + Landed []Convoy + LastUpdate time.Time +} + +// FetchConvoys retrieves convoy status from town-level beads +func FetchConvoys(townRoot string) (*ConvoyState, error) { + townBeads := filepath.Join(townRoot, ".beads") + + state := &ConvoyState{ + InProgress: make([]Convoy, 0), + Landed: make([]Convoy, 0), + LastUpdate: time.Now(), + } + + // Fetch open convoys + openConvoys, err := listConvoys(townBeads, "open") + if err != nil { + // Not a fatal error - just return empty state + return state, nil + } + + for _, c := range openConvoys { + // Get detailed status for each convoy + convoy := enrichConvoy(townBeads, c) + state.InProgress = append(state.InProgress, convoy) + } + + // Fetch recently closed convoys (landed in last 24h) + closedConvoys, err := listConvoys(townBeads, "closed") + if err == nil { + cutoff := time.Now().Add(-24 * time.Hour) + for _, c := range closedConvoys { + convoy := enrichConvoy(townBeads, c) + if !convoy.ClosedAt.IsZero() && convoy.ClosedAt.After(cutoff) { + state.Landed = append(state.Landed, convoy) + } + } + } + + // Sort: in-progress by created (oldest first), landed by closed (newest first) + sort.Slice(state.InProgress, func(i, j int) bool { + return state.InProgress[i].CreatedAt.Before(state.InProgress[j].CreatedAt) + }) + sort.Slice(state.Landed, func(i, j int) bool { + return state.Landed[i].ClosedAt.After(state.Landed[j].ClosedAt) + }) + + return state, nil +} + +// listConvoys returns convoys with the given status +func listConvoys(beadsDir, status string) ([]convoyListItem, error) { + listArgs := []string{"list", "--type=convoy", "--status=" + status, "--json"} + + cmd := exec.Command("bd", listArgs...) + cmd.Dir = beadsDir + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return nil, err + } + + var items []convoyListItem + if err := json.Unmarshal(stdout.Bytes(), &items); err != nil { + return nil, err + } + + return items, nil +} + +type convoyListItem struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + ClosedAt string `json:"closed_at,omitempty"` +} + +// enrichConvoy adds tracked issue counts to a convoy +func enrichConvoy(beadsDir string, item convoyListItem) Convoy { + convoy := Convoy{ + ID: item.ID, + Title: item.Title, + Status: item.Status, + } + + // Parse timestamps + if t, err := time.Parse(time.RFC3339, item.CreatedAt); err == nil { + convoy.CreatedAt = t + } else if t, err := time.Parse("2006-01-02 15:04", item.CreatedAt); err == nil { + convoy.CreatedAt = t + } + if t, err := time.Parse(time.RFC3339, item.ClosedAt); err == nil { + convoy.ClosedAt = t + } else if t, err := time.Parse("2006-01-02 15:04", item.ClosedAt); err == nil { + convoy.ClosedAt = t + } + + // Get tracked issues and their status + tracked := getTrackedIssueStatus(beadsDir, item.ID) + convoy.Total = len(tracked) + for _, t := range tracked { + if t.Status == "closed" { + convoy.Completed++ + } + } + + return convoy +} + +type trackedStatus struct { + ID string + Status string +} + +// getTrackedIssueStatus queries tracked issues and their status +func getTrackedIssueStatus(beadsDir, convoyID string) []trackedStatus { + dbPath := filepath.Join(beadsDir, "beads.db") + + // Query tracked dependencies from SQLite + cmd := exec.Command("sqlite3", "-json", dbPath, + fmt.Sprintf(`SELECT depends_on_id FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, convoyID)) + + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil + } + + var deps []struct { + DependsOnID string `json:"depends_on_id"` + } + if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil { + return nil + } + + var tracked []trackedStatus + for _, dep := range deps { + issueID := dep.DependsOnID + + // Handle external reference format: external:rig:issue-id + if strings.HasPrefix(issueID, "external:") { + parts := strings.SplitN(issueID, ":", 3) + if len(parts) == 3 { + issueID = parts[2] + } + } + + // Get issue status + status := getIssueStatus(issueID) + tracked = append(tracked, trackedStatus{ID: issueID, Status: status}) + } + + return tracked +} + +// getIssueStatus fetches just the status of an issue +func getIssueStatus(issueID string) string { + cmd := exec.Command("bd", "show", issueID, "--json") + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return "unknown" + } + + var issues []struct { + Status string `json:"status"` + } + if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil || len(issues) == 0 { + return "unknown" + } + + return issues[0].Status +} + +// Convoy panel styles +var ( + ConvoyPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorDim). + Padding(0, 1) + + ConvoyTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorPrimary) + + ConvoySectionStyle = lipgloss.NewStyle(). + Foreground(colorDim). + Bold(true) + + ConvoyIDStyle = lipgloss.NewStyle(). + Foreground(colorHighlight) + + ConvoyNameStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("15")) + + ConvoyProgressStyle = lipgloss.NewStyle(). + Foreground(colorSuccess) + + ConvoyLandedStyle = lipgloss.NewStyle(). + Foreground(colorSuccess). + Bold(true) + + ConvoyAgeStyle = lipgloss.NewStyle(). + Foreground(colorDim) +) + +// renderConvoyPanel renders the convoy status panel +func (m *Model) renderConvoyPanel() string { + style := ConvoyPanelStyle + if m.focusedPanel == PanelConvoy { + style = FocusedBorderStyle + } + // Add title before content + title := ConvoyTitleStyle.Render("🚚 Convoys") + content := title + "\n" + m.convoyViewport.View() + return style.Width(m.width - 2).Render(content) +} + +// renderConvoys renders the convoy panel content +func (m *Model) renderConvoys() string { + if m.convoyState == nil { + return AgentIdleStyle.Render("Loading convoys...") + } + + var lines []string + + // In Progress section + lines = append(lines, ConvoySectionStyle.Render("IN PROGRESS")) + if len(m.convoyState.InProgress) == 0 { + lines = append(lines, " "+AgentIdleStyle.Render("No active convoys")) + } else { + for _, c := range m.convoyState.InProgress { + lines = append(lines, renderConvoyLine(c, false)) + } + } + + lines = append(lines, "") + + // Recently Landed section + lines = append(lines, ConvoySectionStyle.Render("RECENTLY LANDED (24h)")) + if len(m.convoyState.Landed) == 0 { + lines = append(lines, " "+AgentIdleStyle.Render("No recent landings")) + } else { + for _, c := range m.convoyState.Landed { + lines = append(lines, renderConvoyLine(c, true)) + } + } + + return strings.Join(lines, "\n") +} + +// renderConvoyLine renders a single convoy status line +func renderConvoyLine(c Convoy, landed bool) string { + // Format: " hq-xyz Title 2/4 ●●○○" or " hq-xyz Title ✓ 2h ago" + id := ConvoyIDStyle.Render(c.ID) + + // Truncate title if too long + title := c.Title + if len(title) > 20 { + title = title[:17] + "..." + } + title = ConvoyNameStyle.Render(title) + + if landed { + // Show checkmark and time since landing + age := formatConvoyAge(time.Since(c.ClosedAt)) + status := ConvoyLandedStyle.Render("✓") + " " + ConvoyAgeStyle.Render(age+" ago") + return fmt.Sprintf(" %s %-20s %s", id, title, status) + } + + // Show progress bar + progress := renderProgressBar(c.Completed, c.Total) + count := ConvoyProgressStyle.Render(fmt.Sprintf("%d/%d", c.Completed, c.Total)) + return fmt.Sprintf(" %s %-20s %s %s", id, title, count, progress) +} + +// renderProgressBar creates a simple progress bar: ●●○○ +func renderProgressBar(completed, total int) string { + if total == 0 { + return "" + } + + // Cap at 5 dots for display + displayTotal := total + if displayTotal > 5 { + displayTotal = 5 + } + + filled := (completed * displayTotal) / total + if filled > displayTotal { + filled = displayTotal + } + + bar := strings.Repeat("●", filled) + strings.Repeat("○", displayTotal-filled) + return ConvoyProgressStyle.Render(bar) +} + +// formatConvoyAge formats duration for convoy display +func formatConvoyAge(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)) +} diff --git a/internal/tui/feed/keys.go b/internal/tui/feed/keys.go index cecb759c..37867a6e 100644 --- a/internal/tui/feed/keys.go +++ b/internal/tui/feed/keys.go @@ -13,10 +13,11 @@ type KeyMap struct { Bottom key.Binding // Panel switching - Tab key.Binding - ShiftTab key.Binding - FocusTree key.Binding - FocusFeed key.Binding + Tab key.Binding + ShiftTab key.Binding + FocusTree key.Binding + FocusConvoy key.Binding + FocusFeed key.Binding // Actions Enter key.Binding @@ -72,9 +73,13 @@ func DefaultKeyMap() KeyMap { key.WithKeys("1"), key.WithHelp("1", "agent tree"), ), - FocusFeed: key.NewBinding( + FocusConvoy: key.NewBinding( key.WithKeys("2"), - key.WithHelp("2", "event feed"), + key.WithHelp("2", "convoys"), + ), + FocusFeed: key.NewBinding( + key.WithKeys("3"), + key.WithHelp("3", "event feed"), ), Enter: key.NewBinding( key.WithKeys("enter"), @@ -120,7 +125,7 @@ func (k KeyMap) ShortHelp() []key.Binding { 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.Tab, k.FocusTree, k.FocusConvoy, k.FocusFeed, k.Enter, k.Expand}, {k.Search, k.Filter, k.ClearFilter, k.Refresh}, {k.Help, k.Quit}, } diff --git a/internal/tui/feed/model.go b/internal/tui/feed/model.go index d4d491cb..26ae107d 100644 --- a/internal/tui/feed/model.go +++ b/internal/tui/feed/model.go @@ -16,6 +16,7 @@ type Panel int const ( PanelTree Panel = iota + PanelConvoy PanelFeed ) @@ -57,13 +58,16 @@ type Model struct { height int // Panels - focusedPanel Panel - treeViewport viewport.Model - feedViewport viewport.Model + focusedPanel Panel + treeViewport viewport.Model + convoyViewport viewport.Model + feedViewport viewport.Model // Data - rigs map[string]*Rig - events []Event + rigs map[string]*Rig + events []Event + convoyState *ConvoyState + townRoot string // UI state keys KeyMap @@ -83,21 +87,28 @@ func NewModel() *Model { 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{}), + focusedPanel: PanelTree, + treeViewport: viewport.New(0, 0), + convoyViewport: 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{}), } } +// SetTownRoot sets the town root for convoy fetching +func (m *Model) SetTownRoot(townRoot string) { + m.townRoot = townRoot +} + // Init initializes the model func (m *Model) Init() tea.Cmd { return tea.Batch( m.listenForEvents(), + m.fetchConvoys(), tea.SetWindowTitle("GT Feed"), ) } @@ -105,6 +116,11 @@ func (m *Model) Init() tea.Cmd { // eventMsg is sent when a new event arrives type eventMsg Event +// convoyUpdateMsg is sent when convoy data is refreshed +type convoyUpdateMsg struct { + state *ConvoyState +} + // tickMsg is sent periodically to refresh the view type tickMsg time.Time @@ -136,6 +152,25 @@ func tick() tea.Cmd { }) } +// fetchConvoys returns a command that fetches convoy data +func (m *Model) fetchConvoys() tea.Cmd { + if m.townRoot == "" { + return nil + } + townRoot := m.townRoot + return func() tea.Msg { + state, _ := FetchConvoys(townRoot) + return convoyUpdateMsg{state: state} + } +} + +// convoyRefreshTick returns a command that schedules the next convoy refresh +func (m *Model) convoyRefreshTick() tea.Cmd { + return tea.Tick(10*time.Second, func(t time.Time) tea.Msg { + return convoyUpdateMsg{} // Empty state triggers a refresh + }) +} + // Update handles messages func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -153,15 +188,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.addEvent(Event(msg)) cmds = append(cmds, m.listenForEvents()) + case convoyUpdateMsg: + if msg.state != nil { + m.convoyState = msg.state + m.updateViewContent() + } + // Schedule next refresh + cmds = append(cmds, m.fetchConvoys(), m.convoyRefreshTick()) + case tickMsg: cmds = append(cmds, tick()) } // Update viewports var cmd tea.Cmd - if m.focusedPanel == PanelTree { + switch m.focusedPanel { + case PanelTree: m.treeViewport, cmd = m.treeViewport.Update(msg) - } else { + case PanelConvoy: + m.convoyViewport, cmd = m.convoyViewport.Update(msg) + case PanelFeed: m.feedViewport, cmd = m.feedViewport.Update(msg) } cmds = append(cmds, cmd) @@ -182,9 +228,13 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keys.Tab): - if m.focusedPanel == PanelTree { + // Cycle: Tree -> Convoy -> Feed -> Tree + switch m.focusedPanel { + case PanelTree: + m.focusedPanel = PanelConvoy + case PanelConvoy: m.focusedPanel = PanelFeed - } else { + case PanelFeed: m.focusedPanel = PanelTree } return m, nil @@ -197,6 +247,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focusedPanel = PanelFeed return m, nil + case key.Matches(msg, m.keys.FocusConvoy): + m.focusedPanel = PanelConvoy + return m, nil + case key.Matches(msg, m.keys.Refresh): m.updateViewContent() return m, nil @@ -204,9 +258,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Pass to focused viewport var cmd tea.Cmd - if m.focusedPanel == PanelTree { + switch m.focusedPanel { + case PanelTree: m.treeViewport, cmd = m.treeViewport.Update(msg) - } else { + case PanelConvoy: + m.convoyViewport, cmd = m.convoyViewport.Update(msg) + case PanelFeed: m.feedViewport, cmd = m.feedViewport.Update(msg) } return m, cmd @@ -214,23 +271,35 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // updateViewportSizes recalculates viewport dimensions func (m *Model) updateViewportSizes() { - // Reserve space: header (1) + borders (4) + status bar (1) + help (1-2) + // Reserve space: header (1) + borders (6 for 3 panels) + 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 + borderHeight := 6 // top and bottom borders for 3 panels availableHeight := m.height - headerHeight - statusHeight - helpHeight - borderHeight - if availableHeight < 4 { - availableHeight = 4 + if availableHeight < 6 { + availableHeight = 6 } - // Split 40% tree, 60% feed - treeHeight := availableHeight * 40 / 100 - feedHeight := availableHeight - treeHeight + // Split: 30% tree, 25% convoy, 45% feed + treeHeight := availableHeight * 30 / 100 + convoyHeight := availableHeight * 25 / 100 + feedHeight := availableHeight - treeHeight - convoyHeight + + // Ensure minimum heights + if treeHeight < 3 { + treeHeight = 3 + } + if convoyHeight < 3 { + convoyHeight = 3 + } + if feedHeight < 3 { + feedHeight = 3 + } contentWidth := m.width - 4 // borders and padding if contentWidth < 20 { @@ -239,15 +308,18 @@ func (m *Model) updateViewportSizes() { m.treeViewport.Width = contentWidth m.treeViewport.Height = treeHeight + m.convoyViewport.Width = contentWidth + m.convoyViewport.Height = convoyHeight m.feedViewport.Width = contentWidth m.feedViewport.Height = feedHeight m.updateViewContent() } -// updateViewContent refreshes the content of both viewports +// updateViewContent refreshes the content of all viewports func (m *Model) updateViewContent() { m.treeViewport.SetContent(m.renderTree()) + m.convoyViewport.SetContent(m.renderConvoys()) m.feedViewport.SetContent(m.renderFeed()) } diff --git a/internal/tui/feed/view.go b/internal/tui/feed/view.go index dfe59a13..c99be43a 100644 --- a/internal/tui/feed/view.go +++ b/internal/tui/feed/view.go @@ -24,6 +24,10 @@ func (m *Model) render() string { 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) @@ -298,8 +302,13 @@ func (m *Model) renderEvent(e Event) string { // renderStatusBar renders the bottom status bar func (m *Model) renderStatusBar() string { // Panel indicator - panelName := "tree" - if m.focusedPanel == PanelFeed { + var panelName string + switch m.focusedPanel { + case PanelTree: + panelName = "tree" + case PanelConvoy: + panelName = "convoy" + case PanelFeed: panelName = "feed" } panel := fmt.Sprintf("[%s]", panelName)