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 - 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, 1/2/3 for panels, q to quit 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). 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 func() { _ = multiSource.Close() }() // 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()) 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(_ *tmux.Tmux, session, windowName string) (bool, error) { // t unused: direct exec for simplicity 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(_ *tmux.Tmux, session, windowName, workDir, command string) error { // t unused: direct exec for simplicity 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(_ *tmux.Tmux, target string) error { // t unused: direct exec for simplicity cmd := exec.Command("tmux", "select-window", "-t", target) return cmd.Run() }