diff --git a/internal/cmd/cycle.go b/internal/cmd/cycle.go index 7312684c..c30c112c 100644 --- a/internal/cmd/cycle.go +++ b/internal/cmd/cycle.go @@ -32,6 +32,7 @@ Session groups: - Town sessions: Mayor ↔ Deacon - Crew sessions: All crew members in the same rig (e.g., gastown/crew/max ↔ gastown/crew/joe) - Rig infra sessions: Witness ↔ Refinery (per rig) +- Polecat sessions: All polecats in the same rig (e.g., gastown/Toast ↔ gastown/Nux) The appropriate cycling is detected automatically from the session name.`, } @@ -92,7 +93,12 @@ func cycleToSession(direction int, sessionOverride string) error { return cycleRigInfraSession(direction, session, rig) } - // Unknown session type (polecat) - do nothing + // Check if it's a polecat session (gt--, not crew/witness/refinery) + if rig, _, ok := parsePolecatSessionName(session); ok && rig != "" { + return cyclePolecatSession(direction, session) + } + + // Unknown session type - do nothing return nil } diff --git a/internal/cmd/polecat_cycle.go b/internal/cmd/polecat_cycle.go new file mode 100644 index 00000000..995b3268 --- /dev/null +++ b/internal/cmd/polecat_cycle.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "fmt" + "os/exec" + "sort" + "strings" +) + +// cyclePolecatSession switches to the next or previous polecat session in the same rig. +// direction: 1 for next, -1 for previous +// sessionOverride: if non-empty, use this instead of detecting current session +func cyclePolecatSession(direction int, sessionOverride string) error { + var currentSession string + var err error + + if sessionOverride != "" { + currentSession = sessionOverride + } else { + currentSession, err = getCurrentTmuxSession() + if err != nil { + return fmt.Errorf("not in a tmux session: %w", err) + } + if currentSession == "" { + return fmt.Errorf("not in a tmux session") + } + } + + // Parse rig name from current session + rigName, _, ok := parsePolecatSessionName(currentSession) + if !ok { + // Not a polecat session - no cycling + return nil + } + + // Find all polecat sessions for this rig + sessions, err := findRigPolecatSessions(rigName) + if err != nil { + return fmt.Errorf("listing sessions: %w", err) + } + + if len(sessions) == 0 { + return nil // No polecat sessions + } + + // Sort for consistent ordering + sort.Strings(sessions) + + // Find current position + currentIdx := -1 + for i, s := range sessions { + if s == currentSession { + currentIdx = i + break + } + } + + if currentIdx == -1 { + // Current session not in list (shouldn't happen) + return nil + } + + // Calculate target index (with wrapping) + targetIdx := (currentIdx + direction + len(sessions)) % len(sessions) + + if targetIdx == currentIdx { + // Only one session, nothing to switch to + return nil + } + + targetSession := sessions[targetIdx] + + // Switch to target session + cmd := exec.Command("tmux", "switch-client", "-t", targetSession) + if err := cmd.Run(); err != nil { + return fmt.Errorf("switching to %s: %w", targetSession, err) + } + + return nil +} + +// parsePolecatSessionName extracts rig and polecat name from a tmux session name. +// Format: gt-- where name is NOT crew-*, witness, or refinery. +// Returns empty strings and false if the format doesn't match. +func parsePolecatSessionName(sessionName string) (rigName, polecatName string, ok bool) { + // Must start with "gt-" + if !strings.HasPrefix(sessionName, "gt-") { + return "", "", false + } + + // Exclude town-level sessions + if sessionName == "gt-mayor" || sessionName == "gt-deacon" { + return "", "", false + } + + // Remove "gt-" prefix + rest := sessionName[3:] + + // Must have at least one hyphen (rig-name) + idx := strings.Index(rest, "-") + if idx == -1 { + return "", "", false + } + + rigName = rest[:idx] + polecatName = rest[idx+1:] + + if rigName == "" || polecatName == "" { + return "", "", false + } + + // Exclude crew sessions (contain "crew-" prefix in the name part) + if strings.HasPrefix(polecatName, "crew-") { + return "", "", false + } + + // Exclude rig infra sessions + if polecatName == "witness" || polecatName == "refinery" { + return "", "", false + } + + return rigName, polecatName, true +} + +// findRigPolecatSessions returns all polecat sessions for a given rig. +// Uses tmux list-sessions to find sessions matching gt-- pattern, +// excluding crew, witness, and refinery sessions. +func findRigPolecatSessions(rigName string) ([]string, error) { + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}") + out, err := cmd.Output() + if err != nil { + // No tmux server or no sessions + return nil, nil + } + + prefix := fmt.Sprintf("gt-%s-", rigName) + var sessions []string + + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + if !strings.HasPrefix(line, prefix) { + continue + } + + // Verify this is actually a polecat session + _, _, ok := parsePolecatSessionName(line) + if ok { + sessions = append(sessions, line) + } + } + + return sessions, nil +} diff --git a/internal/cmd/polecat_cycle_test.go b/internal/cmd/polecat_cycle_test.go new file mode 100644 index 00000000..e87434a5 --- /dev/null +++ b/internal/cmd/polecat_cycle_test.go @@ -0,0 +1,118 @@ +package cmd + +import "testing" + +func TestParsePolecatSessionName(t *testing.T) { + tests := []struct { + name string + sessionName string + wantRig string + wantPolecat string + wantOk bool + }{ + // Valid polecat sessions + { + name: "simple polecat", + sessionName: "gt-gastown-Toast", + wantRig: "gastown", + wantPolecat: "Toast", + wantOk: true, + }, + { + name: "another polecat", + sessionName: "gt-gastown-Nux", + wantRig: "gastown", + wantPolecat: "Nux", + wantOk: true, + }, + { + name: "polecat in different rig", + sessionName: "gt-beads-Worker", + wantRig: "beads", + wantPolecat: "Worker", + wantOk: true, + }, + { + name: "polecat with hyphen in name", + sessionName: "gt-gastown-Max-01", + wantRig: "gastown", + wantPolecat: "Max-01", + wantOk: true, + }, + + // Not polecat sessions (should return false) + { + name: "crew session", + sessionName: "gt-gastown-crew-jack", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + { + name: "witness session", + sessionName: "gt-gastown-witness", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + { + name: "refinery session", + sessionName: "gt-gastown-refinery", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + { + name: "mayor session", + sessionName: "gt-mayor", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + { + name: "deacon session", + sessionName: "gt-deacon", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + { + name: "no gt prefix", + sessionName: "gastown-Toast", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + { + name: "empty string", + sessionName: "", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + { + name: "just gt prefix", + sessionName: "gt-", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + { + name: "no name after rig", + sessionName: "gt-gastown-", + wantRig: "", + wantPolecat: "", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRig, gotPolecat, gotOk := parsePolecatSessionName(tt.sessionName) + if gotRig != tt.wantRig || gotPolecat != tt.wantPolecat || gotOk != tt.wantOk { + t.Errorf("parsePolecatSessionName(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.sessionName, gotRig, gotPolecat, gotOk, tt.wantRig, tt.wantPolecat, tt.wantOk) + } + }) + } +}