280 lines
8.4 KiB
Go
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
|
|
}
|