From 481138a72ba9bb53a6a6a9d9444a151fe74debcf Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 23:23:15 -0800 Subject: [PATCH] feat(polecat): Add gt polecat status command (gt-6lt3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements detailed polecat status display including: - Lifecycle state (working, done, stuck, idle) - Assigned issue - Session status (running/stopped, attached/detached) - Session creation time - Last activity time with relative "ago" format Also extends tmux.SessionInfo and session.Info to include last activity timestamp from tmux session_activity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/polecat.go | 174 ++++++++++++++++++++++++++++++++++++ internal/session/manager.go | 11 +++ internal/tmux/tmux.go | 28 ++++-- 3 files changed, 205 insertions(+), 8 deletions(-) diff --git a/internal/cmd/polecat.go b/internal/cmd/polecat.go index ac24a916..6c050032 100644 --- a/internal/cmd/polecat.go +++ b/internal/cmd/polecat.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/config" @@ -169,9 +170,29 @@ Examples: RunE: runPolecatSync, } +var polecatStatusCmd = &cobra.Command{ + Use: "status /", + Short: "Show detailed status for a polecat", + Long: `Show detailed status for a polecat. + +Displays comprehensive information including: + - Current lifecycle state (working, done, stuck, idle) + - Assigned issue (if any) + - Session status (running/stopped, attached/detached) + - Session creation time + - Last activity time + +Examples: + gt polecat status gastown/Toast + gt polecat status gastown/Toast --json`, + Args: cobra.ExactArgs(1), + RunE: runPolecatStatus, +} + var ( polecatSyncAll bool polecatSyncFromMain bool + polecatStatusJSON bool ) func init() { @@ -186,6 +207,9 @@ func init() { polecatSyncCmd.Flags().BoolVar(&polecatSyncAll, "all", false, "Sync all polecats in the rig") polecatSyncCmd.Flags().BoolVar(&polecatSyncFromMain, "from-main", false, "Pull only, no push") + // Status flags + polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON") + // Add subcommands polecatCmd.AddCommand(polecatListCmd) polecatCmd.AddCommand(polecatAddCmd) @@ -195,6 +219,7 @@ func init() { polecatCmd.AddCommand(polecatDoneCmd) polecatCmd.AddCommand(polecatResetCmd) polecatCmd.AddCommand(polecatSyncCmd) + polecatCmd.AddCommand(polecatStatusCmd) rootCmd.AddCommand(polecatCmd) } @@ -577,3 +602,152 @@ func runPolecatSync(cmd *cobra.Command, args []string) error { return nil } + +// PolecatStatus represents detailed polecat status for JSON output. +type PolecatStatus struct { + Rig string `json:"rig"` + Name string `json:"name"` + State polecat.State `json:"state"` + Issue string `json:"issue,omitempty"` + ClonePath string `json:"clone_path"` + Branch string `json:"branch"` + SessionRunning bool `json:"session_running"` + SessionID string `json:"session_id,omitempty"` + Attached bool `json:"attached,omitempty"` + Windows int `json:"windows,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + LastActivity string `json:"last_activity,omitempty"` +} + +func runPolecatStatus(cmd *cobra.Command, args []string) error { + rigName, polecatName, err := parseAddress(args[0]) + if err != nil { + return err + } + + mgr, r, err := getPolecatManager(rigName) + if err != nil { + return err + } + + // Get polecat info + p, err := mgr.Get(polecatName) + if err != nil { + return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) + } + + // Get session info + t := tmux.NewTmux() + sessMgr := session.NewManager(t, r) + sessInfo, err := sessMgr.Status(polecatName) + if err != nil { + // Non-fatal - continue without session info + sessInfo = &session.Info{ + Polecat: polecatName, + Running: false, + } + } + + // JSON output + if polecatStatusJSON { + status := PolecatStatus{ + Rig: rigName, + Name: polecatName, + State: p.State, + Issue: p.Issue, + ClonePath: p.ClonePath, + Branch: p.Branch, + SessionRunning: sessInfo.Running, + SessionID: sessInfo.SessionID, + Attached: sessInfo.Attached, + Windows: sessInfo.Windows, + } + if !sessInfo.Created.IsZero() { + status.CreatedAt = sessInfo.Created.Format("2006-01-02 15:04:05") + } + if !sessInfo.LastActivity.IsZero() { + status.LastActivity = sessInfo.LastActivity.Format("2006-01-02 15:04:05") + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(status) + } + + // Human-readable output + fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Polecat: %s/%s", rigName, polecatName))) + + // State with color + stateStr := string(p.State) + switch p.State { + case polecat.StateWorking: + stateStr = style.Info.Render(stateStr) + case polecat.StateStuck: + stateStr = style.Warning.Render(stateStr) + case polecat.StateDone: + stateStr = style.Success.Render(stateStr) + default: + stateStr = style.Dim.Render(stateStr) + } + fmt.Printf(" State: %s\n", stateStr) + + // Issue + if p.Issue != "" { + fmt.Printf(" Issue: %s\n", p.Issue) + } else { + fmt.Printf(" Issue: %s\n", style.Dim.Render("(none)")) + } + + // Clone path and branch + fmt.Printf(" Clone: %s\n", style.Dim.Render(p.ClonePath)) + fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch)) + + // Session info + fmt.Println() + fmt.Printf("%s\n", style.Bold.Render("Session")) + + if sessInfo.Running { + fmt.Printf(" Status: %s\n", style.Success.Render("running")) + fmt.Printf(" Session ID: %s\n", style.Dim.Render(sessInfo.SessionID)) + + if sessInfo.Attached { + fmt.Printf(" Attached: %s\n", style.Info.Render("yes")) + } else { + fmt.Printf(" Attached: %s\n", style.Dim.Render("no")) + } + + if sessInfo.Windows > 0 { + fmt.Printf(" Windows: %d\n", sessInfo.Windows) + } + + if !sessInfo.Created.IsZero() { + fmt.Printf(" Created: %s\n", sessInfo.Created.Format("2006-01-02 15:04:05")) + } + + if !sessInfo.LastActivity.IsZero() { + // Show relative time for activity + ago := formatActivityTime(sessInfo.LastActivity) + fmt.Printf(" Last Activity: %s (%s)\n", + sessInfo.LastActivity.Format("15:04:05"), + style.Dim.Render(ago)) + } + } else { + fmt.Printf(" Status: %s\n", style.Dim.Render("not running")) + } + + return nil +} + +// formatActivityTime returns a human-readable relative time string. +func formatActivityTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%d seconds ago", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%d minutes ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%d hours ago", int(d.Hours())) + default: + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) + } +} diff --git a/internal/session/manager.go b/internal/session/manager.go index 594116e8..6ce90c6d 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -69,6 +69,9 @@ type Info struct { // Windows is the number of tmux windows. Windows int `json:"windows,omitempty"` + + // LastActivity is when the session last had activity. + LastActivity time.Time `json:"last_activity,omitempty"` } // sessionName generates the tmux session name for a polecat. @@ -254,6 +257,14 @@ func (m *Manager) Status(polecat string) (*Info, error) { } } + // Parse activity time (unix timestamp from tmux) + if tmuxInfo.Activity != "" { + var activityUnix int64 + if _, err := fmt.Sscanf(tmuxInfo.Activity, "%d", &activityUnix); err == nil && activityUnix > 0 { + info.LastActivity = time.Unix(activityUnix, 0) + } + } + return info, nil } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index b332b41a..2445d04f 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -218,10 +218,12 @@ func (t *Tmux) RenameSession(oldName, newName string) error { // SessionInfo contains information about a tmux session. type SessionInfo struct { - Name string - Windows int - Created string - Attached bool + Name string + Windows int + Created string + Attached bool + Activity string // Last activity time + LastAttached string // Last time the session was attached } // DisplayMessage shows a message in the tmux status line. @@ -316,7 +318,7 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error { // GetSessionInfo returns detailed information about a session. func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) { - format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}" + format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}|#{session_activity}|#{session_last_attached}" out, err := t.run("list-sessions", "-F", format, "-f", fmt.Sprintf("#{==:#{session_name},%s}", name)) if err != nil { return nil, err @@ -326,19 +328,29 @@ func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) { } parts := strings.Split(out, "|") - if len(parts) != 4 { + if len(parts) < 4 { return nil, fmt.Errorf("unexpected session info format: %s", out) } windows := 0 _, _ = fmt.Sscanf(parts[1], "%d", &windows) - return &SessionInfo{ + info := &SessionInfo{ Name: parts[0], Windows: windows, Created: parts[2], Attached: parts[3] == "1", - }, nil + } + + // Activity and last attached are optional (may not be present in older tmux) + if len(parts) > 4 { + info.Activity = parts[4] + } + if len(parts) > 5 { + info.LastAttached = parts[5] + } + + return info, nil } // ApplyTheme sets the status bar style for a session.