Files
superwork/tui/internal/tui/session.go
T
2026-06-23 05:02:15 +08:00

280 lines
8.4 KiB
Go

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 <profile>] [--system-prompt="$(cmd)"] (--resume <id> | <prompt>)
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
}