diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go index e2373a59..e6c57f51 100644 --- a/internal/cmd/crew_at.go +++ b/internal/cmd/crew_at.go @@ -101,12 +101,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error { } // Apply rig-based theming (non-fatal: theming failure doesn't affect operation) + // Note: ConfigureGasTownSession includes cycle bindings theme := getThemeForRig(r.Name) _ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew") - // Set up C-b n/p keybindings for crew session cycling (non-fatal) - _ = t.SetCrewCycleBindings(sessionID) - // Wait for shell to be ready after session creation if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { return fmt.Errorf("waiting for shell: %w", err) diff --git a/internal/cmd/cycle.go b/internal/cmd/cycle.go index e3e36e3a..7312684c 100644 --- a/internal/cmd/cycle.go +++ b/internal/cmd/cycle.go @@ -1,6 +1,9 @@ package cmd import ( + "fmt" + "os/exec" + "sort" "strings" "github.com/spf13/cobra" @@ -28,6 +31,7 @@ var cycleCmd = &cobra.Command{ 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) The appropriate cycling is detected automatically from the session name.`, } @@ -83,6 +87,89 @@ func cycleToSession(direction int, sessionOverride string) error { return cycleCrewSession(direction, session) } - // Unknown session type (polecat, witness, refinery) - do nothing + // Check if it's a rig infra session (witness or refinery) + if rig := parseRigInfraSession(session); rig != "" { + return cycleRigInfraSession(direction, session, rig) + } + + // Unknown session type (polecat) - do nothing return nil } + +// parseRigInfraSession extracts rig name if this is a witness or refinery session. +// Returns empty string if not a rig infra session. +// Format: gt--witness or gt--refinery +func parseRigInfraSession(session string) string { + if !strings.HasPrefix(session, "gt-") { + return "" + } + rest := session[3:] // Remove "gt-" prefix + + // Check for -witness or -refinery suffix + if strings.HasSuffix(rest, "-witness") { + return strings.TrimSuffix(rest, "-witness") + } + if strings.HasSuffix(rest, "-refinery") { + return strings.TrimSuffix(rest, "-refinery") + } + return "" +} + +// cycleRigInfraSession cycles between witness and refinery sessions for a rig. +func cycleRigInfraSession(direction int, currentSession, rig string) error { + // Find running infra sessions for this rig + witnessSession := fmt.Sprintf("gt-%s-witness", rig) + refinerySession := fmt.Sprintf("gt-%s-refinery", rig) + + var sessions []string + allSessions, err := listTmuxSessions() + if err != nil { + return err + } + + for _, s := range allSessions { + if s == witnessSession || s == refinerySession { + sessions = append(sessions, s) + } + } + + if len(sessions) == 0 { + return nil // No infra sessions running + } + + // 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 { + return nil // Current session not in list + } + + // Calculate target index (with wrapping) + targetIdx := (currentIdx + direction + len(sessions)) % len(sessions) + + if targetIdx == currentIdx { + return nil // Only one session + } + + // Switch to target session + cmd := exec.Command("tmux", "switch-client", "-t", sessions[targetIdx]) + return cmd.Run() +} + +// listTmuxSessions returns all tmux session names. +func listTmuxSessions() ([]string, error) { + out, err := exec.Command("tmux", "list-sessions", "-F", "#{session_name}").Output() + if err != nil { + return nil, err + } + return splitLines(string(out)), nil +} diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index dd3e21dc..cb1f878c 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -185,12 +185,10 @@ func startDeaconSession(t *tmux.Tmux) error { _ = t.SetEnvironment(DeaconSessionName, "BD_ACTOR", "deacon") // Apply Deacon theme (non-fatal: theming failure doesn't affect operation) + // Note: ConfigureGasTownSession includes cycle bindings theme := tmux.DeaconTheme() _ = t.ConfigureGasTownSession(DeaconSessionName, theme, "", "Deacon", "health-check") - // Set up C-b n/p keybindings for town session cycling (non-fatal) - _ = t.SetTownCycleBindings(DeaconSessionName) - // Launch Claude directly (no shell respawn loop) // Restarts are handled by daemon via ensureDeaconRunning on each heartbeat // The startup hook handles context loading automatically diff --git a/internal/cmd/feed.go b/internal/cmd/feed.go index 5d0fe799..74857d14 100644 --- a/internal/cmd/feed.go +++ b/internal/cmd/feed.go @@ -75,6 +75,12 @@ Examples: } func runFeed(cmd *cobra.Command, args []string) error { + // Must be in a Gas Town workspace + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace (run from ~/gt or a rig directory)") + } + // Determine working directory workDir, err := os.Getwd() if err != nil { @@ -83,11 +89,6 @@ func runFeed(cmd *cobra.Command, args []string) error { // If --rig specified, find that rig's beads directory if feedRig != "" { - townRoot, err := workspace.FindFromCwdOrError() - if err != nil { - return fmt.Errorf("not in a Gas Town workspace: %w", err) - } - // Try common beads locations for the rig candidates := []string{ fmt.Sprintf("%s/%s/mayor/rig", townRoot, feedRig), diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index 76f37597..2ce3196b 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -123,12 +123,10 @@ func startMayorSession(t *tmux.Tmux) error { _ = t.SetEnvironment(MayorSessionName, "BD_ACTOR", "mayor") // Apply Mayor theme (non-fatal: theming failure doesn't affect operation) + // Note: ConfigureGasTownSession includes cycle bindings theme := tmux.MayorTheme() _ = t.ConfigureGasTownSession(MayorSessionName, theme, "", "Mayor", "coordinator") - // Set up C-b n/p keybindings for town session cycling (non-fatal) - _ = t.SetTownCycleBindings(MayorSessionName) - // Launch Claude - the startup hook handles 'gt prime' automatically // Use SendKeysDelayed to allow shell initialization after NewSession // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes diff --git a/internal/cmd/start.go b/internal/cmd/start.go index cc783dce..6fc543c6 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -747,7 +747,7 @@ func runStartCrew(cmd *cobra.Command, args []string) error { return fmt.Errorf("restarting claude: %w", err) } // Wait for Claude to start, then prime - shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} + shells := constants.SupportedShells if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { style.PrintWarning("Timeout waiting for Claude to start: %v", err) } @@ -774,12 +774,10 @@ func runStartCrew(cmd *cobra.Command, args []string) error { } // Apply rig-based theming (non-fatal: theming failure doesn't affect operation) + // Note: ConfigureGasTownSession includes cycle bindings theme := getThemeForRig(rigName) _ = t.ConfigureGasTownSession(sessionID, theme, rigName, name, "crew") - // Set up C-b n/p keybindings for crew session cycling (non-fatal) - _ = t.SetCrewCycleBindings(sessionID) - // Wait for shell to be ready after session creation if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { return fmt.Errorf("waiting for shell: %w", err) @@ -791,7 +789,7 @@ func runStartCrew(cmd *cobra.Command, args []string) error { } // Wait for Claude to start - shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} + shells := constants.SupportedShells if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { style.PrintWarning("Timeout waiting for Claude to start: %v", err) } @@ -925,7 +923,7 @@ func startCrewMember(rigName, crewName, townRoot string) error { } // Wait for Claude to start - shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} + shells := constants.SupportedShells if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { // Non-fatal: Claude might still be starting } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 1b7870d8..a199e481 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -611,6 +611,9 @@ func (t *Tmux) ConfigureGasTownSession(session string, theme Theme, rig, worker, if err := t.SetFeedBinding(session); err != nil { return fmt.Errorf("setting feed binding: %w", err) } + if err := t.SetCycleBindings(session); err != nil { + return fmt.Errorf("setting cycle bindings: %w", err) + } return nil } diff --git a/internal/tui/feed/model.go b/internal/tui/feed/model.go index 4fed69ad..d0e43278 100644 --- a/internal/tui/feed/model.go +++ b/internal/tui/feed/model.go @@ -1,6 +1,7 @@ package feed import ( + "sync" "time" "github.com/charmbracelet/bubbles/help" @@ -64,24 +65,23 @@ type Model struct { events []Event // UI state - keys KeyMap - help help.Model - showHelp bool - filter string - filterActive bool - err error + keys KeyMap + help help.Model + showHelp bool + filter string // Event source eventChan <-chan Event done chan struct{} + closeOnce sync.Once } // NewModel creates a new feed TUI model -func NewModel() Model { +func NewModel() *Model { h := help.New() h.ShowAll = false - return Model{ + return &Model{ focusedPanel: PanelTree, treeViewport: viewport.New(0, 0), feedViewport: viewport.New(0, 0), @@ -94,7 +94,7 @@ func NewModel() Model { } // Init initializes the model -func (m Model) Init() tea.Cmd { +func (m *Model) Init() tea.Cmd { return tea.Batch( m.listenForEvents(), tea.SetWindowTitle("GT Feed"), @@ -108,18 +108,21 @@ type eventMsg Event type tickMsg time.Time // listenForEvents returns a command that listens for events -func (m Model) listenForEvents() tea.Cmd { +func (m *Model) listenForEvents() tea.Cmd { if m.eventChan == nil { return nil } + // Capture channels to avoid race with Model mutations + eventChan := m.eventChan + done := m.done return func() tea.Msg { select { - case event, ok := <-m.eventChan: + case event, ok := <-eventChan: if !ok { return nil } return eventMsg(event) - case <-m.done: + case <-done: return nil } } @@ -133,7 +136,7 @@ func tick() tea.Cmd { } // Update handles messages -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -166,10 +169,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // handleKey processes key presses -func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Quit): - close(m.done) + m.closeOnce.Do(func() { close(m.done) }) return m, tea.Quit case key.Matches(msg, m.keys.Help): @@ -293,6 +296,6 @@ func (m *Model) SetEventChannel(ch <-chan Event) { } // View renders the TUI -func (m Model) View() string { +func (m *Model) View() string { return m.render() } diff --git a/internal/tui/feed/view.go b/internal/tui/feed/view.go index 899e07dd..2a3cd6f7 100644 --- a/internal/tui/feed/view.go +++ b/internal/tui/feed/view.go @@ -10,7 +10,7 @@ import ( ) // render produces the full TUI output -func (m Model) render() string { +func (m *Model) render() string { if m.width == 0 || m.height == 0 { return "Loading..." } @@ -40,7 +40,7 @@ func (m Model) render() string { } // renderHeader renders the top header bar -func (m Model) renderHeader() string { +func (m *Model) renderHeader() string { title := TitleStyle.Render("GT Feed") filter := "" @@ -60,7 +60,7 @@ func (m Model) renderHeader() string { } // renderTreePanel renders the agent tree panel with border -func (m Model) renderTreePanel() string { +func (m *Model) renderTreePanel() string { style := TreePanelStyle if m.focusedPanel == PanelTree { style = FocusedBorderStyle @@ -69,7 +69,7 @@ func (m Model) renderTreePanel() string { } // renderFeedPanel renders the event feed panel with border -func (m Model) renderFeedPanel() string { +func (m *Model) renderFeedPanel() string { style := StreamPanelStyle if m.focusedPanel == PanelFeed { style = FocusedBorderStyle @@ -78,7 +78,7 @@ func (m Model) renderFeedPanel() string { } // renderTree renders the agent tree content -func (m Model) renderTree() string { +func (m *Model) renderTree() string { if len(m.rigs) == 0 { return AgentIdleStyle.Render("No agents active") } @@ -131,7 +131,7 @@ func (m Model) renderTree() string { } // groupAgentsByRole groups agents by their role -func (m Model) groupAgentsByRole(agents map[string]*Agent) map[string][]*Agent { +func (m *Model) groupAgentsByRole(agents map[string]*Agent) map[string][]*Agent { result := make(map[string][]*Agent) for _, agent := range agents { role := agent.Role @@ -152,7 +152,7 @@ func (m Model) groupAgentsByRole(agents map[string]*Agent) map[string][]*Agent { } // renderAgentGroup renders a group of agents (crew or polecats) -func (m Model) renderAgentGroup(icon, role string, agents []*Agent) string { +func (m *Model) renderAgentGroup(icon, role string, agents []*Agent) string { var lines []string // Group header @@ -172,10 +172,12 @@ func (m Model) renderAgentGroup(icon, role string, agents []*Agent) string { } // renderAgent renders a single agent line -func (m Model) renderAgent(icon string, agent *Agent, indent int) string { +func (m *Model) renderAgent(icon string, agent *Agent, indent int) string { prefix := strings.Repeat(" ", indent) - if icon != "" { + if icon != "" && indent >= 2 { prefix = strings.Repeat(" ", indent-2) + icon + " " + } else if icon != "" { + prefix = icon + " " } // Name with status indicator @@ -208,7 +210,7 @@ func (m Model) renderAgent(icon string, agent *Agent, indent int) string { } // renderFeed renders the event feed content -func (m Model) renderFeed() string { +func (m *Model) renderFeed() string { if len(m.events) == 0 { return AgentIdleStyle.Render("No events yet") } @@ -230,7 +232,7 @@ func (m Model) renderFeed() string { } // renderEvent renders a single event line -func (m Model) renderEvent(e Event) string { +func (m *Model) renderEvent(e Event) string { // Timestamp ts := TimestampStyle.Render(fmt.Sprintf("[%s]", e.Time.Format("15:04:05"))) @@ -282,7 +284,7 @@ func (m Model) renderEvent(e Event) string { } // renderStatusBar renders the bottom status bar -func (m Model) renderStatusBar() string { +func (m *Model) renderStatusBar() string { // Panel indicator panelName := "tree" if m.focusedPanel == PanelFeed { @@ -307,7 +309,7 @@ func (m Model) renderStatusBar() string { } // renderShortHelp renders abbreviated key hints -func (m Model) renderShortHelp() string { +func (m *Model) renderShortHelp() string { hints := []string{ HelpKeyStyle.Render("j/k") + HelpDescStyle.Render(":scroll"), HelpKeyStyle.Render("tab") + HelpDescStyle.Render(":switch"),