package tui import ( "context" "fmt" "os" "os/exec" "strings" "time" tea "charm.land/bubbletea/v2" "superwork-tui/internal/issue" ) // Phase identifies which tmux window is being managed. type Phase string const ( PhaseBrain Phase = "brain" PhaseImpl Phase = "impl" PhaseReview Phase = "review" PhaseTest Phase = "test" ) // windowName returns the tmux window name for a given issue and phase. func windowName(issueNumber int, phase Phase) string { return fmt.Sprintf("%d-%s", issueNumber, phase) } // resolveSessionName returns the current tmux session name when running inside // tmux, or "superwork" otherwise. func resolveSessionName() string { if os.Getenv("TMUX") == "" { return "superwork" } out, err := exec.Command("tmux", "display-message", "-p", "#S").Output() if err != nil { return "superwork" } name := strings.TrimRight(string(out), "\n") if name == "" { return "superwork" } return name } // TmuxRunner is a function that executes a tmux subcommand and returns its output. // It is injectable for tests. type TmuxRunner func(args ...string) ([]byte, error) // SessionManager orchestrates tmux sessions for superwork issues. // It owns its own TmuxRunner so tests can inject a recorder without touching // the tmux package's global runner. type SessionManager struct { run TmuxRunner } // NewSessionManager returns a SessionManager backed by the given runner. func NewSessionManager(runner TmuxRunner) *SessionManager { return &SessionManager{run: runner} } // defaultTmuxRunner is the production runner used when no test runner is injected. func defaultTmuxRunner(args ...string) ([]byte, error) { out, err := exec.Command("tmux", args...).CombinedOutput() if err != nil { return nil, fmt.Errorf("tmux %s: %w\n%s", args[0], err, out) } return out, nil } // ensureSession creates the named session if it does not already exist. func (s *SessionManager) ensureSession(name string) error { _, err := s.run("has-session", "-t", "="+name) if err == nil { return nil // already exists } _, err = s.run("new-session", "-d", "-s", name) return err } // windowExists reports whether the given window name exists in the session. func (s *SessionManager) windowExists(session, window string) (bool, error) { out, err := s.run("list-windows", "-t", session, "-F", "#{window_name}") if err != nil { return false, err } raw := strings.TrimRight(string(out), "\n") if raw == "" { return false, nil } for _, w := range strings.Split(raw, "\n") { if w == window { return true, nil } } return false, nil } // OpenOrFocus ensures the session exists, then focuses the window for the given // phase+issue — creating it (with cwd and command) if it does not yet exist. func (s *SessionManager) OpenOrFocus(_ context.Context, phase Phase, iss issue.Issue, cwd, command string) error { session := resolveSessionName() win := windowName(iss.Number, phase) if err := s.ensureSession(session); err != nil { return fmt.Errorf("ensure session: %w", err) } exists, err := s.windowExists(session, win) if err != nil { return fmt.Errorf("check window: %w", err) } if !exists { args := []string{"new-window", "-t", session + ":", "-n", win, "-c", cwd} if command != "" { args = append(args, command) } if _, err := s.run(args...); err != nil { return fmt.Errorf("new window: %w", err) } } if _, err := s.run("select-window", "-t", session+":"+win); err != nil { return fmt.Errorf("select window: %w", err) } return nil } // Inject sends text (followed by Enter) to the issue's phase window. func (s *SessionManager) Inject(_ context.Context, phase Phase, iss issue.Issue, text string) error { session := resolveSessionName() win := windowName(iss.Number, phase) if _, err := s.run("send-keys", "-t", session+":"+win, "-l", "--", text); err != nil { return fmt.Errorf("send-keys literal: %w", err) } if _, err := s.run("send-keys", "-t", session+":"+win, "Enter"); err != nil { return fmt.Errorf("send-keys enter: %w", err) } return nil } // Close kills the tmux window for the given issue and phase. func (s *SessionManager) Close(_ context.Context, phase Phase, iss issue.Issue, session string) error { win := windowName(iss.Number, phase) if _, err := s.run("kill-window", "-t", session+":"+win); err != nil { return fmt.Errorf("kill window: %w", err) } return nil } // claudeCmdOpts configures the claudeCmd builder. type claudeCmdOpts struct { ResumeSessionID string ProfilePath string Prompt string EffortHigh bool SystemPromptCommand string } // claudeCmd builds a claude CLI command string from opts. // Flag order matches TypeScript: [--effort high] --dangerously-skip-permissions // [--settings ] [--system-prompt="$(cmd)"] (--resume | ) func claudeCmd(opts claudeCmdOpts) string { parts := []string{"claude"} if opts.EffortHigh { parts = append(parts, "--effort", "high") } parts = append(parts, "--dangerously-skip-permissions") if opts.ProfilePath != "" { parts = append(parts, "--settings", shellQuote(opts.ProfilePath)) } if opts.SystemPromptCommand != "" { parts = append(parts, `--system-prompt="$(`+opts.SystemPromptCommand+`)"`) } if opts.ResumeSessionID != "" { parts = append(parts, "--resume", shellQuote(opts.ResumeSessionID)) } else if opts.Prompt != "" { parts = append(parts, shellQuote(opts.Prompt)) } return strings.Join(parts, " ") } // claudeResumeCmd returns the CLI command to resume a Claude brainstorm session. func claudeResumeCmd(sessionID, profilePath, systemPromptCommand string) string { return claudeCmd(claudeCmdOpts{ ResumeSessionID: sessionID, ProfilePath: profilePath, SystemPromptCommand: systemPromptCommand, }) } // shellQuote wraps s in single quotes (escaping any embedded single quote) so a // path containing spaces or special characters survives the shell that tmux runs // the window command through. func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // tabAliveMsg carries the window list from a periodic tmux poll. type tabAliveMsg struct { windows []string } // tabAliveCmd returns a tea.Cmd that polls tmux every ~2s for live windows. func tabAliveCmd(sessionName string) tea.Cmd { return tea.Tick(2*time.Second, func(_ time.Time) tea.Msg { mgr := NewSessionManager(defaultTmuxRunner) wins, _ := mgr.listWindows(sessionName) return tabAliveMsg{windows: wins} }) } // listWindows returns window names for the given session. func (s *SessionManager) listWindows(session string) ([]string, error) { out, err := s.run("list-windows", "-t", session, "-F", "#{window_name}") if err != nil { return nil, err } raw := strings.TrimRight(string(out), "\n") if raw == "" { return nil, nil } return strings.Split(raw, "\n"), nil } // RunInWindow opens a named tmux window (or focuses it if it exists) and runs command in it. func (s *SessionManager) RunInWindow(ctx context.Context, winName, cwd, command string) error { session := resolveSessionName() if err := s.ensureSession(session); err != nil { return fmt.Errorf("ensure session: %w", err) } exists, err := s.windowExists(session, winName) if err != nil { return fmt.Errorf("check window: %w", err) } if exists { if _, err := s.run("select-window", "-t", session+":"+winName); err != nil { return fmt.Errorf("select window: %w", err) } return nil } args := []string{"new-window", "-t", session + ":", "-n", winName, "-c", cwd} if command != "" { args = append(args, command) } if _, err := s.run(args...); err != nil { return fmt.Errorf("new window: %w", err) } if _, err := s.run("select-window", "-t", session+":"+winName); err != nil { return fmt.Errorf("select window: %w", err) } return nil } // reconcileTabs sets the *TabOpen fields on each issue based on which tmux // windows are currently alive. func reconcileTabs(issues []issue.Issue, aliveWindows []string) []issue.Issue { alive := make(map[string]bool, len(aliveWindows)) for _, w := range aliveWindows { alive[w] = true } out := make([]issue.Issue, len(issues)) for i, iss := range issues { iss.BrainstormTabOpen = alive[windowName(iss.Number, PhaseBrain)] iss.ImplementTabOpen = alive[windowName(iss.Number, PhaseImpl)] iss.ReviewTabOpen = alive[windowName(iss.Number, PhaseReview)] iss.TestTabOpen = alive[windowName(iss.Number, PhaseTest)] out[i] = iss } return out } // sessionActionResultMsg carries the result of an async tmux operation. type sessionActionResultMsg struct { err error }