From 93b19a7e72dac52dd2fd9a7010845fd727aa0ed1 Mon Sep 17 00:00:00 2001 From: Subhrajit Makur Date: Wed, 7 Jan 2026 08:40:43 +0530 Subject: [PATCH] feat: add watch mode to gt status (#8) (#11) (#231) * feat: add watch mode to gt status - Add --watch/-w flag for continuous status refresh - Add --interval/-n flag to set refresh interval (default 2s) - Clears screen and shows timestamp on each refresh - Graceful Ctrl+C handling to stop watch mode - Works with existing --fast and --json flags * fix(status): validate watch interval to prevent panic on zero/negative values * fix(status): harden watch mode with signal cleanup, TTY detection, and tests - Add defer signal.Stop() to prevent signal handler leak - Reject --json + --watch combination (produces invalid output) - Add TTY detection for ANSI escapes (safe when piped) - Use style.Dim for header when in TTY mode - Fix duplicate '(default 2)' in flag help - Add tests for interval validation and flag conflicts --- internal/cmd/status.go | 63 ++++++++++++++++++++++++++++++++++++- internal/cmd/status_test.go | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 625bbd56..dc7e1403 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -4,9 +4,12 @@ import ( "encoding/json" "fmt" "os" + "os/signal" "path/filepath" "strings" "sync" + "syscall" + "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" @@ -19,10 +22,13 @@ import ( "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" + "golang.org/x/term" ) var statusJSON bool var statusFast bool +var statusWatch bool +var statusInterval int var statusCmd = &cobra.Command{ Use: "status", @@ -33,13 +39,16 @@ var statusCmd = &cobra.Command{ Shows town name, registered rigs, active polecats, and witness status. -Use --fast to skip mail lookups for faster execution.`, +Use --fast to skip mail lookups for faster execution. +Use --watch to continuously refresh status at regular intervals.`, RunE: runStatus, } func init() { statusCmd.Flags().BoolVar(&statusJSON, "json", false, "Output as JSON") statusCmd.Flags().BoolVar(&statusFast, "fast", false, "Skip mail lookups for faster execution") + statusCmd.Flags().BoolVarP(&statusWatch, "watch", "w", false, "Watch mode: refresh status continuously") + statusCmd.Flags().IntVarP(&statusInterval, "interval", "n", 2, "Refresh interval in seconds") rootCmd.AddCommand(statusCmd) } @@ -120,6 +129,58 @@ type StatusSum struct { } func runStatus(cmd *cobra.Command, args []string) error { + if statusWatch { + return runStatusWatch(cmd, args) + } + return runStatusOnce(cmd, args) +} + +func runStatusWatch(cmd *cobra.Command, args []string) error { + if statusJSON { + return fmt.Errorf("--json and --watch cannot be used together") + } + if statusInterval <= 0 { + return fmt.Errorf("interval must be positive, got %d", statusInterval) + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigChan) + + ticker := time.NewTicker(time.Duration(statusInterval) * time.Second) + defer ticker.Stop() + + isTTY := term.IsTerminal(int(os.Stdout.Fd())) + + for { + if isTTY { + fmt.Print("\033[H\033[2J") // ANSI: cursor home + clear screen + } + + timestamp := time.Now().Format("15:04:05") + header := fmt.Sprintf("[%s] gt status --watch (every %ds, Ctrl+C to stop)", timestamp, statusInterval) + if isTTY { + fmt.Printf("%s\n\n", style.Dim.Render(header)) + } else { + fmt.Printf("%s\n\n", header) + } + + if err := runStatusOnce(cmd, args); err != nil { + fmt.Printf("Error: %v\n", err) + } + + select { + case <-sigChan: + if isTTY { + fmt.Println("\nStopped.") + } + return nil + case <-ticker.C: + } + } +} + +func runStatusOnce(_ *cobra.Command, _ []string) error { // Find town root townRoot, err := workspace.FindFromCwdOrError() if err != nil { diff --git a/internal/cmd/status_test.go b/internal/cmd/status_test.go index 11ef2b7f..1dd6455b 100644 --- a/internal/cmd/status_test.go +++ b/internal/cmd/status_test.go @@ -95,3 +95,66 @@ func TestRenderAgentDetails_UsesRigPrefix(t *testing.T) { t.Fatalf("output %q does not contain rig-prefixed bead ID", output) } } + +func TestRunStatusWatch_RejectsZeroInterval(t *testing.T) { + oldInterval := statusInterval + oldWatch := statusWatch + defer func() { + statusInterval = oldInterval + statusWatch = oldWatch + }() + + statusInterval = 0 + statusWatch = true + + err := runStatusWatch(nil, nil) + if err == nil { + t.Fatal("expected error for zero interval, got nil") + } + if !strings.Contains(err.Error(), "positive") { + t.Errorf("error %q should mention 'positive'", err.Error()) + } +} + +func TestRunStatusWatch_RejectsNegativeInterval(t *testing.T) { + oldInterval := statusInterval + oldWatch := statusWatch + defer func() { + statusInterval = oldInterval + statusWatch = oldWatch + }() + + statusInterval = -5 + statusWatch = true + + err := runStatusWatch(nil, nil) + if err == nil { + t.Fatal("expected error for negative interval, got nil") + } + if !strings.Contains(err.Error(), "positive") { + t.Errorf("error %q should mention 'positive'", err.Error()) + } +} + +func TestRunStatusWatch_RejectsJSONCombo(t *testing.T) { + oldJSON := statusJSON + oldWatch := statusWatch + oldInterval := statusInterval + defer func() { + statusJSON = oldJSON + statusWatch = oldWatch + statusInterval = oldInterval + }() + + statusJSON = true + statusWatch = true + statusInterval = 2 + + err := runStatusWatch(nil, nil) + if err == nil { + t.Fatal("expected error for --json + --watch, got nil") + } + if !strings.Contains(err.Error(), "cannot be used together") { + t.Errorf("error %q should mention 'cannot be used together'", err.Error()) + } +}