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:
committed by
Steve Yegge
parent
59ffb3cc58
commit
31c4a222bc
@@ -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
342
internal/tui/feed/convoy.go
Normal 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))
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user