Add convoy dashboard panel to gt feed TUI

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 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/gus
2025-12-30 23:17:41 -08:00
committed by Steve Yegge
parent 59ffb3cc58
commit 31c4a222bc
5 changed files with 469 additions and 38 deletions

View File

@@ -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())

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

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

View File

@@ -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},
}

View File

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

View File

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