Using "greenplace" (The Green Place from Mad Max: Fury Road) as the canonical example project/rig name in documentation and help text. This provides a clearer distinction from the actual gastown repo name. Changes: - docs/*.md: Updated all example paths and commands - internal/cmd/*.go: Updated help text examples - internal/templates/: Updated example references - Tests: Updated to use greenplace in example session names Note: Import paths (github.com/steveyegge/gastown) and actual code paths referencing the gastown repo structure are unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
332 lines
9.8 KiB
Go
332 lines
9.8 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/tui/feed"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
var (
|
|
feedFollow bool
|
|
feedLimit int
|
|
feedSince string
|
|
feedMol string
|
|
feedType string
|
|
feedRig string
|
|
feedNoFollow bool
|
|
feedWindow bool
|
|
feedPlain bool
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(feedCmd)
|
|
|
|
feedCmd.Flags().BoolVarP(&feedFollow, "follow", "f", false, "Stream events in real-time (default when no other flags)")
|
|
feedCmd.Flags().BoolVar(&feedNoFollow, "no-follow", false, "Show events once and exit")
|
|
feedCmd.Flags().IntVarP(&feedLimit, "limit", "n", 100, "Maximum number of events to show")
|
|
feedCmd.Flags().StringVar(&feedSince, "since", "", "Show events since duration (e.g., 5m, 1h, 30s)")
|
|
feedCmd.Flags().StringVar(&feedMol, "mol", "", "Filter by molecule/issue ID prefix")
|
|
feedCmd.Flags().StringVar(&feedType, "type", "", "Filter by event type (create, update, delete, comment)")
|
|
feedCmd.Flags().StringVar(&feedRig, "rig", "", "Run from specific rig's beads directory")
|
|
feedCmd.Flags().BoolVarP(&feedWindow, "window", "w", false, "Open in dedicated tmux window (creates 'feed' window)")
|
|
feedCmd.Flags().BoolVar(&feedPlain, "plain", false, "Use plain text output (bd activity) instead of TUI")
|
|
}
|
|
|
|
var feedCmd = &cobra.Command{
|
|
Use: "feed",
|
|
GroupID: GroupDiag,
|
|
Short: "Show real-time activity feed from beads and gt events",
|
|
Long: `Display a real-time feed of issue changes and agent activity.
|
|
|
|
By default, launches an interactive TUI dashboard with:
|
|
- Agent tree (top): Shows all agents organized by role with latest activity
|
|
- Event stream (bottom): Chronological feed you can scroll through
|
|
- Vim-style navigation: j/k to scroll, tab to switch panels, q to quit
|
|
|
|
The feed combines two event sources:
|
|
- Beads activity: Issue creates, updates, completions (from bd activity)
|
|
- GT events: Agent activity like patrol, sling, handoff (from .events.jsonl)
|
|
|
|
Use --plain for simple text output (wraps bd activity only).
|
|
|
|
Tmux Integration:
|
|
Use --window to open the feed in a dedicated tmux window named 'feed'.
|
|
This creates a persistent window you can cycle to with C-b n/p.
|
|
|
|
Event symbols:
|
|
+ created/bonded - New issue or molecule created
|
|
→ in_progress - Work started on an issue
|
|
✓ completed - Issue closed or step completed
|
|
✗ failed - Step or issue failed
|
|
⊘ deleted - Issue removed
|
|
👁 patrol_started - Witness began patrol cycle
|
|
⚡ polecat_nudged - Worker was nudged
|
|
🎯 sling - Work was slung to worker
|
|
🤝 handoff - Session handed off
|
|
|
|
MQ (Merge Queue) event symbols:
|
|
⚙ merge_started - Refinery began processing an MR
|
|
✓ merged - MR successfully merged (green)
|
|
✗ merge_failed - Merge failed (conflict, tests, etc.) (red)
|
|
⊘ merge_skipped - MR skipped (already merged, etc.)
|
|
|
|
Examples:
|
|
gt feed # Launch TUI dashboard
|
|
gt feed --plain # Plain text output (bd activity)
|
|
gt feed --window # Open in dedicated tmux window
|
|
gt feed --since 1h # Events from last hour
|
|
gt feed --rig greenplace # Use gastown rig's beads`,
|
|
RunE: runFeed,
|
|
}
|
|
|
|
func runFeed(cmd *cobra.Command, args []string) error {
|
|
// Must be in a Gas Town workspace
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace (run from ~/gt or a rig directory)")
|
|
}
|
|
|
|
// Determine working directory
|
|
workDir, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("getting current directory: %w", err)
|
|
}
|
|
|
|
// If --rig specified, find that rig's beads directory
|
|
if feedRig != "" {
|
|
// Try common beads locations for the rig
|
|
candidates := []string{
|
|
fmt.Sprintf("%s/%s/mayor/rig", townRoot, feedRig),
|
|
fmt.Sprintf("%s/%s", townRoot, feedRig),
|
|
}
|
|
|
|
found := false
|
|
for _, candidate := range candidates {
|
|
if _, err := os.Stat(candidate + "/.beads"); err == nil {
|
|
workDir = candidate
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return fmt.Errorf("rig '%s' not found or has no .beads directory", feedRig)
|
|
}
|
|
}
|
|
|
|
// Build bd activity command (without argv[0] for buildFeedCommand)
|
|
bdArgs := buildFeedArgs()
|
|
|
|
// Handle --window mode: open in dedicated tmux window
|
|
if feedWindow {
|
|
return runFeedInWindow(workDir, bdArgs)
|
|
}
|
|
|
|
// Use TUI by default if running in a terminal and not --plain
|
|
useTUI := !feedPlain && term.IsTerminal(int(os.Stdout.Fd()))
|
|
|
|
if useTUI {
|
|
return runFeedTUI(workDir)
|
|
}
|
|
|
|
// Plain mode: exec bd activity directly
|
|
return runFeedDirect(workDir, bdArgs)
|
|
}
|
|
|
|
// buildFeedArgs builds the bd activity arguments based on flags.
|
|
func buildFeedArgs() []string {
|
|
var args []string
|
|
|
|
// Default to follow mode unless --no-follow set
|
|
shouldFollow := !feedNoFollow
|
|
if feedFollow {
|
|
shouldFollow = true
|
|
}
|
|
|
|
if shouldFollow {
|
|
args = append(args, "--follow")
|
|
}
|
|
|
|
if feedLimit != 100 {
|
|
args = append(args, "--limit", fmt.Sprintf("%d", feedLimit))
|
|
}
|
|
|
|
if feedSince != "" {
|
|
args = append(args, "--since", feedSince)
|
|
}
|
|
|
|
if feedMol != "" {
|
|
args = append(args, "--mol", feedMol)
|
|
}
|
|
|
|
if feedType != "" {
|
|
args = append(args, "--type", feedType)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
// runFeedDirect runs bd activity in the current terminal.
|
|
func runFeedDirect(workDir string, bdArgs []string) error {
|
|
bdPath, err := exec.LookPath("bd")
|
|
if err != nil {
|
|
return fmt.Errorf("bd not found in PATH: %w", err)
|
|
}
|
|
|
|
// Prepend argv[0] for exec
|
|
fullArgs := append([]string{"bd", "activity"}, bdArgs...)
|
|
|
|
// Change to the target directory before exec
|
|
if err := os.Chdir(workDir); err != nil {
|
|
return fmt.Errorf("changing to directory %s: %w", workDir, err)
|
|
}
|
|
|
|
return syscall.Exec(bdPath, fullArgs, os.Environ())
|
|
}
|
|
|
|
// runFeedTUI runs the interactive TUI feed.
|
|
func runFeedTUI(workDir string) error {
|
|
// Must be in a Gas Town workspace
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
var sources []feed.EventSource
|
|
|
|
// Create event source from bd activity
|
|
bdSource, err := feed.NewBdActivitySource(workDir)
|
|
if err != nil {
|
|
return fmt.Errorf("creating bd activity source: %w", err)
|
|
}
|
|
sources = append(sources, bdSource)
|
|
|
|
// Create MQ event source (optional - don't fail if not available)
|
|
mqSource, err := feed.NewMQEventSourceFromWorkDir(workDir)
|
|
if err == nil {
|
|
sources = append(sources, mqSource)
|
|
}
|
|
|
|
// Create GT events source (optional - don't fail if not available)
|
|
gtSource, err := feed.NewGtEventsSource(townRoot)
|
|
if err == nil {
|
|
sources = append(sources, gtSource)
|
|
}
|
|
|
|
// Combine all sources
|
|
multiSource := feed.NewMultiSource(sources...)
|
|
defer multiSource.Close()
|
|
|
|
// Create model and connect event source
|
|
m := feed.NewModel()
|
|
m.SetEventChannel(multiSource.Events())
|
|
|
|
// Run the TUI
|
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
return fmt.Errorf("running TUI: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runFeedInWindow opens the feed in a dedicated tmux window.
|
|
func runFeedInWindow(workDir string, bdArgs []string) error {
|
|
// Check if we're in tmux
|
|
if !tmux.IsInsideTmux() {
|
|
return fmt.Errorf("--window requires running inside tmux")
|
|
}
|
|
|
|
// Get current session from TMUX env var
|
|
// Format: /tmp/tmux-501/default,12345,0 -> we need the session name
|
|
tmuxEnv := os.Getenv("TMUX")
|
|
if tmuxEnv == "" {
|
|
return fmt.Errorf("TMUX environment variable not set")
|
|
}
|
|
|
|
t := tmux.NewTmux()
|
|
|
|
// Get current session name
|
|
sessionName, err := getCurrentTmuxSession()
|
|
if err != nil {
|
|
return fmt.Errorf("getting current session: %w", err)
|
|
}
|
|
|
|
// Build the command to run in the window
|
|
// Always use follow mode in window (it's meant to be persistent)
|
|
feedCmd := fmt.Sprintf("cd %s && bd activity --follow", workDir)
|
|
if len(bdArgs) > 0 {
|
|
// Filter out --follow if present (we add it unconditionally)
|
|
var filteredArgs []string
|
|
for _, arg := range bdArgs {
|
|
if arg != "--follow" {
|
|
filteredArgs = append(filteredArgs, arg)
|
|
}
|
|
}
|
|
if len(filteredArgs) > 0 {
|
|
feedCmd = fmt.Sprintf("cd %s && bd activity --follow %s", workDir, strings.Join(filteredArgs, " "))
|
|
}
|
|
}
|
|
|
|
// Check if 'feed' window already exists
|
|
windowTarget := sessionName + ":feed"
|
|
exists, err := windowExists(t, sessionName, "feed")
|
|
if err != nil {
|
|
return fmt.Errorf("checking for feed window: %w", err)
|
|
}
|
|
|
|
if exists {
|
|
// Window exists - just switch to it
|
|
fmt.Printf("Switching to existing feed window...\n")
|
|
return selectWindow(t, windowTarget)
|
|
}
|
|
|
|
// Create new window named 'feed' with the bd activity command
|
|
fmt.Printf("Creating feed window in session %s...\n", sessionName)
|
|
if err := createWindow(t, sessionName, "feed", workDir, feedCmd); err != nil {
|
|
return fmt.Errorf("creating feed window: %w", err)
|
|
}
|
|
|
|
// Switch to the new window
|
|
return selectWindow(t, windowTarget)
|
|
}
|
|
|
|
// windowExists checks if a window with the given name exists in the session.
|
|
// Note: getCurrentTmuxSession is defined in handoff.go
|
|
func windowExists(t *tmux.Tmux, session, windowName string) (bool, error) {
|
|
cmd := exec.Command("tmux", "list-windows", "-t", session, "-F", "#{window_name}")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
if strings.TrimSpace(line) == windowName {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// createWindow creates a new tmux window with the given name and command.
|
|
func createWindow(t *tmux.Tmux, session, windowName, workDir, command string) error {
|
|
args := []string{"new-window", "-t", session, "-n", windowName, "-c", workDir, command}
|
|
cmd := exec.Command("tmux", args...)
|
|
return cmd.Run()
|
|
}
|
|
|
|
// selectWindow switches to the specified window.
|
|
func selectWindow(t *tmux.Tmux, target string) error {
|
|
cmd := exec.Command("tmux", "select-window", "-t", target)
|
|
return cmd.Run()
|
|
}
|