Files
gastown/internal/cmd/feed.go
Steve Yegge 012ff444db Add --window flag to gt feed for tmux integration (gt-3pm0f)
Phase 2: Adds dedicated tmux window support:
- gt feed --window creates 'feed' window in current session
- If window exists, switches to it (idempotent)
- Always uses --follow mode in window (persistent stream)
- Error handling for non-tmux environments

Users can now C-b n/p to cycle to/from the activity feed.

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

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

260 lines
7.5 KiB
Go

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()
}