Files
gastown/internal/tui/convoy/model.go
gastown/crew/jack 7d6ea09efe feat(convoy): Add numbered shortcuts and interactive TUI (gt-fo0qa)
- Add numbered prefixes to `gt convoy list` output (1. 2. 3. ...)
- Support numeric shortcuts in `gt convoy status <n>`
- Add `-i/--interactive` flag for expandable tree view TUI
- New internal/tui/convoy package with bubbletea-based UI
  - j/k navigation, enter to expand/collapse
  - 1-9 to jump directly to convoy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:58:56 -08:00

330 lines
7.0 KiB
Go

package convoy
import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
// IssueItem represents a tracked issue within a convoy.
type IssueItem struct {
ID string
Title string
Status string
}
// ConvoyItem represents a convoy with its tracked issues.
type ConvoyItem struct {
ID string
Title string
Status string
Issues []IssueItem
Progress string // e.g., "2/5"
Expanded bool
}
// Model is the bubbletea model for the convoy TUI.
type Model struct {
convoys []ConvoyItem
cursor int // Current selection index in flattened view
townBeads string // Path to town beads directory
err error
// UI state
keys KeyMap
help help.Model
showHelp bool
width int
height int
}
// New creates a new convoy TUI model.
func New(townBeads string) Model {
return Model{
townBeads: townBeads,
keys: DefaultKeyMap(),
help: help.New(),
convoys: make([]ConvoyItem, 0),
}
}
// Init initializes the model.
func (m Model) Init() tea.Cmd {
return m.fetchConvoys
}
// fetchConvoysMsg is the result of fetching convoys.
type fetchConvoysMsg struct {
convoys []ConvoyItem
err error
}
// fetchConvoys fetches convoy data from beads.
func (m Model) fetchConvoys() tea.Msg {
convoys, err := loadConvoys(m.townBeads)
return fetchConvoysMsg{convoys: convoys, err: err}
}
// loadConvoys loads convoy data from the beads directory.
func loadConvoys(townBeads string) ([]ConvoyItem, error) {
// Get list of open convoys
listArgs := []string{"list", "--type=convoy", "--json"}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return nil, fmt.Errorf("listing convoys: %w", err)
}
var rawConvoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
}
if err := json.Unmarshal(stdout.Bytes(), &rawConvoys); err != nil {
return nil, fmt.Errorf("parsing convoy list: %w", err)
}
convoys := make([]ConvoyItem, 0, len(rawConvoys))
for _, rc := range rawConvoys {
issues, completed, total := loadTrackedIssues(townBeads, rc.ID)
convoys = append(convoys, ConvoyItem{
ID: rc.ID,
Title: rc.Title,
Status: rc.Status,
Issues: issues,
Progress: fmt.Sprintf("%d/%d", completed, total),
Expanded: false,
})
}
return convoys, nil
}
// loadTrackedIssues loads issues tracked by a convoy.
func loadTrackedIssues(townBeads, convoyID string) ([]IssueItem, int, int) {
dbPath := filepath.Join(townBeads, "beads.db")
// Query tracked issues from SQLite
query := fmt.Sprintf(`
SELECT d.depends_on_id
FROM dependencies d
WHERE d.issue_id = '%s' AND d.dependency_type = 'tracks'
`, convoyID)
cmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, 0, 0
}
var deps []struct {
DependsOnID string `json:"depends_on_id"`
}
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
return nil, 0, 0
}
issues := make([]IssueItem, 0, len(deps))
completed := 0
for _, dep := range deps {
issueID := dep.DependsOnID
// Handle external references
if strings.HasPrefix(issueID, "external:") {
parts := strings.SplitN(issueID, ":", 3)
if len(parts) == 3 {
issueID = parts[2]
}
}
// Get issue details
issue := getIssueDetails(townBeads, issueID)
if issue != nil {
issues = append(issues, *issue)
if issue.Status == "closed" {
completed++
}
}
}
// Sort by status (open first, then closed)
sort.Slice(issues, func(i, j int) bool {
if issues[i].Status == issues[j].Status {
return issues[i].ID < issues[j].ID
}
return issues[i].Status != "closed" // open comes first
})
return issues, completed, len(issues)
}
// getIssueDetails fetches details for a single issue.
func getIssueDetails(townBeads, issueID string) *IssueItem {
cmd := exec.Command("bd", "show", issueID, "--json")
cmd.Dir = townBeads
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil || len(issues) == 0 {
return nil
}
return &IssueItem{
ID: issues[0].ID,
Title: issues[0].Title,
Status: issues[0].Status,
}
}
// Update handles messages.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.Width = msg.Width
return m, nil
case fetchConvoysMsg:
m.err = msg.err
m.convoys = msg.convoys
return m, nil
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.showHelp = !m.showHelp
return m, nil
case key.Matches(msg, m.keys.Up):
if m.cursor > 0 {
m.cursor--
}
return m, nil
case key.Matches(msg, m.keys.Down):
max := m.maxCursor()
if m.cursor < max {
m.cursor++
}
return m, nil
case key.Matches(msg, m.keys.Top):
m.cursor = 0
return m, nil
case key.Matches(msg, m.keys.Bottom):
m.cursor = m.maxCursor()
return m, nil
case key.Matches(msg, m.keys.Toggle):
m.toggleExpand()
return m, nil
// Number keys for direct convoy access
case msg.String() >= "1" && msg.String() <= "9":
n := int(msg.String()[0] - '0')
if n <= len(m.convoys) {
m.jumpToConvoy(n - 1)
}
return m, nil
}
}
return m, nil
}
// maxCursor returns the maximum valid cursor position.
func (m Model) maxCursor() int {
count := 0
for _, c := range m.convoys {
count++ // convoy itself
if c.Expanded {
count += len(c.Issues)
}
}
if count == 0 {
return 0
}
return count - 1
}
// cursorToConvoyIndex returns the convoy index and issue index for the current cursor.
// Returns (convoyIdx, issueIdx) where issueIdx is -1 if on a convoy row.
func (m Model) cursorToConvoyIndex() (int, int) {
pos := 0
for ci, c := range m.convoys {
if pos == m.cursor {
return ci, -1
}
pos++
if c.Expanded {
for ii := range c.Issues {
if pos == m.cursor {
return ci, ii
}
pos++
}
}
}
return -1, -1
}
// toggleExpand toggles expansion of the convoy at the current cursor.
func (m *Model) toggleExpand() {
ci, ii := m.cursorToConvoyIndex()
if ci >= 0 && ii == -1 {
// On a convoy row, toggle it
m.convoys[ci].Expanded = !m.convoys[ci].Expanded
}
}
// jumpToConvoy moves the cursor to a specific convoy by index.
func (m *Model) jumpToConvoy(convoyIdx int) {
if convoyIdx < 0 || convoyIdx >= len(m.convoys) {
return
}
pos := 0
for ci, c := range m.convoys {
if ci == convoyIdx {
m.cursor = pos
return
}
pos++
if c.Expanded {
pos += len(c.Issues)
}
}
}
// View renders the model.
func (m Model) View() string {
return m.renderView()
}