package cmd import ( "fmt" "os" "os/exec" "strings" "syscall" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) var ( feedFollow bool feedLimit int feedSince string feedMol string feedType string feedRig string feedNoFollow bool feedWindow 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)") } var feedCmd = &cobra.Command{ Use: "feed", GroupID: GroupDiag, Short: "Show real-time activity feed from beads", Long: `Display a real-time feed of issue and molecule state changes. This command wraps 'bd activity' to show mutations as they happen, providing visibility into workflow progress across Gas Town. By default, streams in follow mode. Use --no-follow to show events once. 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. If the window already exists, switches to it. 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 Examples: gt feed # Stream all events (default: --follow) gt feed --window # Open in dedicated tmux window gt feed -w # Short form of --window gt feed --no-follow # Show last 100 events and exit gt feed --since 1h # Events from last hour gt feed --mol gt-xyz # Filter by issue prefix gt feed --rig gastown # Use gastown rig's beads`, RunE: runFeed, } func runFeed(cmd *cobra.Command, args []string) error { // 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 != "" { townRoot, err := workspace.FindFromCwdOrError() if err != nil { return fmt.Errorf("not in a Gas Town workspace: %w", err) } // 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) } // Standard 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()) } // 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() }