* 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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
@@ -19,10 +22,13 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
var statusJSON bool
|
var statusJSON bool
|
||||||
var statusFast bool
|
var statusFast bool
|
||||||
|
var statusWatch bool
|
||||||
|
var statusInterval int
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
var statusCmd = &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
@@ -33,13 +39,16 @@ var statusCmd = &cobra.Command{
|
|||||||
|
|
||||||
Shows town name, registered rigs, active polecats, and witness status.
|
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,
|
RunE: runStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
statusCmd.Flags().BoolVar(&statusJSON, "json", false, "Output as JSON")
|
statusCmd.Flags().BoolVar(&statusJSON, "json", false, "Output as JSON")
|
||||||
statusCmd.Flags().BoolVar(&statusFast, "fast", false, "Skip mail lookups for faster execution")
|
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)
|
rootCmd.AddCommand(statusCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +129,58 @@ type StatusSum struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runStatus(cmd *cobra.Command, args []string) error {
|
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
|
// Find town root
|
||||||
townRoot, err := workspace.FindFromCwdOrError()
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
if err != nil {
|
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)
|
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