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
This commit is contained in:
Subhrajit Makur
2026-01-07 08:40:43 +05:30
committed by GitHub
parent c2451b85e7
commit 93b19a7e72
2 changed files with 125 additions and 1 deletions

View File

@@ -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 {

View File

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