From 40977b1ccca4f9383e34ac011046fe958d81d213 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 16:07:58 -0800 Subject: [PATCH] feat: Unify C-b n/p cycling across session types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation set separate bindings for crew vs town sessions, but tmux key bindings are global. This meant whichever session type was started last would overwrite the other's bindings. New approach: - Add unified `gt cycle next/prev` command that auto-detects session type - Town sessions (gt-mayor, gt-deacon) cycle within town group - Crew sessions (gt-*-crew-*) cycle within their rig's crew - Other sessions (polecats, witness, refinery) do nothing on cycle The old SetCrewCycleBindings and SetTownCycleBindings are now aliases for the unified SetCycleBindings function. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/cycle.go | 88 +++++++++++++++++++++++++++++++++++++++++++ internal/tmux/tmux.go | 45 +++++++++++----------- 2 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 internal/cmd/cycle.go diff --git a/internal/cmd/cycle.go b/internal/cmd/cycle.go new file mode 100644 index 00000000..e3e36e3a --- /dev/null +++ b/internal/cmd/cycle.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" +) + +// cycleSession is the --session flag for cycle next/prev commands. +// When run via tmux key binding (run-shell), the session context may not be +// correct, so we pass the session name explicitly via #{session_name} expansion. +var cycleSession string + +func init() { + rootCmd.AddCommand(cycleCmd) + cycleCmd.AddCommand(cycleNextCmd) + cycleCmd.AddCommand(cyclePrevCmd) + + cycleNextCmd.Flags().StringVar(&cycleSession, "session", "", "Override current session (used by tmux binding)") + cyclePrevCmd.Flags().StringVar(&cycleSession, "session", "", "Override current session (used by tmux binding)") +} + +var cycleCmd = &cobra.Command{ + Use: "cycle", + Short: "Cycle between sessions in the same group", + Long: `Cycle between related tmux sessions based on the current session type. + +Session groups: +- Town sessions: Mayor ↔ Deacon +- Crew sessions: All crew members in the same rig (e.g., gastown/crew/max ↔ gastown/crew/joe) + +The appropriate cycling is detected automatically from the session name.`, +} + +var cycleNextCmd = &cobra.Command{ + Use: "next", + Short: "Switch to next session in group", + Long: `Switch to the next session in the current group. + +This command is typically invoked via the C-b n keybinding. It automatically +detects whether you're in a town-level session (Mayor/Deacon) or a crew session +and cycles within the appropriate group.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cycleToSession(1, cycleSession) + }, +} + +var cyclePrevCmd = &cobra.Command{ + Use: "prev", + Short: "Switch to previous session in group", + Long: `Switch to the previous session in the current group. + +This command is typically invoked via the C-b p keybinding. It automatically +detects whether you're in a town-level session (Mayor/Deacon) or a crew session +and cycles within the appropriate group.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cycleToSession(-1, cycleSession) + }, +} + +// cycleToSession dispatches to the appropriate cycling function based on session type. +// direction: 1 for next, -1 for previous +// sessionOverride: if non-empty, use this instead of detecting current session +func cycleToSession(direction int, sessionOverride string) error { + session := sessionOverride + if session == "" { + var err error + session, err = getCurrentTmuxSession() + if err != nil { + return nil // Not in tmux, nothing to do + } + } + + // Check if it's a town-level session + for _, townSession := range townLevelSessions { + if session == townSession { + return cycleTownSession(direction, session) + } + } + + // Check if it's a crew session (format: gt--crew-) + if strings.HasPrefix(session, "gt-") && strings.Contains(session, "-crew-") { + return cycleCrewSession(direction, session) + } + + // Unknown session type (polecat, witness, refinery) - do nothing + return nil +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index e5753da7..1b7870d8 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -653,39 +653,42 @@ func (t *Tmux) SwitchClient(targetSession string) error { return err } -// SetCrewCycleBindings sets up C-b n/p to cycle through crew sessions in the same rig. -// This allows quick switching between crew members without using the session picker. +// SetCrewCycleBindings sets up C-b n/p to cycle through sessions. +// This is now an alias for SetCycleBindings - the unified command detects +// session type automatically. // // IMPORTANT: We pass #{session_name} to the command because run-shell doesn't // reliably preserve the session context. tmux expands #{session_name} at binding // resolution time (when the key is pressed), giving us the correct session. func (t *Tmux) SetCrewCycleBindings(session string) error { - // C-b n → gt crew next (switch to next crew session) - // #{session_name} is expanded by tmux when the key is pressed - if _, err := t.run("bind-key", "-T", "prefix", "n", - "run-shell", "gt crew next --session '#{session_name}'"); err != nil { - return err - } - // C-b p → gt crew prev (switch to previous crew session) - if _, err := t.run("bind-key", "-T", "prefix", "p", - "run-shell", "gt crew prev --session '#{session_name}'"); err != nil { - return err - } - return nil + return t.SetCycleBindings(session) } -// SetTownCycleBindings sets up C-b n/p to cycle through town-level sessions. -// Town-level sessions are Mayor and Deacon - the global coordinators. -// This allows quick switching between them without using the session picker. +// SetTownCycleBindings sets up C-b n/p to cycle through sessions. +// This is now an alias for SetCycleBindings - the unified command detects +// session type automatically. func (t *Tmux) SetTownCycleBindings(session string) error { - // C-b n → gt town next (switch to next town session) + return t.SetCycleBindings(session) +} + +// SetCycleBindings sets up C-b n/p to cycle through related sessions. +// The gt cycle command automatically detects the session type and cycles +// within the appropriate group: +// - Town sessions: Mayor ↔ Deacon +// - Crew sessions: All crew members in the same rig +// +// IMPORTANT: We pass #{session_name} to the command because run-shell doesn't +// reliably preserve the session context. tmux expands #{session_name} at binding +// resolution time (when the key is pressed), giving us the correct session. +func (t *Tmux) SetCycleBindings(session string) error { + // C-b n → gt cycle next (auto-detects session type) if _, err := t.run("bind-key", "-T", "prefix", "n", - "run-shell", "gt town next --session '#{session_name}'"); err != nil { + "run-shell", "gt cycle next --session '#{session_name}'"); err != nil { return err } - // C-b p → gt town prev (switch to previous town session) + // C-b p → gt cycle prev (auto-detects session type) if _, err := t.run("bind-key", "-T", "prefix", "p", - "run-shell", "gt town prev --session '#{session_name}'"); err != nil { + "run-shell", "gt cycle prev --session '#{session_name}'"); err != nil { return err } return nil