* 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:
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user