diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1740cab..b6d806a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,9 @@ concurrency:
cancel-in-progress: true
jobs:
- test:
+ # Code Quality Checks (formatting, linting, etc.)
+ quality:
+ name: Code Quality
runs-on: ubuntu-latest
steps:
@@ -36,25 +38,91 @@ jobs:
- name: Check formatting
run: |
if [ "$(gofmt -l .)" != "" ]; then
- echo "Code is not formatted. Run 'gofmt -w .' to fix."
+ echo "β Code is not formatted. Run 'go fmt ./...' to fix."
+ echo "Files that need formatting:"
gofmt -l .
exit 1
fi
+ echo "β
Code formatting is correct"
- name: Check go mod tidy
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
- echo "go.mod or go.sum is not tidy. Run 'go mod tidy' to fix."
+ echo "β go.mod or go.sum is not tidy. Run 'go mod tidy' to fix."
git diff go.mod go.sum
exit 1
fi
+ echo "β
go.mod and go.sum are tidy"
+
+ - name: Run linting with go vet
+ run: |
+ echo "π Running go vet..."
+ go vet ./...
+ echo "β
Linting passed"
+
+ # Build Check
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: "1.23"
+
+ - name: Cache Go modules
+ uses: actions/cache@v4
+ with:
+ path: ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-
+
+ - name: Download dependencies
+ run: go mod download
- name: Build
- run: go build -v ./...
+ run: |
+ echo "π¨ Building..."
+ go build -v ./...
+ echo "β
Build successful"
- - name: Run linting with go vet
- run: go vet ./...
+ # Tests
+ test:
+ name: Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: "1.23"
+
+ - name: Cache Go modules
+ uses: actions/cache@v4
+ with:
+ path: ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-
+
+ - name: Download dependencies
+ run: go mod download
- name: Run tests with coverage
- run: go test -v -coverprofile=coverage.out ./...
+ run: |
+ echo "π§ͺ Running tests..."
+ go test -v -coverprofile=coverage.out ./...
+ echo "β
All tests passed"
+
+ - name: Upload coverage reports
+ uses: codecov/codecov-action@v3
+ with:
+ file: ./coverage.out
+ fail_ci_if_error: false
diff --git a/coverage.html b/coverage.html
new file mode 100644
index 0000000..e7e0d0f
--- /dev/null
+++ b/coverage.html
@@ -0,0 +1,8109 @@
+
+
+
+
+
+
+
package cli
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+func newAttachCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "attach [session-name]",
+ Short: "Attach to a session's tmux session",
+ Long: `Attach to the tmux session for a CWT session.
+
+This is a convenience command that replaces the need to remember
+tmux session names (cwt-{session-name}).
+
+If session-name is not provided, you will be prompted to select
+from available sessions.`,
+ Aliases: []string{"a"},
+ Args: cobra.MaximumNArgs(1),
+ RunE: runAttachCmd,
+ }
+
+ return cmd
+}
+
+func runAttachCmd(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ // Get sessions
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ if len(sessions) == 0 {
+ fmt.Println("No sessions found.")
+ fmt.Println("Create a new session with: cwt new [session-name]")
+ return fmt.Errorf("no sessions available to attach to")
+ }
+
+ // Determine which session to attach to
+ var sessionToAttach *types.Session
+
+ if len(args) > 0 {
+ // Session name provided
+ sessionName := args[0]
+ for i := range sessions {
+ if sessions[i].Core.Name == sessionName {
+ sessionToAttach = &sessions[i]
+ break
+ }
+ }
+
+ if sessionToAttach == nil {
+ return fmt.Errorf("session '%s' not found", sessionName)
+ }
+ } else {
+ // Interactive selection
+ selected, err := promptForAttachSelection(sessions)
+ if err != nil {
+ return err
+ }
+ sessionToAttach = selected
+ }
+
+ // Check if tmux session is alive
+ if !sessionToAttach.IsAlive {
+ fmt.Printf("β οΈ Tmux session for '%s' is not running.\n", sessionToAttach.Core.Name)
+ fmt.Printf("This might happen if:\n")
+ fmt.Printf(" β’ The Claude Code process exited\n")
+ fmt.Printf(" β’ The tmux session was manually terminated\n")
+ fmt.Printf(" β’ There was a system restart\n\n")
+
+ // Ask user if they want to recreate the session
+ fmt.Printf("Do you want to recreate the tmux session? (y/N): ")
+ var response string
+ fmt.Scanln(&response)
+
+ if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" {
+ fmt.Println("Session not recreated.")
+ return fmt.Errorf("cannot attach to dead tmux session")
+ }
+
+ // Recreate the tmux session with Claude resumption
+ if err := recreateSessionWithClaudeResume(sm, sessionToAttach); err != nil {
+ return fmt.Errorf("failed to recreate session: %w", err)
+ }
+
+ fmt.Printf("β
Session '%s' recreated successfully\n", sessionToAttach.Core.Name)
+ }
+
+ // Attach to tmux session
+ fmt.Printf("π Attaching to session '%s' (tmux: %s)...\n",
+ sessionToAttach.Core.Name, sessionToAttach.Core.TmuxSession)
+
+ // Use exec to replace current process with tmux attach
+ tmuxPath, err := exec.LookPath("tmux")
+ if err != nil {
+ return fmt.Errorf("tmux not found in PATH: %w", err)
+ }
+
+ args = []string{"tmux", "attach-session", "-t", sessionToAttach.Core.TmuxSession}
+ err = syscall.Exec(tmuxPath, args, os.Environ())
+ if err != nil {
+ return fmt.Errorf("failed to exec tmux: %w", err)
+ }
+
+ // This point should never be reached if exec succeeds
+ return nil
+}
+
+func promptForAttachSelection(sessions []types.Session) (*types.Session, error) {
+ fmt.Println("Multiple sessions found. Select one to attach to:")
+
+ // Filter to only show alive sessions
+ aliveSessions := make([]types.Session, 0)
+ deadSessions := make([]types.Session, 0)
+
+ for _, session := range sessions {
+ if session.IsAlive {
+ aliveSessions = append(aliveSessions, session)
+ } else {
+ deadSessions = append(deadSessions, session)
+ }
+ }
+
+ if len(aliveSessions) == 0 {
+ fmt.Println("β No active tmux sessions found.")
+ if len(deadSessions) > 0 {
+ fmt.Printf("Found %d stale session(s). Run 'cwt cleanup' to remove them.\n", len(deadSessions))
+ }
+ return nil, fmt.Errorf("no active sessions to attach to")
+ }
+
+ if len(deadSessions) > 0 {
+ fmt.Printf("Found %d stale session(s). Run 'cwt cleanup' to remove them.\n", len(deadSessions))
+ }
+
+ // Use interactive selector for alive sessions
+ selectedSession, err := SelectSession(aliveSessions, WithTitle("Select a session to attach to:"))
+ if err != nil {
+ return nil, fmt.Errorf("failed to select session: %w", err)
+ }
+
+ if selectedSession == nil {
+ fmt.Println("Cancelled")
+ return nil, nil
+ }
+
+ return selectedSession, nil
+}
+
+// recreateSessionWithClaudeResume recreates a dead tmux session and resumes Claude if possible
+func recreateSessionWithClaudeResume(sm *state.Manager, session *types.Session) error {
+ // Find Claude executable
+ claudeExec := findClaudeExecutable()
+ if claudeExec == "" {
+ return fmt.Errorf("claude executable not found")
+ }
+
+ // Check if there's an existing Claude session to resume for this worktree
+ var command string
+ if existingSessionID, err := sm.GetClaudeChecker().FindSessionID(session.Core.WorktreePath); err == nil && existingSessionID != "" {
+ command = fmt.Sprintf("%s -r %s", claudeExec, existingSessionID)
+ fmt.Printf("π Resuming Claude session %s\n", existingSessionID)
+ } else {
+ command = claudeExec
+ fmt.Printf("π Starting new Claude session\n")
+ }
+
+ // Recreate the tmux session
+ tmuxChecker := sm.GetTmuxChecker()
+ if err := tmuxChecker.CreateSession(session.Core.TmuxSession, session.Core.WorktreePath, command); err != nil {
+ return fmt.Errorf("failed to recreate tmux session: %w", err)
+ }
+
+ return nil
+}
+
+// findClaudeExecutable searches for claude in common installation paths
+func findClaudeExecutable() string {
+ claudePaths := []string{
+ "claude",
+ os.ExpandEnv("$HOME/.claude/local/claude"),
+ os.ExpandEnv("$HOME/.claude/local/node_modules/.bin/claude"),
+ "/usr/local/bin/claude",
+ }
+
+ for _, path := range claudePaths {
+ cmd := exec.Command(path, "--version")
+ if err := cmd.Run(); err == nil {
+ return path
+ }
+ }
+
+ return ""
+}
+
+
+
package cli
+
+import (
+ "fmt"
+
+ "github.com/jlaneve/cwt-cli/internal/operations"
+ "github.com/spf13/cobra"
+)
+
+func newCleanupCmd() *cobra.Command {
+ var dryRun bool
+
+ cmd := &cobra.Command{
+ Use: "cleanup",
+ Short: "Remove orphaned sessions and resources",
+ Long: `Clean up orphaned CWT resources:
+- Sessions with dead tmux sessions
+- Unused git worktrees
+- Stale session metadata
+
+This helps maintain a clean state after crashes or manual tmux session termination.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runCleanupCmd(dryRun)
+ },
+ }
+
+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be cleaned up without actually doing it")
+
+ return cmd
+}
+
+func runCleanupCmd(dryRun bool) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ fmt.Println("π Scanning for orphaned resources...")
+
+ // Use operations layer for cleanup
+ cleanupOps := operations.NewCleanupOperations(sm)
+ stats, err := cleanupOps.FindAndCleanupStaleResources(dryRun)
+ if err != nil {
+ return fmt.Errorf("cleanup failed: %w", err)
+ }
+
+ // Show what was found
+ totalOrphans := stats.StaleSessions + stats.OrphanedTmux + stats.OrphanedWorktrees
+ if totalOrphans == 0 {
+ fmt.Println("β
No orphaned resources found. Everything looks clean!")
+ return nil
+ }
+
+ fmt.Printf("\nFound orphaned resources:\n")
+ if stats.StaleSessions > 0 {
+ fmt.Printf(" π %d stale session(s) with dead tmux\n", stats.StaleSessions)
+ }
+ if stats.OrphanedTmux > 0 {
+ fmt.Printf(" π§ %d orphaned tmux session(s)\n", stats.OrphanedTmux)
+ }
+ if stats.OrphanedWorktrees > 0 {
+ fmt.Printf(" π³ %d orphaned git worktree(s)\n", stats.OrphanedWorktrees)
+ }
+ fmt.Println()
+
+ if dryRun {
+ fmt.Println("π Dry run mode - no changes made.")
+ fmt.Printf("Run 'cwt cleanup' to actually clean up these %d resource(s).\n", totalOrphans)
+ return nil
+ }
+
+ // Show cleanup results
+ fmt.Printf("π§Ή Cleanup complete!\n")
+ fmt.Printf(" β
Cleaned: %d\n", stats.Cleaned)
+ if stats.Failed > 0 {
+ fmt.Printf(" β Failed: %d\n", stats.Failed)
+ for _, errMsg := range stats.Errors {
+ fmt.Printf(" - %s\n", errMsg)
+ }
+ }
+
+ return nil
+}
+
+
package cli
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/operations"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+func newDeleteCmd() *cobra.Command {
+ var force bool
+
+ cmd := &cobra.Command{
+ Use: "delete [session-name]",
+ Short: "Delete a session and clean up its resources",
+ Long: `Delete a CWT session, removing:
+- Tmux session
+- Git worktree
+- Session metadata
+
+This operation cannot be undone.`,
+ Aliases: []string{"del", "rm"},
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runDeleteCmd(args, force)
+ },
+ }
+
+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
+
+ return cmd
+}
+
+func runDeleteCmd(args []string, force bool) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ // Create operations layer
+ sessionOps := operations.NewSessionOperations(sm)
+
+ // Get sessions
+ sessions, err := sessionOps.GetAllSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ if len(sessions) == 0 {
+ fmt.Println("No sessions found to delete.")
+ return fmt.Errorf("no sessions available to delete")
+ }
+
+ // Determine which session to delete
+ var sessionToDelete *string
+ var sessionID string
+
+ if len(args) > 0 {
+ // Session name provided - use operations layer
+ sessionName := args[0]
+ session, id, err := sessionOps.FindSessionByName(sessionName)
+ if err != nil {
+ return err
+ }
+ sessionToDelete = &session.Core.Name
+ sessionID = id
+ } else {
+ // Interactive selection
+ sessionName, id, err := promptForSessionSelection(sessions)
+ if err != nil {
+ return err
+ }
+ sessionToDelete = &sessionName
+ sessionID = id
+ }
+
+ // Confirm deletion unless forced
+ if !force {
+ if !confirmDeletion(*sessionToDelete) {
+ fmt.Println("Deletion cancelled.")
+ return nil
+ }
+ }
+
+ // Delete session using operations layer
+ fmt.Printf("Deleting session '%s'...\n", *sessionToDelete)
+
+ if err := sessionOps.DeleteSession(sessionID); err != nil {
+ return fmt.Errorf("failed to delete session: %w", err)
+ }
+
+ fmt.Printf("β
Session '%s' deleted successfully!\n", *sessionToDelete)
+
+ return nil
+}
+
+func promptForSessionSelection(sessions []types.Session) (string, string, error) {
+ if len(sessions) == 1 {
+ return sessions[0].Core.Name, sessions[0].Core.ID, nil
+ }
+
+ fmt.Println("Multiple sessions found. Select one to delete:")
+ for i, session := range sessions {
+ status := "π΄ dead"
+ if session.IsAlive {
+ status = "π’ alive"
+ }
+ fmt.Printf(" %d. %s (%s)\n", i+1, session.Core.Name, status)
+ }
+
+ reader := bufio.NewReader(os.Stdin)
+
+ for {
+ fmt.Print("Enter selection (1-" + fmt.Sprintf("%d", len(sessions)) + "): ")
+ input, err := reader.ReadString('\n')
+ if err != nil {
+ return "", "", err
+ }
+
+ var selection int
+ if _, err := fmt.Sscanf(strings.TrimSpace(input), "%d", &selection); err != nil {
+ fmt.Println("Invalid input. Please enter a number.")
+ continue
+ }
+
+ if selection < 1 || selection > len(sessions) {
+ fmt.Printf("Invalid selection. Please enter a number between 1 and %d.\n", len(sessions))
+ continue
+ }
+
+ selectedSession := sessions[selection-1]
+ return selectedSession.Core.Name, selectedSession.Core.ID, nil
+ }
+}
+
+func confirmDeletion(sessionName string) bool {
+ reader := bufio.NewReader(os.Stdin)
+
+ fmt.Printf("Are you sure you want to delete session '%s'? This cannot be undone. (y/N): ", sessionName)
+ input, err := reader.ReadString('\n')
+ if err != nil {
+ return false
+ }
+
+ response := strings.ToLower(strings.TrimSpace(input))
+ return response == "y" || response == "yes"
+}
+
+
+
package cli
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// newDiffCmd creates the 'cwt diff' command
+func newDiffCmd() *cobra.Command {
+ var against string
+ var web bool
+ var stat bool
+ var name bool
+ var cached bool
+
+ cmd := &cobra.Command{
+ Use: "diff [session-name]",
+ Short: "Show detailed diff for session changes",
+ Long: `Show comprehensive diff view of changes in a session with rich formatting.
+
+Examples:
+ cwt diff my-session # Show full diff for session
+ cwt diff my-session --stat # Show diff statistics only
+ cwt diff my-session --against main # Compare against specific branch
+ cwt diff my-session --web # Open diff in external viewer
+ cwt diff my-session --cached # Show staged changes only
+ cwt diff # Interactive session selector`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ if len(args) == 0 {
+ return interactiveDiff(sm, against, web, stat, name, cached)
+ }
+
+ sessionName := args[0]
+ return showSessionDiff(sm, sessionName, against, web, stat, name, cached)
+ },
+ }
+
+ cmd.Flags().StringVar(&against, "against", "", "Compare against specific branch (default: base branch)")
+ cmd.Flags().BoolVar(&web, "web", false, "Open diff in external viewer")
+ cmd.Flags().BoolVar(&stat, "stat", false, "Show diff statistics only")
+ cmd.Flags().BoolVar(&name, "name-only", false, "Show only file names")
+ cmd.Flags().BoolVar(&cached, "cached", false, "Show staged changes only")
+
+ return cmd
+}
+
+// showSessionDiff displays the diff for a specific session
+func showSessionDiff(sm *state.Manager, sessionName, against string, web, stat, nameOnly, cached bool) error {
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ // Find the session
+ var targetSession *types.Session
+ for _, session := range sessions {
+ if session.Core.Name == sessionName {
+ targetSession = &session
+ break
+ }
+ }
+
+ if targetSession == nil {
+ return fmt.Errorf("session '%s' not found", sessionName)
+ }
+
+ return renderSessionDiff(*targetSession, against, web, stat, nameOnly, cached)
+}
+
+// interactiveDiff provides an interactive session selector for diff
+func interactiveDiff(sm *state.Manager, against string, web, stat, nameOnly, cached bool) error {
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ if len(sessions) == 0 {
+ fmt.Println("No sessions available for diff")
+ return nil
+ }
+
+ // Use selector with filter for sessions with changes
+ selectedSession, err := SelectSession(sessions,
+ WithTitle("Select a session to view diff:"),
+ WithSessionFilter(func(session types.Session) bool {
+ return session.GitStatus.HasChanges
+ }))
+
+ if err != nil {
+ return fmt.Errorf("failed to select session: %w", err)
+ }
+
+ if selectedSession == nil {
+ fmt.Println("Cancelled")
+ return nil
+ }
+
+ return renderSessionDiff(*selectedSession, against, web, stat, nameOnly, cached)
+}
+
+// renderSessionDiff renders the diff for a session
+func renderSessionDiff(session types.Session, against string, web, stat, nameOnly, cached bool) error {
+ // Change to session worktree directory
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return fmt.Errorf("failed to get current directory: %w", err)
+ }
+ defer os.Chdir(originalDir)
+
+ if err := os.Chdir(session.Core.WorktreePath); err != nil {
+ return fmt.Errorf("failed to change to worktree directory: %w", err)
+ }
+
+ // Determine comparison target
+ target := against
+ if target == "" {
+ target = "main" // Default base branch
+ }
+
+ // Open in external viewer if requested
+ if web {
+ return openDiffInExternalViewer(target, cached)
+ }
+
+ // Show diff header
+ fmt.Printf("π Diff for session: %s\n", session.Core.Name)
+ fmt.Printf("π Path: %s\n", session.Core.WorktreePath)
+
+ if cached {
+ fmt.Printf("π Comparing: staged changes\n")
+ } else {
+ fmt.Printf("π Comparing: working tree vs %s\n", target)
+ }
+
+ fmt.Println(strings.Repeat("=", 70))
+
+ // Show summary stats first
+ if err := showDiffStats(target, cached); err != nil {
+ fmt.Printf("Warning: failed to show diff stats: %v\n", err)
+ }
+
+ if stat {
+ return nil // Only show stats
+ }
+
+ fmt.Println(strings.Repeat("-", 70))
+
+ // Show file names only if requested
+ if nameOnly {
+ return showDiffFileNames(target, cached)
+ }
+
+ // Show full diff with syntax highlighting
+ return showFullDiff(target, cached)
+}
+
+// showDiffStats shows diff statistics
+func showDiffStats(target string, cached bool) error {
+ var cmd *exec.Cmd
+
+ if cached {
+ cmd = exec.Command("git", "diff", "--cached", "--stat")
+ } else {
+ cmd = exec.Command("git", "diff", target, "--stat")
+ }
+
+ output, err := cmd.Output()
+ if err != nil {
+ return err
+ }
+
+ if len(output) > 0 {
+ fmt.Printf("π Change Statistics:\n")
+ fmt.Print(string(output))
+ } else {
+ fmt.Println("π No changes found")
+ }
+
+ return nil
+}
+
+// showDiffFileNames shows only the names of changed files
+func showDiffFileNames(target string, cached bool) error {
+ var cmd *exec.Cmd
+
+ if cached {
+ cmd = exec.Command("git", "diff", "--cached", "--name-status")
+ } else {
+ cmd = exec.Command("git", "diff", target, "--name-status")
+ }
+
+ output, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("failed to get file names: %w", err)
+ }
+
+ if len(output) == 0 {
+ fmt.Println("No files changed")
+ return nil
+ }
+
+ fmt.Println("π Changed Files:")
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+
+ parts := strings.SplitN(line, "\t", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ status := parts[0]
+ filename := parts[1]
+
+ icon := getFileStatusIcon(status)
+ fmt.Printf(" %s %s %s\n", icon, status, filename)
+ }
+
+ return nil
+}
+
+// showFullDiff shows the complete diff with syntax highlighting
+func showFullDiff(target string, cached bool) error {
+ var cmd *exec.Cmd
+
+ if cached {
+ cmd = exec.Command("git", "diff", "--cached", "--color=always")
+ } else {
+ cmd = exec.Command("git", "diff", target, "--color=always")
+ }
+
+ // Try to use a pager if available (less, more, etc.)
+ if isInteractiveTerminal() {
+ if pager := getPager(); pager != "" {
+ return runDiffWithPager(cmd, pager)
+ }
+ }
+
+ // Fallback to direct output
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to show diff: %w", err)
+ }
+
+ return nil
+}
+
+// openDiffInExternalViewer opens the diff in an external application
+func openDiffInExternalViewer(target string, cached bool) error {
+ // Try different diff viewers in order of preference
+ viewers := []string{
+ "code --diff", // VSCode
+ "subl --wait", // Sublime Text
+ "mate -w", // TextMate
+ "vim -d", // Vim
+ }
+
+ for _, viewer := range viewers {
+ if cmd := strings.Fields(viewer); len(cmd) > 0 {
+ if _, err := exec.LookPath(cmd[0]); err == nil {
+ return openWithViewer(viewer, target, cached)
+ }
+ }
+ }
+
+ // Fallback to system default
+ return openWithSystemDefault(target, cached)
+}
+
+// openWithViewer opens diff with a specific viewer
+func openWithViewer(viewer, target string, cached bool) error {
+ // For now, just show the diff in terminal with a message
+ fmt.Printf("π§ External viewer integration not yet implemented\n")
+ fmt.Printf("π Preferred viewer: %s\n", viewer)
+ fmt.Println("π Falling back to terminal diff:")
+ fmt.Println(strings.Repeat("-", 50))
+
+ return showFullDiff(target, cached)
+}
+
+// openWithSystemDefault opens diff with system default application
+func openWithSystemDefault(target string, cached bool) error {
+ fmt.Println("π§ System default diff viewer not yet implemented")
+ fmt.Println("π Falling back to terminal diff:")
+ fmt.Println(strings.Repeat("-", 50))
+
+ return showFullDiff(target, cached)
+}
+
+// Helper functions
+
+func getFileStatusIcon(status string) string {
+ switch status {
+ case "A":
+ return "β"
+ case "M":
+ return "π"
+ case "D":
+ return "β"
+ case "R":
+ return "π"
+ case "C":
+ return "π"
+ default:
+ return "β"
+ }
+}
+
+func isInteractiveTerminal() bool {
+ // Simple check for interactive terminal
+ if os.Getenv("TERM") == "" {
+ return false
+ }
+
+ // Check if stdout is a terminal
+ if stat, err := os.Stdout.Stat(); err == nil {
+ return (stat.Mode() & os.ModeCharDevice) != 0
+ }
+
+ return false
+}
+
+func getPager() string {
+ // Check environment variables for pager preference
+ if pager := os.Getenv("GIT_PAGER"); pager != "" {
+ return pager
+ }
+
+ if pager := os.Getenv("PAGER"); pager != "" {
+ return pager
+ }
+
+ // Try common pagers
+ pagers := []string{"less", "more", "cat"}
+ for _, pager := range pagers {
+ if _, err := exec.LookPath(pager); err == nil {
+ if pager == "less" {
+ return "less -R" // Enable color support
+ }
+ return pager
+ }
+ }
+
+ return ""
+}
+
+func runDiffWithPager(cmd *exec.Cmd, pager string) error {
+ // Create a pipe from git diff to pager
+ pagerCmd := exec.Command("sh", "-c", pager)
+
+ pipe, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("failed to create pipe: %w", err)
+ }
+
+ pagerCmd.Stdin = pipe
+ pagerCmd.Stdout = os.Stdout
+ pagerCmd.Stderr = os.Stderr
+
+ if err := pagerCmd.Start(); err != nil {
+ return fmt.Errorf("failed to start pager: %w", err)
+ }
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("failed to start git diff: %w", err)
+ }
+
+ if err := cmd.Wait(); err != nil {
+ pipe.Close()
+ return fmt.Errorf("git diff failed: %w", err)
+ }
+
+ pipe.Close()
+
+ if err := pagerCmd.Wait(); err != nil {
+ return fmt.Errorf("pager failed: %w", err)
+ }
+
+ return nil
+}
+
+
+
package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+func newFixHooksCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "fix-hooks",
+ Short: "Fix broken hook paths in existing sessions",
+ Long: `Auto-detect and fix broken hook paths in Claude settings.json files.
+
+This command scans all existing sessions and updates any invalid executable
+paths in their Claude hook configurations. This is useful when:
+- Sessions were created with 'go run' and have temp executable paths
+- The cwt binary was moved or renamed
+- Hook paths are pointing to non-existent executables`,
+ RunE: runFixHooksCmd,
+ }
+
+ return cmd
+}
+
+func runFixHooksCmd(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ if len(sessions) == 0 {
+ fmt.Println("No sessions found.")
+ return nil
+ }
+
+ // Get the correct cwt executable path
+ correctPath := getCwtExecutablePath()
+
+ fixed := 0
+ for _, session := range sessions {
+ settingsPath := filepath.Join(session.Core.WorktreePath, "settings.json")
+
+ if updated, err := fixSettingsFile(settingsPath, session.Core.ID, correctPath); err != nil {
+ fmt.Printf("β οΈ Failed to fix hooks for session '%s': %v\n", session.Core.Name, err)
+ } else if updated {
+ fmt.Printf("β
Fixed hooks for session '%s'\n", session.Core.Name)
+ fixed++
+ }
+ }
+
+ if fixed == 0 {
+ fmt.Println("All session hooks are already correctly configured.")
+ } else {
+ fmt.Printf("\nπ Fixed hooks for %d session(s)\n", fixed)
+ }
+
+ return nil
+}
+
+// fixSettingsFile updates the settings.json file with correct hook paths
+func fixSettingsFile(settingsPath, sessionID, correctPath string) (bool, error) {
+ // Check if settings file exists
+ if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
+ return false, fmt.Errorf("settings.json not found")
+ }
+
+ // Read current settings
+ data, err := os.ReadFile(settingsPath)
+ if err != nil {
+ return false, fmt.Errorf("failed to read settings file: %w", err)
+ }
+
+ var settings map[string]interface{}
+ if err := json.Unmarshal(data, &settings); err != nil {
+ return false, fmt.Errorf("failed to parse settings JSON: %w", err)
+ }
+
+ // Check if hooks exist
+ hooks, ok := settings["hooks"].(map[string]interface{})
+ if !ok {
+ // No hooks section, create it
+ hooks = make(map[string]interface{})
+ settings["hooks"] = hooks
+ }
+
+ // Check if any hooks need updating
+ needsUpdate := false
+ expectedHooks := map[string]interface{}{
+ "Notification": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s notification", correctPath, sessionID),
+ },
+ },
+ },
+ },
+ "Stop": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s stop", correctPath, sessionID),
+ },
+ },
+ },
+ },
+ "PreToolUse": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s pre_tool_use", correctPath, sessionID),
+ },
+ },
+ },
+ },
+ "PostToolUse": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s post_tool_use", correctPath, sessionID),
+ },
+ },
+ },
+ },
+ "SubagentStop": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s subagent_stop", correctPath, sessionID),
+ },
+ },
+ },
+ },
+ "PreCompact": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s pre_compact", correctPath, sessionID),
+ },
+ },
+ },
+ },
+ }
+
+ for hookName, expectedHook := range expectedHooks {
+ currentHook, exists := hooks[hookName]
+ if !exists {
+ needsUpdate = true
+ hooks[hookName] = expectedHook
+ } else {
+ // Check if current hook matches expected structure
+ expectedJSON, _ := json.Marshal(expectedHook)
+ currentJSON, _ := json.Marshal(currentHook)
+ if string(expectedJSON) != string(currentJSON) {
+ needsUpdate = true
+ hooks[hookName] = expectedHook
+ }
+ }
+ }
+
+ if !needsUpdate {
+ return false, nil // No changes needed
+ }
+
+ // Write updated settings
+ updatedData, err := json.MarshalIndent(settings, "", " ")
+ if err != nil {
+ return false, fmt.Errorf("failed to marshal updated settings: %w", err)
+ }
+
+ if err := os.WriteFile(settingsPath, updatedData, 0644); err != nil {
+ return false, fmt.Errorf("failed to write updated settings: %w", err)
+ }
+
+ return true, nil
+}
+
+// getCwtExecutablePath duplicates the logic from state manager for consistency
+func getCwtExecutablePath() string {
+ // First, try to find cwt in PATH (most reliable for installed binaries)
+ if path, err := exec.LookPath("cwt"); err == nil {
+ return path
+ }
+
+ // Check if we're running from go run (has temp executable path)
+ if execPath, err := os.Executable(); err == nil {
+ // If it's a temp path from go run, use "go run cmd/cwt/main.go" instead
+ if strings.Contains(execPath, "go-build") || strings.Contains(execPath, "/tmp/") {
+ // Check if we're in the cwt project directory
+ if _, err := os.Stat("cmd/cwt/main.go"); err == nil {
+ return "go run cmd/cwt/main.go"
+ }
+ } else {
+ // It's a real executable path
+ return execPath
+ }
+ }
+
+ // Final fallback to "cwt" in PATH
+ return "cwt"
+}
+
+
+
package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// newHookCmd creates the hidden hook command for Claude Code integration
+func newHookCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "__hook [session-id] [event-type]",
+ Aliases: []string{"hook"}, // Add alias for troubleshooting
+ Hidden: true, // Don't show in help output
+ Short: "Internal hook handler for Claude Code events",
+ Long: `This is an internal command used by Claude Code hooks.
+It receives session events and updates session state files.
+
+This command is automatically configured when creating sessions
+and should not be called manually.`,
+ Args: cobra.MinimumNArgs(2),
+ RunE: runHookCmd,
+ }
+
+ return cmd
+}
+
+func runHookCmd(cmd *cobra.Command, args []string) error {
+ sessionID := args[0]
+ eventType := args[1]
+
+ // Debug: log what we received (comment out in production)
+ // fmt.Fprintf(os.Stderr, "Hook called with args: %v\n", args)
+
+ // Read hook data from stdin (Claude passes JSON data)
+ var eventData map[string]interface{}
+ if err := json.NewDecoder(os.Stdin).Decode(&eventData); err != nil {
+ // If no JSON data, use empty map
+ eventData = make(map[string]interface{})
+ }
+
+ // Extract message if present
+ var lastMessage string
+ if msg, ok := eventData["message"].(string); ok {
+ lastMessage = msg
+ }
+
+ // Create session state update
+ state := &types.SessionState{
+ SessionID: sessionID,
+ ClaudeState: types.ParseClaudeStateFromEvent(eventType, eventData),
+ LastEvent: eventType,
+ LastEventTime: time.Now(),
+ LastEventData: eventData,
+ LastMessage: lastMessage,
+ LastUpdated: time.Now(),
+ }
+
+ // Save session state (using .cwt as default data directory)
+ dataDir := ".cwt"
+ if err := types.SaveSessionState(dataDir, state); err != nil {
+ return fmt.Errorf("failed to save session state: %w", err)
+ }
+
+ return nil
+}
+
+
+
package cli
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/mattn/go-runewidth"
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+func newListCmd() *cobra.Command {
+ var verbose bool
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List all sessions with their current status",
+ Long: `List all CWT sessions with derived status from:
+- Tmux session alive status
+- Git working tree changes
+- Claude activity and availability
+
+Status is derived fresh from external systems for accuracy.`,
+ Aliases: []string{"ls"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runListCmd(verbose)
+ },
+ }
+
+ cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed information")
+
+ return cmd
+}
+
+func runListCmd(verbose bool) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ // Derive fresh sessions
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ if len(sessions) == 0 {
+ fmt.Println("No sessions found.")
+ fmt.Println("\nCreate a new session with: cwt new [session-name] [task-description]")
+ return nil
+ }
+
+ // Sort sessions by creation time (newest first)
+ sort.Slice(sessions, func(i, j int) bool {
+ return sessions[i].Core.CreatedAt.After(sessions[j].Core.CreatedAt)
+ })
+
+ if verbose {
+ renderVerboseSessionList(sessions)
+ } else {
+ renderCompactSessionList(sessions)
+ }
+
+ return nil
+}
+
+func renderCompactSessionList(sessions []types.Session) {
+ fmt.Printf("Found %d session(s):\n\n", len(sessions))
+
+ // Calculate max widths for each column based on content
+ maxNameLen := 4 // "NAME"
+ maxTmuxLen := 4 // "TMUX"
+ maxClaudeLen := 6 // "CLAUDE"
+ maxGitLen := 3 // "GIT"
+ maxActivityLen := 8 // "ACTIVITY"
+
+ // Pre-format all data to calculate actual widths
+ type rowData struct {
+ name string
+ tmux string
+ claude string
+ git string
+ activity string
+ }
+
+ rows := make([]rowData, len(sessions))
+ for i, session := range sessions {
+ rows[i] = rowData{
+ name: truncate(session.Core.Name, 30),
+ tmux: formatTmuxStatus(session.IsAlive),
+ claude: formatClaudeStatus(session.ClaudeStatus),
+ git: formatGitStatus(session.GitStatus),
+ activity: FormatActivity(session.LastActivity),
+ }
+
+ // Update max lengths (using visual length)
+ if l := visualLength(rows[i].name); l > maxNameLen {
+ maxNameLen = l
+ }
+ if l := visualLength(rows[i].tmux); l > maxTmuxLen {
+ maxTmuxLen = l
+ }
+ if l := visualLength(rows[i].claude); l > maxClaudeLen {
+ maxClaudeLen = l
+ }
+ if l := visualLength(rows[i].git); l > maxGitLen {
+ maxGitLen = l
+ }
+ if l := visualLength(rows[i].activity); l > maxActivityLen {
+ maxActivityLen = l
+ }
+ }
+
+ // Add padding
+ maxNameLen += 2
+ maxTmuxLen += 2
+ maxClaudeLen += 2
+ maxGitLen += 2
+ maxActivityLen += 2
+
+ // Print header
+ fmt.Printf("%s %s %s %s %s\n",
+ padRight("NAME", maxNameLen),
+ padRight("TMUX", maxTmuxLen),
+ padRight("CLAUDE", maxClaudeLen),
+ padRight("GIT", maxGitLen),
+ padRight("ACTIVITY", maxActivityLen))
+
+ fmt.Printf("%s %s %s %s %s\n",
+ strings.Repeat("-", maxNameLen),
+ strings.Repeat("-", maxTmuxLen),
+ strings.Repeat("-", maxClaudeLen),
+ strings.Repeat("-", maxGitLen),
+ strings.Repeat("-", maxActivityLen))
+
+ // Print rows
+ for _, row := range rows {
+ fmt.Printf("%s %s %s %s %s\n",
+ padRight(row.name, maxNameLen),
+ padRight(row.tmux, maxTmuxLen),
+ padRight(row.claude, maxClaudeLen),
+ padRight(row.git, maxGitLen),
+ padRight(row.activity, maxActivityLen))
+ }
+}
+
+func renderVerboseSessionList(sessions []types.Session) {
+ fmt.Printf("Found %d session(s):\n\n", len(sessions))
+
+ for i, session := range sessions {
+ if i > 0 {
+ fmt.Println()
+ }
+
+ fmt.Printf("π·οΈ %s\n", session.Core.Name)
+ fmt.Printf(" ID: %s\n", session.Core.ID)
+ fmt.Printf(" Created: %s\n", session.Core.CreatedAt.Format("2006-01-02 15:04:05"))
+ fmt.Printf(" Worktree: %s\n", session.Core.WorktreePath)
+ fmt.Printf(" \n")
+
+ // Tmux status
+ fmt.Printf(" π₯οΈ Tmux: %s (session: %s)\n",
+ formatTmuxStatus(session.IsAlive), session.Core.TmuxSession)
+
+ // Git status
+ gitDetails := ""
+ if session.GitStatus.HasChanges {
+ changes := []string{}
+ if len(session.GitStatus.ModifiedFiles) > 0 {
+ changes = append(changes, fmt.Sprintf("%d modified", len(session.GitStatus.ModifiedFiles)))
+ }
+ if len(session.GitStatus.AddedFiles) > 0 {
+ changes = append(changes, fmt.Sprintf("%d added", len(session.GitStatus.AddedFiles)))
+ }
+ if len(session.GitStatus.DeletedFiles) > 0 {
+ changes = append(changes, fmt.Sprintf("%d deleted", len(session.GitStatus.DeletedFiles)))
+ }
+ gitDetails = fmt.Sprintf(" (%s)", strings.Join(changes, ", "))
+ }
+ fmt.Printf(" π Git: %s%s\n", formatGitStatus(session.GitStatus), gitDetails)
+
+ // Claude status
+ claudeDetails := ""
+ if session.ClaudeStatus.SessionID != "" {
+ claudeDetails = fmt.Sprintf(" (session: %s)", session.ClaudeStatus.SessionID)
+ }
+ if !session.ClaudeStatus.LastMessage.IsZero() {
+ age := time.Since(session.ClaudeStatus.LastMessage)
+ claudeDetails += fmt.Sprintf(" (last: %s ago)", FormatDuration(age))
+ }
+ fmt.Printf(" π€ Claude: %s%s\n", formatClaudeStatusShort(session.ClaudeStatus), claudeDetails)
+
+ // Show full message in verbose mode if available
+ if session.ClaudeStatus.StatusMessage != "" {
+ fmt.Printf(" Message: %s\n", session.ClaudeStatus.StatusMessage)
+ }
+
+ // Last activity
+ fmt.Printf(" β° Activity: %s\n", FormatActivity(session.LastActivity))
+ }
+}
+
+func formatTmuxStatus(isAlive bool) string {
+ if isAlive {
+ return "π’ alive"
+ }
+ return "π΄ dead"
+}
+
+func formatClaudeStatusShort(status types.ClaudeStatus) string {
+ switch status.State {
+ case types.ClaudeWorking:
+ return "π working"
+ case types.ClaudeWaiting:
+ return "βΈοΈ waiting"
+ case types.ClaudeComplete:
+ return "β
complete"
+ case types.ClaudeIdle:
+ return "π€ idle"
+ default:
+ return "β unknown"
+ }
+}
+
+func formatClaudeStatus(status types.ClaudeStatus) string {
+ switch status.State {
+ case types.ClaudeWorking:
+ return "π working"
+ case types.ClaudeWaiting:
+ if status.StatusMessage != "" {
+ // Truncate message for compact view
+ msg := status.StatusMessage
+ if len(msg) > 30 {
+ msg = msg[:27] + "..."
+ }
+ return "βΈοΈ " + msg
+ }
+ return "βΈοΈ waiting"
+ case types.ClaudeComplete:
+ return "β
complete"
+ case types.ClaudeIdle:
+ return "π€ idle"
+ default:
+ return "β unknown"
+ }
+}
+
+func formatGitStatus(status types.GitStatus) string {
+ if status.HasChanges {
+ // Calculate total changes
+ total := len(status.ModifiedFiles) + len(status.AddedFiles) +
+ len(status.DeletedFiles) + len(status.UntrackedFiles)
+
+ if total == 1 {
+ return "π 1 change"
+ }
+ return fmt.Sprintf("π %d changes", total)
+ }
+ return "β¨ clean"
+}
+
+func FormatActivity(lastActivity time.Time) string {
+ if lastActivity.IsZero() {
+ return "unknown"
+ }
+
+ age := time.Since(lastActivity)
+ if age < time.Minute {
+ return "just now"
+ }
+ return FormatDuration(age) + " ago"
+}
+
+func FormatDuration(d time.Duration) string {
+ if d < time.Minute {
+ return "just now"
+ }
+ if d < time.Hour {
+ minutes := int(d.Minutes())
+ return fmt.Sprintf("%dm", minutes)
+ }
+ if d < 24*time.Hour {
+ hours := int(d.Hours())
+ return fmt.Sprintf("%dh", hours)
+ }
+ days := int(d.Hours() / 24)
+ return fmt.Sprintf("%dd", days)
+}
+
+func truncate(s string, maxLen int) string {
+ if len(s) <= maxLen {
+ return s
+ }
+ if maxLen <= 3 {
+ return s[:maxLen]
+ }
+ return s[:maxLen-3] + "..."
+}
+
+// visualLength calculates the visual display width of a string using runewidth
+func visualLength(s string) int {
+ return runewidth.StringWidth(s)
+}
+
+// padRight pads a string to the specified visual width
+func padRight(s string, width int) string {
+ currentWidth := runewidth.StringWidth(s)
+ if currentWidth >= width {
+ return s
+ }
+ return s + strings.Repeat(" ", width-currentWidth)
+}
+
+
+
package cli
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// newMergeCmd creates the 'cwt merge' command
+func newMergeCmd() *cobra.Command {
+ var target string
+ var squash bool
+ var dryRun bool
+
+ cmd := &cobra.Command{
+ Use: "merge <session-name>",
+ Short: "Merge session changes back to target branch",
+ Long: `Safely integrate session changes back to target branches with conflict resolution.
+
+Examples:
+ cwt merge my-session # Interactive merge to current branch
+ cwt merge my-session --target main # Merge to specific target branch
+ cwt merge my-session --squash # Squash merge for clean history
+ cwt merge my-session --dry-run # Preview merge without executing`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ sessionName := args[0]
+ return mergeSession(sm, sessionName, target, squash, dryRun)
+ },
+ }
+
+ cmd.Flags().StringVar(&target, "target", "", "Target branch to merge into (default: current branch)")
+ cmd.Flags().BoolVar(&squash, "squash", false, "Squash merge for clean history")
+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview merge without executing")
+
+ return cmd
+}
+
+// mergeSession merges a session's changes into the target branch
+func mergeSession(sm *state.Manager, sessionName, target string, squash, dryRun bool) error {
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ // Find the session
+ var targetSession *types.Session
+ for _, session := range sessions {
+ if session.Core.Name == sessionName {
+ targetSession = &session
+ break
+ }
+ }
+
+ if targetSession == nil {
+ return fmt.Errorf("session '%s' not found", sessionName)
+ }
+
+ // Determine target branch
+ if target == "" {
+ currentBranch, err := getCurrentBranch()
+ if err != nil {
+ return fmt.Errorf("failed to get current branch: %w", err)
+ }
+ target = currentBranch
+ }
+
+ sessionBranch := fmt.Sprintf("cwt-%s", sessionName)
+
+ // Validate pre-merge conditions
+ if err := validateMergeConditions(target, sessionBranch); err != nil {
+ return err
+ }
+
+ // Show merge preview
+ if err := showMergePreview(sessionBranch, target); err != nil {
+ return fmt.Errorf("failed to show merge preview: %w", err)
+ }
+
+ if dryRun {
+ fmt.Println("\nDry run completed. No changes were made.")
+ return nil
+ }
+
+ // Confirm merge unless dry run
+ if !confirmMerge(sessionName, target, squash) {
+ fmt.Println("Merge cancelled")
+ return nil
+ }
+
+ // Perform the merge
+ if err := performMerge(sessionBranch, target, squash); err != nil {
+ return fmt.Errorf("merge failed: %w", err)
+ }
+
+ fmt.Printf("Successfully merged session '%s' into '%s'\n", sessionName, target)
+
+ // Update session status (this would require extending the Session type)
+ // For now, just print success message
+
+ return nil
+}
+
+// validateMergeConditions checks if merge can proceed safely
+func validateMergeConditions(targetBranch, sessionBranch string) error {
+ // Check if target branch exists
+ if !branchExists(targetBranch) {
+ return fmt.Errorf("target branch '%s' does not exist", targetBranch)
+ }
+
+ // Check if session branch exists
+ if !branchExists(sessionBranch) {
+ return fmt.Errorf("session branch '%s' does not exist", sessionBranch)
+ }
+
+ // Check for uncommitted changes in target branch
+ currentBranch, err := getCurrentBranch()
+ if err != nil {
+ return fmt.Errorf("failed to get current branch: %w", err)
+ }
+
+ if currentBranch == targetBranch && hasUncommittedChanges() {
+ return fmt.Errorf("target branch '%s' has uncommitted changes. Please commit or stash them first", targetBranch)
+ }
+
+ // Check if session branch is ahead of target
+ if !branchIsAhead(sessionBranch, targetBranch) {
+ return fmt.Errorf("session branch '%s' is not ahead of target branch '%s'", sessionBranch, targetBranch)
+ }
+
+ return nil
+}
+
+// showMergePreview displays what will be merged
+func showMergePreview(sessionBranch, targetBranch string) error {
+ fmt.Printf("Merge Preview: %s -> %s\n", sessionBranch, targetBranch)
+ fmt.Println(strings.Repeat("=", 50))
+
+ // Show commit summary
+ cmd := exec.Command("git", "log", "--oneline", fmt.Sprintf("%s..%s", targetBranch, sessionBranch))
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to show commit summary: %w", err)
+ }
+
+ fmt.Println(strings.Repeat("=", 50))
+
+ // Show file changes summary
+ cmd = exec.Command("git", "diff", "--stat", targetBranch, sessionBranch)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to show file changes: %w", err)
+ }
+
+ return nil
+}
+
+// confirmMerge asks user for confirmation
+func confirmMerge(sessionName, target string, squash bool) bool {
+ mergeType := "merge"
+ if squash {
+ mergeType = "squash merge"
+ }
+
+ fmt.Printf("\nProceed with %s of session '%s' into '%s'? (y/N): ", mergeType, sessionName, target)
+
+ var response string
+ fmt.Scanln(&response)
+
+ response = strings.ToLower(strings.TrimSpace(response))
+ return response == "y" || response == "yes"
+}
+
+// performMerge executes the actual merge
+func performMerge(sessionBranch, targetBranch string, squash bool) error {
+ // Switch to target branch first
+ if err := switchBranch(targetBranch); err != nil {
+ return fmt.Errorf("failed to switch to target branch '%s': %w", targetBranch, err)
+ }
+
+ // Prepare merge command
+ var cmd *exec.Cmd
+ if squash {
+ cmd = exec.Command("git", "merge", "--squash", sessionBranch)
+ } else {
+ cmd = exec.Command("git", "merge", "--no-ff", sessionBranch, "-m", fmt.Sprintf("Merge session branch %s", sessionBranch))
+ }
+
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ // If merge failed, try to provide helpful error message
+ if exitError, ok := err.(*exec.ExitError); ok {
+ if exitError.ExitCode() == 1 {
+ return fmt.Errorf("merge conflicts detected. Please resolve conflicts and run 'git commit' to complete the merge")
+ }
+ }
+ return fmt.Errorf("merge command failed: %w", err)
+ }
+
+ // If squash merge, we need to commit the changes
+ if squash {
+ commitMsg := fmt.Sprintf("Squash merge session %s", strings.TrimPrefix(sessionBranch, "cwt-"))
+ cmd = exec.Command("git", "commit", "-m", commitMsg)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to commit squash merge: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// Helper functions for git operations
+
+func branchExists(branch string) bool {
+ cmd := exec.Command("git", "rev-parse", "--verify", branch)
+ return cmd.Run() == nil
+}
+
+func branchIsAhead(sourceBranch, targetBranch string) bool {
+ // Check if source branch has commits that target doesn't have
+ cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", targetBranch, sourceBranch))
+ output, err := cmd.Output()
+ if err != nil {
+ return false
+ }
+
+ count := strings.TrimSpace(string(output))
+ return count != "0"
+}
+
+
+
package cli
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/operations"
+)
+
+func newNewCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "new [session-name]",
+ Short: "Create a new session with isolated git worktree and tmux session",
+ Long: `Create a new CWT session with:
+- Isolated git worktree in .cwt/worktrees/[session-name]
+- New tmux session running Claude Code
+- Session metadata persistence
+
+If session-name is not provided, you will be prompted interactively.`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: runNewCmd,
+ }
+
+ return cmd
+}
+
+func runNewCmd(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ // Get session name
+ var sessionName string
+ if len(args) > 0 {
+ sessionName = args[0]
+ } else {
+ sessionName, err = promptForSessionName()
+ if err != nil {
+ return err
+ }
+ }
+
+ // Create session using operations layer
+ fmt.Printf("Creating session '%s'...\n", sessionName)
+
+ sessionOps := operations.NewSessionOperations(sm)
+ if err := sessionOps.CreateSession(sessionName); err != nil {
+ return fmt.Errorf("failed to create session: %w", err)
+ }
+
+ // Success message
+ fmt.Printf("β
Session '%s' created successfully!\n\n", sessionName)
+ fmt.Printf("Next steps:\n")
+ fmt.Printf(" β’ View all sessions: cwt list\n")
+ fmt.Printf(" β’ Attach to session: cwt attach %s\n", sessionName)
+ fmt.Printf(" β’ Open TUI dashboard: cwt tui\n")
+ fmt.Printf(" β’ Work in isolated directory: cd %s/worktrees/%s\n", dataDir, sessionName)
+
+ return nil
+}
+
+func promptForSessionName() (string, error) {
+ reader := bufio.NewReader(os.Stdin)
+
+ for {
+ fmt.Print("Enter session name: ")
+ input, err := reader.ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+
+ sessionName := strings.TrimSpace(input)
+ if sessionName == "" {
+ fmt.Println("Session name cannot be empty. Please try again.")
+ continue
+ }
+
+ return sessionName, nil
+ }
+}
+
+
+
package cli
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// newPublishCmd creates the 'cwt publish' command
+func newPublishCmd() *cobra.Command {
+ var draft bool
+ var pr bool
+ var localOnly bool
+ var message string
+
+ cmd := &cobra.Command{
+ Use: "publish <session-name>",
+ Short: "Commit all session changes and publish the branch",
+ Long: `Commit all session changes and publish the branch for collaboration or backup.
+
+Examples:
+ cwt publish my-session # Commit all changes + push branch
+ cwt publish my-session --draft # Push as draft PR (if GitHub CLI available)
+ cwt publish my-session --pr # Create PR automatically
+ cwt publish my-session --local # Commit only, no push
+ cwt publish my-session -m "Custom commit message" # Use custom commit message`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ sessionName := args[0]
+ return publishSession(sm, sessionName, message, draft, pr, localOnly)
+ },
+ }
+
+ cmd.Flags().BoolVar(&draft, "draft", false, "Push as draft PR (requires GitHub CLI)")
+ cmd.Flags().BoolVar(&pr, "pr", false, "Create PR automatically (requires GitHub CLI)")
+ cmd.Flags().BoolVar(&localOnly, "local", false, "Commit only, no push")
+ cmd.Flags().StringVarP(&message, "message", "m", "", "Custom commit message")
+
+ return cmd
+}
+
+// publishSession commits and publishes a session's changes
+func publishSession(sm *state.Manager, sessionName, customMessage string, draft, pr, localOnly bool) error {
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ // Find the session
+ var targetSession *types.Session
+ for _, session := range sessions {
+ if session.Core.Name == sessionName {
+ targetSession = &session
+ break
+ }
+ }
+
+ if targetSession == nil {
+ return fmt.Errorf("session '%s' not found", sessionName)
+ }
+
+ if !targetSession.IsAlive {
+ fmt.Printf("Warning: Session '%s' is not currently active\n", sessionName)
+ }
+
+ worktreePath := targetSession.Core.WorktreePath
+ sessionBranch := fmt.Sprintf("cwt-%s", sessionName)
+
+ // Switch to the session's worktree directory
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return fmt.Errorf("failed to get current directory: %w", err)
+ }
+ defer os.Chdir(originalDir)
+
+ if err := os.Chdir(worktreePath); err != nil {
+ return fmt.Errorf("failed to change to worktree directory: %w", err)
+ }
+
+ // Check if there are changes to commit
+ if !hasChangesToCommit() {
+ fmt.Printf("No changes to commit in session '%s'\n", sessionName)
+ if !localOnly {
+ // Still try to push in case there are unpushed commits
+ return pushBranch(sessionBranch, draft, pr)
+ }
+ return nil
+ }
+
+ // Generate commit message
+ commitMessage := customMessage
+ if commitMessage == "" {
+ commitMessage = generateCommitMessage(sessionName, worktreePath)
+ }
+
+ // Stage and commit changes
+ if err := stageAndCommit(commitMessage); err != nil {
+ return fmt.Errorf("failed to commit changes: %w", err)
+ }
+
+ fmt.Printf("Committed changes in session '%s'\n", sessionName)
+
+ // Push if not local-only
+ if !localOnly {
+ if err := pushBranch(sessionBranch, draft, pr); err != nil {
+ return fmt.Errorf("failed to push branch: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// hasChangesToCommit checks if there are changes to commit
+func hasChangesToCommit() bool {
+ // Check for staged changes
+ cmd := exec.Command("git", "diff", "--cached", "--quiet")
+ if cmd.Run() != nil {
+ return true // Has staged changes
+ }
+
+ // Check for unstaged changes
+ cmd = exec.Command("git", "diff", "--quiet")
+ if cmd.Run() != nil {
+ return true // Has unstaged changes
+ }
+
+ // Check for untracked files
+ cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
+ output, err := cmd.Output()
+ if err == nil && len(strings.TrimSpace(string(output))) > 0 {
+ return true // Has untracked files
+ }
+
+ return false
+}
+
+// generateCommitMessage creates an intelligent commit message
+func generateCommitMessage(sessionName, worktreePath string) string {
+ // Try to read Claude's recent activity to understand what was done
+ claudeMessage := extractClaudeWorkSummary(worktreePath)
+ if claudeMessage != "" {
+ return fmt.Sprintf("feat(%s): %s\n\nπ€ Generated with Claude Code\n\nCo-Authored-By: Claude <noreply@anthropic.com>", sessionName, claudeMessage)
+ }
+
+ // Fallback to generic message with file analysis
+ changes := analyzeChanges()
+ if changes != "" {
+ return fmt.Sprintf("feat(%s): %s\n\nπ€ Generated with Claude Code\n\nCo-Authored-By: Claude <noreply@anthropic.com>", sessionName, changes)
+ }
+
+ // Final fallback
+ return fmt.Sprintf("feat(%s): Update session changes\n\nπ€ Generated with Claude Code\n\nCo-Authored-By: Claude <noreply@anthropic.com>", sessionName)
+}
+
+// extractClaudeWorkSummary tries to extract what Claude was working on
+func extractClaudeWorkSummary(worktreePath string) string {
+ // Look for Claude's session state or recent JSONL activity
+ sessionStateDir := filepath.Join(worktreePath, ".claude", "session_state")
+
+ // This is a simplified implementation - in a full version,
+ // you'd parse Claude's actual activity logs
+ if _, err := os.Stat(sessionStateDir); err == nil {
+ return "implement new features and improvements"
+ }
+
+ return ""
+}
+
+// analyzeChanges analyzes git diff to create a descriptive commit message
+func analyzeChanges() string {
+ // Get list of modified files
+ cmd := exec.Command("git", "diff", "--name-only", "HEAD")
+ output, err := cmd.Output()
+ if err != nil {
+ return "update files"
+ }
+
+ files := strings.Split(strings.TrimSpace(string(output)), "\n")
+ if len(files) == 0 || files[0] == "" {
+ // Check staged files
+ cmd = exec.Command("git", "diff", "--cached", "--name-only")
+ output, err = cmd.Output()
+ if err != nil {
+ return "update files"
+ }
+ files = strings.Split(strings.TrimSpace(string(output)), "\n")
+ }
+
+ if len(files) == 0 || files[0] == "" {
+ return "update files"
+ }
+
+ // Analyze file types and create descriptive message
+ var goFiles, jsFiles, pyFiles, otherFiles int
+
+ for _, file := range files {
+ if file == "" {
+ continue
+ }
+ ext := filepath.Ext(file)
+ switch ext {
+ case ".go":
+ goFiles++
+ case ".js", ".ts", ".jsx", ".tsx":
+ jsFiles++
+ case ".py":
+ pyFiles++
+ default:
+ otherFiles++
+ }
+ }
+
+ if len(files) == 1 {
+ return fmt.Sprintf("update %s", files[0])
+ }
+
+ var parts []string
+ if goFiles > 0 {
+ parts = append(parts, fmt.Sprintf("%d Go files", goFiles))
+ }
+ if jsFiles > 0 {
+ parts = append(parts, fmt.Sprintf("%d JS/TS files", jsFiles))
+ }
+ if pyFiles > 0 {
+ parts = append(parts, fmt.Sprintf("%d Python files", pyFiles))
+ }
+ if otherFiles > 0 {
+ parts = append(parts, fmt.Sprintf("%d other files", otherFiles))
+ }
+
+ if len(parts) > 0 {
+ return fmt.Sprintf("update %s", strings.Join(parts, ", "))
+ }
+
+ return fmt.Sprintf("update %d files", len(files))
+}
+
+// stageAndCommit stages all changes and commits them
+func stageAndCommit(message string) error {
+ // Add all changes (including untracked files)
+ cmd := exec.Command("git", "add", ".")
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to stage changes: %w", err)
+ }
+
+ // Commit changes
+ cmd = exec.Command("git", "commit", "-m", message)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to commit: %w", err)
+ }
+
+ return nil
+}
+
+// pushBranch pushes the branch and optionally creates PR
+func pushBranch(branch string, draft, pr bool) error {
+ // Check if remote exists
+ if !hasRemote() {
+ fmt.Println("No remote repository configured, skipping push")
+ return nil
+ }
+
+ // Push branch with upstream tracking
+ fmt.Printf("Pushing branch '%s'...\n", branch)
+ cmd := exec.Command("git", "push", "-u", "origin", branch)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to push branch: %w", err)
+ }
+
+ fmt.Printf("Successfully pushed branch '%s'\n", branch)
+
+ // Create PR if requested and GitHub CLI is available
+ if (draft || pr) && hasGitHubCLI() {
+ return createPullRequest(branch, draft)
+ } else if draft || pr {
+ fmt.Println("GitHub CLI not found, skipping PR creation")
+ fmt.Printf("You can manually create a PR for branch '%s'\n", branch)
+ }
+
+ return nil
+}
+
+// hasRemote checks if a remote repository is configured
+func hasRemote() bool {
+ cmd := exec.Command("git", "remote")
+ output, err := cmd.Output()
+ return err == nil && len(strings.TrimSpace(string(output))) > 0
+}
+
+// hasGitHubCLI checks if GitHub CLI is available
+func hasGitHubCLI() bool {
+ cmd := exec.Command("gh", "--version")
+ return cmd.Run() == nil
+}
+
+// createPullRequest creates a pull request using GitHub CLI
+func createPullRequest(branch string, draft bool) error {
+ sessionName := strings.TrimPrefix(branch, "cwt-")
+ title := fmt.Sprintf("feat(%s): Session changes", sessionName)
+
+ body := fmt.Sprintf(`## Summary
+Changes from CWT session: %s
+
+## Generated Context
+- Session branch: %s
+- Created: %s
+
+π€ Generated with [Claude Code](https://claude.ai/code)`,
+ sessionName,
+ branch,
+ time.Now().Format("2006-01-02 15:04:05"))
+
+ args := []string{"pr", "create", "--title", title, "--body", body}
+ if draft {
+ args = append(args, "--draft")
+ }
+
+ fmt.Printf("Creating pull request for branch '%s'...\n", branch)
+ cmd := exec.Command("gh", args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to create pull request: %w", err)
+ }
+
+ return nil
+}
+
+
+
package cli
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+)
+
+var (
+ dataDir string
+ baseBranch string
+)
+
+// NewRootCmd creates the root command for the CWT CLI
+func NewRootCmd() *cobra.Command {
+ rootCmd := &cobra.Command{
+ Use: "cwt",
+ Short: "Claude Worktree Tool - Manage multiple Claude Code sessions with isolated git worktrees",
+ Long: `CWT (Claude Worktree Tool) is a control plane for managing multiple Claude Code sessions
+with isolated git worktrees. Think of it as a project management system where you are
+the engineering manager and Claude Code sessions are your engineers working on isolated tasks.`,
+ SilenceUsage: true,
+ SilenceErrors: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // When no subcommand is provided, launch TUI
+ return runTuiCmd(cmd, args)
+ },
+ }
+
+ // Set custom help template
+ rootCmd.SetHelpTemplate(getCustomHelpTemplate())
+
+ // Global flags
+ rootCmd.PersistentFlags().StringVar(&dataDir, "data-dir", ".cwt", "Directory for storing session data")
+ rootCmd.PersistentFlags().StringVar(&baseBranch, "base-branch", "main", "Base branch for creating worktrees")
+
+ // Add subcommands with annotations for grouping
+
+ // Session Management
+ sessionMgmt := []*cobra.Command{
+ addAnnotation(newNewCmd(), "session-mgmt"),
+ addAnnotation(newAttachCmd(), "session-mgmt"),
+ addAnnotation(newDeleteCmd(), "session-mgmt"),
+ addAnnotation(newCleanupCmd(), "session-mgmt"),
+ }
+
+ // Session Workflow (Branch Lifecycle)
+ sessionWorkflow := []*cobra.Command{
+ addAnnotation(newSwitchCmd(), "session-workflow"),
+ addAnnotation(newMergeCmd(), "session-workflow"),
+ addAnnotation(newPublishCmd(), "session-workflow"),
+ }
+
+ // Information & Monitoring
+ info := []*cobra.Command{
+ addAnnotation(newListCmd(), "info"),
+ addAnnotation(newStatusCmd(), "info"),
+ addAnnotation(newDiffCmd(), "info"),
+ }
+
+ // Interface & Utilities
+ interface_utils := []*cobra.Command{
+ addAnnotation(newTuiCmd(), "interface"),
+ addAnnotation(newFixHooksCmd(), "interface"),
+ }
+
+ // Hidden/Internal commands (no annotation needed)
+ hidden := []*cobra.Command{
+ newHookCmd(), // Hidden internal command
+ }
+
+ // Add all commands
+ for _, cmd := range sessionMgmt {
+ rootCmd.AddCommand(cmd)
+ }
+ for _, cmd := range sessionWorkflow {
+ rootCmd.AddCommand(cmd)
+ }
+ for _, cmd := range info {
+ rootCmd.AddCommand(cmd)
+ }
+ for _, cmd := range interface_utils {
+ rootCmd.AddCommand(cmd)
+ }
+ for _, cmd := range hidden {
+ rootCmd.AddCommand(cmd)
+ }
+
+ return rootCmd
+}
+
+// createStateManager creates a StateManager with the current configuration
+func createStateManager() (*state.Manager, error) {
+ config := state.Config{
+ DataDir: dataDir,
+ BaseBranch: baseBranch,
+ // Use real checkers (default behavior)
+ }
+
+ sm := state.NewManager(config)
+
+ // Validate git repository by trying to derive sessions
+ _, err := sm.DeriveFreshSessions()
+ if err != nil {
+ // Try to provide helpful error message
+ if err.Error() == "not a git repository" {
+ return nil, fmt.Errorf("current directory is not a git repository. Please run 'git init' first")
+ }
+ if err.Error() == "repository has no commits" {
+ return nil, fmt.Errorf("git repository has no commits. Please make an initial commit first")
+ }
+ // Return original error for other cases
+ return nil, err
+ }
+
+ return sm, nil
+}
+
+// addAnnotation adds a group annotation to a command
+func addAnnotation(cmd *cobra.Command, group string) *cobra.Command {
+ if cmd.Annotations == nil {
+ cmd.Annotations = make(map[string]string)
+ }
+ cmd.Annotations["group"] = group
+ return cmd
+}
+
+// getCustomHelpTemplate returns a custom help template with organized command groups
+func getCustomHelpTemplate() string {
+ return `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}}
+
+{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}{{if .HasAvailableSubCommands}}
+
+Session Management:{{range .Commands}}{{if and (eq .Annotations.group "session-mgmt") .IsAvailableCommand}}
+ {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}
+
+Session Workflow (Branch Lifecycle):{{range .Commands}}{{if and (eq .Annotations.group "session-workflow") .IsAvailableCommand}}
+ {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}
+
+Information & Monitoring:{{range .Commands}}{{if and (eq .Annotations.group "info") .IsAvailableCommand}}
+ {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}
+
+Interface & Utilities:{{range .Commands}}{{if and (eq .Annotations.group "interface") .IsAvailableCommand}}
+ {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}
+
+Other Commands:{{range .Commands}}{{if and (or (not .Annotations.group) (eq .Name "help") (eq .Name "completion")) .IsAvailableCommand}}
+ {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableInheritedFlags}}
+
+Global Flags:
+{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}}
+
+Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
+
+Workflow Examples:
+ cwt new my-feature "Add user authentication" # Create session
+ cwt attach my-feature # Work on the feature
+ cwt status --summary # Check overall progress
+ cwt switch my-feature # Test changes locally
+ cwt publish my-feature # Commit and push
+ cwt merge my-feature # Merge to main branch
+ cwt tui # Use interactive dashboard
+`
+}
+
+// Execute runs the root command
+func Execute() {
+ rootCmd := NewRootCmd()
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+
+
package cli
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// SessionSelectorResult represents the result of session selection
+type SessionSelectorResult struct {
+ Session *types.Session
+ Canceled bool
+}
+
+// sessionSelectorModel represents the session selector state
+type sessionSelectorModel struct {
+ sessions []types.Session
+ cursor int
+ selected bool
+ canceled bool
+ title string
+ width int
+ height int
+}
+
+// SessionSelectorOption configures the session selector
+type SessionSelectorOption func(*sessionSelectorModel)
+
+// WithTitle sets the selector title
+func WithTitle(title string) SessionSelectorOption {
+ return func(m *sessionSelectorModel) {
+ m.title = title
+ }
+}
+
+// WithSessionFilter filters sessions based on a predicate
+func WithSessionFilter(filter func(types.Session) bool) SessionSelectorOption {
+ return func(m *sessionSelectorModel) {
+ filtered := make([]types.Session, 0)
+ for _, session := range m.sessions {
+ if filter(session) {
+ filtered = append(filtered, session)
+ }
+ }
+ m.sessions = filtered
+ }
+}
+
+// SelectSession shows an interactive session selector
+func SelectSession(sessions []types.Session, options ...SessionSelectorOption) (*types.Session, error) {
+ if len(sessions) == 0 {
+ return nil, fmt.Errorf("no sessions available")
+ }
+
+ if len(sessions) == 1 {
+ // If only one session, return it directly
+ return &sessions[0], nil
+ }
+
+ model := &sessionSelectorModel{
+ sessions: sessions,
+ cursor: 0,
+ title: "Select a session:",
+ }
+
+ // Apply options
+ for _, opt := range options {
+ opt(model)
+ }
+
+ // Check if we have an interactive terminal
+ if !hasInteractiveTerminal() {
+ // Fallback to simple number-based selection
+ return selectSessionFallback(model.sessions, model.title)
+ }
+
+ // Try interactive mode, fallback on any error
+ p := tea.NewProgram(model)
+ finalModel, err := p.Run()
+ if err != nil {
+ // Fallback to simple number-based selection
+ return selectSessionFallback(model.sessions, model.title)
+ }
+
+ result := finalModel.(*sessionSelectorModel)
+ if result.canceled {
+ return nil, nil // User canceled
+ }
+
+ if result.cursor >= 0 && result.cursor < len(result.sessions) {
+ return &result.sessions[result.cursor], nil
+ }
+
+ return nil, fmt.Errorf("invalid selection")
+}
+
+// hasInteractiveTerminal checks if we're running in an interactive terminal
+func hasInteractiveTerminal() bool {
+ // Check if stdin and stdout are terminals
+ _, stdinErr := os.Stdin.Stat()
+ _, stdoutErr := os.Stdout.Stat()
+ return stdinErr == nil && stdoutErr == nil
+}
+
+// selectSessionFallback provides a simple number-based fallback when TTY is not available
+func selectSessionFallback(sessions []types.Session, title string) (*types.Session, error) {
+ fmt.Println(title)
+ fmt.Println()
+
+ for i, session := range sessions {
+ status := getSessionStatusIndicator(session)
+ activity := FormatActivity(session.LastActivity)
+ fmt.Printf(" %d. %s %s (%s)\n", i+1, session.Core.Name, status, activity)
+ }
+
+ fmt.Print("\nEnter session number (or 0 to cancel): ")
+ var choice int
+ if _, err := fmt.Scanf("%d", &choice); err != nil {
+ return nil, fmt.Errorf("invalid input")
+ }
+
+ if choice == 0 {
+ return nil, nil // User canceled
+ }
+
+ if choice < 1 || choice > len(sessions) {
+ return nil, fmt.Errorf("invalid session number")
+ }
+
+ return &sessions[choice-1], nil
+}
+
+// Init initializes the selector model
+func (m *sessionSelectorModel) Init() tea.Cmd {
+ return nil
+}
+
+// Update handles user input
+func (m *sessionSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ return m, nil
+
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "q", "esc":
+ m.canceled = true
+ return m, tea.Quit
+
+ case "up", "k":
+ if m.cursor > 0 {
+ m.cursor--
+ }
+
+ case "down", "j":
+ if m.cursor < len(m.sessions)-1 {
+ m.cursor++
+ }
+
+ case "enter", " ":
+ m.selected = true
+ return m, tea.Quit
+ }
+ }
+
+ return m, nil
+}
+
+// View renders the selector inline
+func (m *sessionSelectorModel) View() string {
+ if len(m.sessions) == 0 {
+ return "No sessions available. Press q to quit."
+ }
+
+ var b strings.Builder
+
+ // Title (more compact)
+ titleStyle := lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("205"))
+
+ b.WriteString(titleStyle.Render(m.title))
+ b.WriteString("\n")
+
+ // Session list (more compact)
+ for i, session := range m.sessions {
+ prefix := " "
+ if i == m.cursor {
+ prefix = "β "
+ }
+
+ // Session info
+ status := getSessionStatusIndicator(session)
+ activity := FormatActivity(session.LastActivity)
+
+ line := fmt.Sprintf("%s%s %s (%s)",
+ prefix,
+ session.Core.Name,
+ status,
+ activity)
+
+ // Style based on selection (less intrusive highlighting)
+ if i == m.cursor {
+ selectedStyle := lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("cyan"))
+ line = selectedStyle.Render(line)
+ }
+
+ b.WriteString(line)
+ b.WriteString("\n")
+ }
+
+ // Compact instructions
+ instructionStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("240")).
+ Italic(true)
+
+ instructions := "β/β: navigate β’ enter: select β’ q/esc: cancel"
+ b.WriteString(instructionStyle.Render(instructions))
+
+ // Show what will be selected for clarity
+ if m.selected {
+ b.WriteString("\n\nβ Selected: " + m.sessions[m.cursor].Core.Name)
+ }
+
+ return b.String()
+}
+
+// getSessionStatusIndicator returns a compact status indicator for a session
+func getSessionStatusIndicator(session types.Session) string {
+ var indicators []string
+
+ // Tmux status
+ if session.IsAlive {
+ indicators = append(indicators, "π’")
+ } else {
+ indicators = append(indicators, "π΄")
+ }
+
+ // Claude status
+ switch session.ClaudeStatus.State {
+ case types.ClaudeWorking:
+ indicators = append(indicators, "π")
+ case types.ClaudeWaiting:
+ indicators = append(indicators, "βΈοΈ")
+ case types.ClaudeComplete:
+ indicators = append(indicators, "β
")
+ case types.ClaudeIdle:
+ indicators = append(indicators, "π€")
+ default:
+ indicators = append(indicators, "β")
+ }
+
+ // Git status
+ if session.GitStatus.HasChanges {
+ total := len(session.GitStatus.ModifiedFiles) + len(session.GitStatus.AddedFiles) +
+ len(session.GitStatus.DeletedFiles) + len(session.GitStatus.UntrackedFiles)
+ indicators = append(indicators, fmt.Sprintf("π%d", total))
+ } else {
+ indicators = append(indicators, "β¨")
+ }
+
+ return strings.Join(indicators, " ")
+}
+
+
+
package cli
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// newStatusCmd creates the 'cwt status' command
+func newStatusCmd() *cobra.Command {
+ var summary bool
+ var branch bool
+
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Show comprehensive status of all sessions with change details",
+ Long: `Show comprehensive view of changes across all sessions with rich status information.
+
+This command provides detailed information about:
+- Session states and activity
+- Git changes and commit counts
+- Branch relationships and merge status
+- Overall project health
+
+Examples:
+ cwt status # Detailed status for all sessions
+ cwt status --summary # Summary view with statistics
+ cwt status --branch # Include branch relationship info`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ return showEnhancedStatus(sm, summary, branch)
+ },
+ }
+
+ cmd.Flags().BoolVar(&summary, "summary", false, "Show summary of all changes across sessions")
+ cmd.Flags().BoolVar(&branch, "branch", false, "Include branch relationship information")
+
+ return cmd
+}
+
+// showEnhancedStatus displays comprehensive session status
+func showEnhancedStatus(sm *state.Manager, summary, showBranch bool) error {
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ if len(sessions) == 0 {
+ fmt.Println("No sessions found.")
+ fmt.Println("\nCreate a new session with: cwt new [session-name] [task-description]")
+ return nil
+ }
+
+ // Sort sessions by last activity (most recent first)
+ sort.Slice(sessions, func(i, j int) bool {
+ return sessions[i].LastActivity.After(sessions[j].LastActivity)
+ })
+
+ if summary {
+ return showStatusSummary(sessions)
+ }
+
+ return showDetailedStatus(sessions, showBranch)
+}
+
+// showStatusSummary shows a high-level summary of all sessions
+func showStatusSummary(sessions []types.Session) error {
+ fmt.Println("π Session Summary")
+ fmt.Println(strings.Repeat("=", 50))
+
+ // Calculate statistics
+ var alive, dead, hasChanges, published, merged int
+ var totalModified, totalAdded, totalDeleted int
+
+ for _, session := range sessions {
+ if session.IsAlive {
+ alive++
+ } else {
+ dead++
+ }
+
+ if session.GitStatus.HasChanges {
+ hasChanges++
+ totalModified += len(session.GitStatus.ModifiedFiles)
+ totalAdded += len(session.GitStatus.AddedFiles)
+ totalDeleted += len(session.GitStatus.DeletedFiles)
+ }
+
+ // Check if published (has remote tracking)
+ if isSessionPublished(session) {
+ published++
+ }
+
+ // Check if merged (would need additional logic)
+ if isSessionMerged(session) {
+ merged++
+ }
+ }
+
+ // Display statistics
+ fmt.Printf("Total Sessions: %d\n", len(sessions))
+ fmt.Printf(" β’ Active: %d\n", alive)
+ fmt.Printf(" β’ Inactive: %d\n", dead)
+ fmt.Printf("\n")
+ fmt.Printf("Change Summary:\n")
+ fmt.Printf(" β’ With Changes: %d\n", hasChanges)
+ fmt.Printf(" β’ Clean: %d\n", len(sessions)-hasChanges)
+ fmt.Printf(" β’ Published: %d\n", published)
+ fmt.Printf(" β’ Merged: %d\n", merged)
+ fmt.Printf("\n")
+ fmt.Printf("File Changes:\n")
+ fmt.Printf(" β’ Modified: %d\n", totalModified)
+ fmt.Printf(" β’ Added: %d\n", totalAdded)
+ fmt.Printf(" β’ Deleted: %d\n", totalDeleted)
+
+ // Show most recent activity
+ if len(sessions) > 0 {
+ fmt.Printf("\n")
+ fmt.Printf("Recent Activity:\n")
+ for i, session := range sessions {
+ if i >= 3 { // Show top 3 most recent
+ break
+ }
+ fmt.Printf(" β’ %s: %s\n", session.Core.Name, FormatActivity(session.LastActivity))
+ }
+ }
+
+ return nil
+}
+
+// showDetailedStatus shows detailed information for each session
+func showDetailedStatus(sessions []types.Session, showBranch bool) error {
+ fmt.Printf("π Session Status (%d sessions)\n", len(sessions))
+ fmt.Println(strings.Repeat("=", 70))
+
+ for i, session := range sessions {
+ if i > 0 {
+ fmt.Println()
+ }
+
+ renderSessionStatus(session, showBranch)
+ }
+
+ return nil
+}
+
+// renderSessionStatus renders detailed status for a single session
+func renderSessionStatus(session types.Session, showBranch bool) {
+ // Session header
+ fmt.Printf("π·οΈ %s", session.Core.Name)
+
+ // Show main status indicators
+ statusIndicators := []string{}
+
+ if session.IsAlive {
+ statusIndicators = append(statusIndicators, "π’ active")
+ } else {
+ statusIndicators = append(statusIndicators, "π΄ inactive")
+ }
+
+ if session.GitStatus.HasChanges {
+ changeCount := len(session.GitStatus.ModifiedFiles) + len(session.GitStatus.AddedFiles) + len(session.GitStatus.DeletedFiles)
+ statusIndicators = append(statusIndicators, fmt.Sprintf("π %d changes", changeCount))
+ } else {
+ statusIndicators = append(statusIndicators, "β¨ clean")
+ }
+
+ if isSessionPublished(session) {
+ statusIndicators = append(statusIndicators, "π€ published")
+ }
+
+ fmt.Printf(" (%s)\n", strings.Join(statusIndicators, ", "))
+
+ // Show activity timing
+ fmt.Printf(" β° Last activity: %s\n", FormatActivity(session.LastActivity))
+
+ // Show Claude status
+ claudeIcon := getClaudeIcon(session.ClaudeStatus.State)
+ fmt.Printf(" %s Claude: %s", claudeIcon, string(session.ClaudeStatus.State))
+
+ if session.ClaudeStatus.StatusMessage != "" {
+ fmt.Printf(" - %s", session.ClaudeStatus.StatusMessage)
+ }
+
+ if !session.ClaudeStatus.LastMessage.IsZero() {
+ age := time.Since(session.ClaudeStatus.LastMessage)
+ fmt.Printf(" (last: %s ago)", FormatDuration(age))
+ }
+ fmt.Println()
+
+ // Show detailed git status
+ if session.GitStatus.HasChanges {
+ fmt.Printf(" π Git changes:\n")
+
+ if len(session.GitStatus.ModifiedFiles) > 0 {
+ fmt.Printf(" π Modified: %s\n",
+ formatFileList(session.GitStatus.ModifiedFiles, 3))
+ }
+
+ if len(session.GitStatus.AddedFiles) > 0 {
+ fmt.Printf(" β Added: %s\n",
+ formatFileList(session.GitStatus.AddedFiles, 3))
+ }
+
+ if len(session.GitStatus.DeletedFiles) > 0 {
+ fmt.Printf(" β Deleted: %s\n",
+ formatFileList(session.GitStatus.DeletedFiles, 3))
+ }
+
+ if len(session.GitStatus.UntrackedFiles) > 0 {
+ fmt.Printf(" β Untracked: %s\n",
+ formatFileList(session.GitStatus.UntrackedFiles, 3))
+ }
+ }
+
+ // Show commit count if available
+ if session.GitStatus.CommitCount > 0 {
+ fmt.Printf(" π Commits ahead: %d\n", session.GitStatus.CommitCount)
+ }
+
+ // Show branch information if requested
+ if showBranch {
+ branchName := fmt.Sprintf("cwt-%s", session.Core.Name)
+ if branchInfo := getBranchInfo(session.Core.WorktreePath, branchName); branchInfo != "" {
+ fmt.Printf(" πΏ Branch: %s\n", branchInfo)
+ }
+ }
+
+ // Show path for easy access
+ fmt.Printf(" π Path: %s\n", session.Core.WorktreePath)
+}
+
+// Helper functions
+
+func getClaudeIcon(state types.ClaudeState) string {
+ switch state {
+ case types.ClaudeWorking:
+ return "π"
+ case types.ClaudeWaiting:
+ return "βΈοΈ"
+ case types.ClaudeComplete:
+ return "β
"
+ case types.ClaudeIdle:
+ return "π€"
+ default:
+ return "β"
+ }
+}
+
+func formatFileList(files []string, maxShow int) string {
+ if len(files) == 0 {
+ return ""
+ }
+
+ if len(files) <= maxShow {
+ return strings.Join(files, ", ")
+ }
+
+ shown := files[:maxShow]
+ remaining := len(files) - maxShow
+ return fmt.Sprintf("%s... (+%d more)", strings.Join(shown, ", "), remaining)
+}
+
+func isSessionPublished(session types.Session) bool {
+ // This is a simplified check - in a full implementation,
+ // you'd check if the branch has been pushed to remote
+ branchName := fmt.Sprintf("cwt-%s", session.Core.Name)
+
+ // Change to worktree directory to check remote tracking
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return false
+ }
+ defer os.Chdir(originalDir)
+
+ if err := os.Chdir(session.Core.WorktreePath); err != nil {
+ return false
+ }
+
+ // Check if branch has remote tracking
+ cmd := exec.Command("git", "rev-parse", "--abbrev-ref", fmt.Sprintf("%s@{upstream}", branchName))
+ return cmd.Run() == nil
+}
+
+func isSessionMerged(session types.Session) bool {
+ // This is a simplified check - in a full implementation,
+ // you'd check if the session branch has been merged into main
+ return false // Placeholder for now
+}
+
+func getBranchInfo(worktreePath, branchName string) string {
+ // Change to worktree directory
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return ""
+ }
+ defer os.Chdir(originalDir)
+
+ if err := os.Chdir(worktreePath); err != nil {
+ return ""
+ }
+
+ // Get branch status relative to main
+ cmd := exec.Command("git", "rev-list", "--count", "--left-right", "main..."+branchName)
+ output, err := cmd.Output()
+ if err != nil {
+ return branchName
+ }
+
+ parts := strings.Fields(strings.TrimSpace(string(output)))
+ if len(parts) == 2 {
+ behind := parts[0]
+ ahead := parts[1]
+ return fmt.Sprintf("%s (β%s β%s)", branchName, behind, ahead)
+ }
+
+ return branchName
+}
+
+// Helper functions are imported from list.go - removed duplicates
+
+
+
package cli
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// newSwitchCmd creates the 'cwt switch' command
+func newSwitchCmd() *cobra.Command {
+ var back bool
+
+ cmd := &cobra.Command{
+ Use: "switch [session-name]",
+ Short: "Switch to a session's branch for testing or manual work",
+ Long: `Switch your main workspace to a session's branch temporarily.
+This allows you to test changes or do manual work on the session branch.
+
+Examples:
+ cwt switch my-session # Switch to my-session branch
+ cwt switch --back # Return to previous branch
+ cwt switch # Interactive session selector`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ if back {
+ return switchBack()
+ }
+
+ if len(args) == 0 {
+ return interactiveSwitch(sm)
+ }
+
+ sessionName := args[0]
+ return switchToSession(sm, sessionName)
+ },
+ }
+
+ cmd.Flags().BoolVar(&back, "back", false, "Return to previous branch")
+
+ return cmd
+}
+
+// switchToSession switches to a session's branch
+func switchToSession(sm *state.Manager, sessionName string) error {
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ // Find the session
+ var targetSession *types.Session
+ for _, session := range sessions {
+ if session.Core.Name == sessionName {
+ targetSession = &session
+ break
+ }
+ }
+
+ if targetSession == nil {
+ return fmt.Errorf("session '%s' not found", sessionName)
+ }
+
+ // Get current branch
+ currentBranch, err := getCurrentBranch()
+ if err != nil {
+ return fmt.Errorf("failed to get current branch: %w", err)
+ }
+
+ // Check for uncommitted changes and handle them interactively
+ if hasUncommittedChanges() {
+ if err := handleUncommittedChanges(); err != nil {
+ return err
+ }
+ }
+
+ // Save current branch for --back functionality
+ if err := savePreviousBranch(currentBranch); err != nil {
+ fmt.Printf("Warning: failed to save previous branch: %v\n", err)
+ }
+
+ // Switch to session branch
+ sessionBranch := fmt.Sprintf("cwt-%s", sessionName)
+ if err := switchBranch(sessionBranch); err != nil {
+ return fmt.Errorf("failed to switch to branch '%s': %w", sessionBranch, err)
+ }
+
+ fmt.Printf("Switched to session branch: %s\n", sessionBranch)
+ fmt.Printf("Use 'cwt switch --back' to return to %s\n", currentBranch)
+
+ return nil
+}
+
+// switchBack returns to the previous branch
+func switchBack() error {
+ previousBranch, err := loadPreviousBranch()
+ if err != nil {
+ return fmt.Errorf("no previous branch saved: %w", err)
+ }
+
+ // Check for uncommitted changes
+ if hasUncommittedChanges() {
+ return fmt.Errorf("cannot switch: you have uncommitted changes. Please commit or stash them first")
+ }
+
+ if err := switchBranch(previousBranch); err != nil {
+ return fmt.Errorf("failed to switch back to '%s': %w", previousBranch, err)
+ }
+
+ fmt.Printf("Switched back to: %s\n", previousBranch)
+
+ // Clear the saved previous branch
+ if err := clearPreviousBranch(); err != nil {
+ fmt.Printf("Warning: failed to clear previous branch: %v\n", err)
+ }
+
+ return nil
+}
+
+// interactiveSwitch provides an interactive session selector
+func interactiveSwitch(sm *state.Manager) error {
+ sessions, err := sm.DeriveFreshSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ if len(sessions) == 0 {
+ fmt.Println("No sessions available to switch to")
+ return nil
+ }
+
+ selectedSession, err := SelectSession(sessions, WithTitle("Select a session to switch to:"))
+ if err != nil {
+ return fmt.Errorf("failed to select session: %w", err)
+ }
+
+ if selectedSession == nil {
+ fmt.Println("Cancelled")
+ return nil
+ }
+
+ return switchToSession(sm, selectedSession.Core.Name)
+}
+
+// Helper functions for git operations
+
+func getCurrentBranch() (string, error) {
+ cmd := exec.Command("git", "branch", "--show-current")
+ output, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(output)), nil
+}
+
+func hasUncommittedChanges() bool {
+ cmd := exec.Command("git", "status", "--porcelain")
+ output, err := cmd.Output()
+ if err != nil {
+ return false // Assume no changes if we can't check
+ }
+ return len(strings.TrimSpace(string(output))) > 0
+}
+
+func switchBranch(branch string) error {
+ cmd := exec.Command("git", "checkout", branch)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
+
+// Helper functions for previous branch management
+
+func savePreviousBranch(branch string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ dataDir := sm.GetDataDir()
+ previousBranchFile := filepath.Join(dataDir, "previous_branch")
+
+ // Ensure data directory exists
+ if err := os.MkdirAll(dataDir, 0755); err != nil {
+ return err
+ }
+
+ return os.WriteFile(previousBranchFile, []byte(branch), 0644)
+}
+
+func loadPreviousBranch() (string, error) {
+ sm, err := createStateManager()
+ if err != nil {
+ return "", err
+ }
+ defer sm.Close()
+
+ dataDir := sm.GetDataDir()
+ previousBranchFile := filepath.Join(dataDir, "previous_branch")
+
+ data, err := os.ReadFile(previousBranchFile)
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimSpace(string(data)), nil
+}
+
+func clearPreviousBranch() error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ defer sm.Close()
+
+ dataDir := sm.GetDataDir()
+ previousBranchFile := filepath.Join(dataDir, "previous_branch")
+
+ return os.Remove(previousBranchFile)
+}
+
+// handleUncommittedChanges provides options for dealing with uncommitted changes
+func handleUncommittedChanges() error {
+ fmt.Println("β οΈ You have uncommitted changes that need to be handled before switching branches.")
+ fmt.Println()
+
+ // Show what changes exist
+ if err := showUncommittedChanges(); err != nil {
+ fmt.Printf("Warning: failed to show changes: %v\n", err)
+ }
+
+ fmt.Println()
+ fmt.Println("How would you like to handle these changes?")
+ fmt.Println(" 1. π¦ Stash changes (recommended - easily recoverable)")
+ fmt.Println(" 2. πΎ Commit changes (permanent)")
+ fmt.Println(" 3. β Cancel switch")
+ fmt.Println()
+
+ for {
+ fmt.Print("Enter your choice (1-3) [1]: ")
+
+ var input string
+ fmt.Scanln(&input)
+
+ // Default to stash if no input
+ if input == "" {
+ input = "1"
+ }
+
+ switch input {
+ case "1":
+ return stashChanges()
+ case "2":
+ return commitChanges()
+ case "3":
+ return fmt.Errorf("switch cancelled by user")
+ default:
+ fmt.Println("Invalid choice. Please enter 1, 2, or 3.")
+ continue
+ }
+ }
+}
+
+// showUncommittedChanges displays what changes exist
+func showUncommittedChanges() error {
+ cmd := exec.Command("git", "status", "--short")
+ output, err := cmd.Output()
+ if err != nil {
+ return err
+ }
+
+ if len(output) > 0 {
+ fmt.Println("Changes that will be affected:")
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ for _, line := range lines {
+ if line != "" {
+ fmt.Printf(" %s\n", line)
+ }
+ }
+ }
+
+ return nil
+}
+
+// stashChanges stashes the current changes
+func stashChanges() error {
+ fmt.Println("π¦ Stashing changes...")
+
+ // Create a meaningful stash message
+ timestamp := time.Now().Format("2006-01-02 15:04:05")
+ message := fmt.Sprintf("CWT auto-stash before branch switch - %s", timestamp)
+
+ cmd := exec.Command("git", "stash", "push", "-m", message)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to stash changes: %w", err)
+ }
+
+ fmt.Println("β
Changes stashed successfully!")
+ fmt.Println("π‘ Use 'git stash pop' to restore them later")
+ return nil
+}
+
+// commitChanges prompts for a commit message and commits changes
+func commitChanges() error {
+ fmt.Print("Enter commit message: ")
+
+ reader := bufio.NewReader(os.Stdin)
+ message, err := reader.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("failed to read commit message: %w", err)
+ }
+
+ message = strings.TrimSpace(message)
+ if message == "" {
+ return fmt.Errorf("commit message cannot be empty")
+ }
+
+ fmt.Println("πΎ Committing changes...")
+
+ // Add all changes
+ if err := exec.Command("git", "add", ".").Run(); err != nil {
+ return fmt.Errorf("failed to stage changes: %w", err)
+ }
+
+ // Commit changes
+ cmd := exec.Command("git", "commit", "-m", message)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to commit changes: %w", err)
+ }
+
+ fmt.Println("β
Changes committed successfully!")
+ return nil
+}
+
+
+
package cli
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/tui"
+)
+
+func newTuiCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "tui",
+ Short: "Launch the interactive TUI dashboard",
+ Long: `Launch the TUI (Terminal User Interface) dashboard for CWT.
+
+The TUI provides:
+- Real-time session status monitoring
+- Interactive session management
+- Visual indicators for tmux, git, and Claude status
+- Quick session creation and deletion
+- Session attachment capabilities`,
+ Aliases: []string{"ui", "dashboard"},
+ RunE: runTuiCmd,
+ }
+
+ return cmd
+}
+
+func runTuiCmd(cmd *cobra.Command, args []string) error {
+ sm, err := createStateManager()
+ if err != nil {
+ return err
+ }
+ // Note: StateManager will be closed by the TUI when it exits
+
+ // Launch TUI
+ if err := tui.Run(sm); err != nil {
+ return fmt.Errorf("TUI error: %w", err)
+ }
+
+ return nil
+}
+
+
+
package claude
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/jlaneve/cwt-cli/internal/clients/tmux"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// Checker defines the interface for Claude status operations
+type Checker interface {
+ GetStatus(worktreePath string) types.ClaudeStatus
+ FindSessionID(worktreePath string) (string, error)
+}
+
+// RealChecker implements Checker using actual Claude session detection
+type RealChecker struct {
+ tmuxChecker tmux.Checker
+ scanner *SessionScanner
+}
+
+// NewRealChecker creates a new RealChecker
+func NewRealChecker(tmuxChecker tmux.Checker) *RealChecker {
+ return &RealChecker{
+ tmuxChecker: tmuxChecker,
+ scanner: NewSessionScanner(),
+ }
+}
+
+// GetStatus analyzes Claude activity in a worktree
+func (r *RealChecker) GetStatus(worktreePath string) types.ClaudeStatus {
+ status := types.ClaudeStatus{
+ State: types.ClaudeUnknown,
+ Availability: types.AvailVeryStale,
+ }
+
+ // Use scanner to find the most recent Claude session
+ claudeSession, err := r.scanner.GetMostRecentSession(worktreePath)
+ if err != nil || claudeSession == nil {
+ return status
+ }
+
+ status.SessionID = claudeSession.SessionID
+ status.LastMessage = claudeSession.LastSeen
+
+ // Parse last message from JSONL file
+ lastMessage, err := r.parseLastMessage(claudeSession.FilePath)
+ if err != nil {
+ // Fallback to session metadata if JSONL parsing fails
+ status.LastMessage = claudeSession.LastSeen
+ status.Availability = r.calculateAvailability(claudeSession.LastSeen)
+ return status
+ }
+
+ // Update timestamp from parsed message
+ status.LastMessage = lastMessage.Timestamp
+
+ // Determine state from message content
+ status.State = r.determineStateFromMessage(lastMessage)
+
+ // Check tmux for waiting prompts (overrides JSONL state)
+ if status.State == types.ClaudeWorking {
+ tmuxSession := r.deriveTmuxSessionName(worktreePath)
+ if r.checkTmuxForWaitingPrompt(tmuxSession) {
+ status.State = types.ClaudeWaiting
+ }
+ }
+
+ // Calculate availability from timestamp
+ status.Availability = r.calculateAvailability(lastMessage.Timestamp)
+
+ return status
+}
+
+// FindSessionID finds the Claude session ID for a worktree
+func (r *RealChecker) FindSessionID(worktreePath string) (string, error) {
+ // Use scanner to find session
+ claudeSession, err := r.scanner.GetMostRecentSession(worktreePath)
+ if err != nil {
+ return "", err
+ }
+
+ if claudeSession == nil {
+ return "", fmt.Errorf("no Claude session found for worktree %s", worktreePath)
+ }
+
+ return claudeSession.SessionID, nil
+}
+
+func (r *RealChecker) parseLastMessage(jsonlPath string) (types.ClaudeMessage, error) {
+ file, err := os.Open(jsonlPath)
+ if err != nil {
+ return types.ClaudeMessage{}, err
+ }
+ defer file.Close()
+
+ var lastMessage types.ClaudeMessage
+ scanner := bufio.NewScanner(file)
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+
+ var rawMessage map[string]interface{}
+ if err := json.Unmarshal([]byte(line), &rawMessage); err != nil {
+ continue
+ }
+
+ // Parse message structure
+ if msg, ok := rawMessage["message"].(map[string]interface{}); ok {
+ claudeMsg := types.ClaudeMessage{}
+
+ if role, ok := msg["role"].(string); ok {
+ claudeMsg.Role = role
+ }
+
+ if content, ok := msg["content"].([]interface{}); ok {
+ for _, c := range content {
+ if contentMap, ok := c.(map[string]interface{}); ok {
+ contentItem := types.Content{}
+ if contentType, ok := contentMap["type"].(string); ok {
+ contentItem.Type = contentType
+ }
+ if text, ok := contentMap["text"].(string); ok {
+ contentItem.Text = text
+ }
+ if name, ok := contentMap["name"].(string); ok {
+ contentItem.Name = name
+ }
+ claudeMsg.Content = append(claudeMsg.Content, contentItem)
+ }
+ }
+ }
+
+ // Parse timestamp
+ if timestamp, ok := rawMessage["timestamp"].(string); ok {
+ if parsed, err := time.Parse(time.RFC3339, timestamp); err == nil {
+ claudeMsg.Timestamp = parsed
+ }
+ }
+
+ if claudeMsg.Role == "assistant" {
+ lastMessage = claudeMsg
+ }
+ }
+ }
+
+ if lastMessage.Role == "" {
+ return types.ClaudeMessage{}, fmt.Errorf("no assistant messages found in JSONL")
+ }
+
+ return lastMessage, nil
+}
+
+func (r *RealChecker) determineStateFromMessage(message types.ClaudeMessage) types.ClaudeState {
+ // Check if message contains tool usage
+ for _, content := range message.Content {
+ if content.Type == "tool_use" {
+ return types.ClaudeWorking
+ }
+ }
+
+ // If no tools, Claude is waiting for user input
+ return types.ClaudeWaiting
+}
+
+func (r *RealChecker) checkTmuxForWaitingPrompt(tmuxSession string) bool {
+ if r.tmuxChecker == nil {
+ return false
+ }
+
+ if !r.tmuxChecker.IsSessionAlive(tmuxSession) {
+ return false
+ }
+
+ output, err := r.tmuxChecker.CaptureOutput(tmuxSession)
+ if err != nil {
+ return false
+ }
+
+ // Check for common waiting prompts
+ waitingPatterns := []*regexp.Regexp{
+ regexp.MustCompile(`Do you want to.*\?`),
+ regexp.MustCompile(`\d+\.\s+(Yes|No|Cancel)`),
+ regexp.MustCompile(`β―\s*\d+\.\s+(Yes|No|Cancel)`),
+ regexp.MustCompile(`Continue\?\s*\(y/n\)`),
+ regexp.MustCompile(`Press.*to continue`),
+ }
+
+ for _, pattern := range waitingPatterns {
+ if pattern.MatchString(output) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (r *RealChecker) deriveTmuxSessionName(worktreePath string) string {
+ // Extract session name from worktree path
+ // Assumes path like: .cwt/worktrees/{session-name}
+ base := filepath.Base(worktreePath)
+ return fmt.Sprintf("cwt-%s", base)
+}
+
+func (r *RealChecker) calculateAvailability(timestamp time.Time) types.Availability {
+ if timestamp.IsZero() {
+ return types.AvailVeryStale
+ }
+
+ age := time.Since(timestamp)
+
+ switch {
+ case age < 5*time.Minute:
+ return types.AvailCurrent
+ case age < 1*time.Hour:
+ return types.AvailRecent
+ case age < 24*time.Hour:
+ return types.AvailStale
+ default:
+ return types.AvailVeryStale
+ }
+}
+
+// MockChecker implements Checker for testing
+type MockChecker struct {
+ Statuses map[string]types.ClaudeStatus
+ Delay time.Duration
+}
+
+// NewMockChecker creates a new MockChecker
+func NewMockChecker() *MockChecker {
+ return &MockChecker{
+ Statuses: make(map[string]types.ClaudeStatus),
+ }
+}
+
+// GetStatus returns the mocked status
+func (m *MockChecker) GetStatus(worktreePath string) types.ClaudeStatus {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ status, exists := m.Statuses[worktreePath]
+ if !exists {
+ return types.ClaudeStatus{
+ State: types.ClaudeUnknown,
+ Availability: types.AvailVeryStale,
+ }
+ }
+ return status
+}
+
+// FindSessionID returns a mock session ID
+func (m *MockChecker) FindSessionID(worktreePath string) (string, error) {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ // Return a mock session ID based on path
+ return fmt.Sprintf("mock-session-%s", filepath.Base(worktreePath)), nil
+}
+
+// SetStatus sets the Claude status for testing
+func (m *MockChecker) SetStatus(worktreePath string, status types.ClaudeStatus) {
+ m.Statuses[worktreePath] = status
+}
+
+// SetDelay sets a delay for all operations
+func (m *MockChecker) SetDelay(delay time.Duration) {
+ m.Delay = delay
+}
+
+
+
package claude
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+// ClaudeSession represents a Claude Code session from JSONL
+type ClaudeSession struct {
+ SessionID string `json:"sessionId"`
+ CWD string `json:"cwd"`
+ LastSeen time.Time `json:"lastSeen"`
+ FilePath string `json:"filePath"`
+ MessageCount int `json:"messageCount"`
+}
+
+// SessionScanner discovers Claude Code sessions
+type SessionScanner struct {
+ claudeDir string
+}
+
+// NewSessionScanner creates a new Claude session scanner
+func NewSessionScanner() *SessionScanner {
+ homeDir, _ := os.UserHomeDir()
+ return &SessionScanner{
+ claudeDir: filepath.Join(homeDir, ".claude"),
+ }
+}
+
+// FindSessionsForDirectory finds Claude sessions that match a given directory
+func (s *SessionScanner) FindSessionsForDirectory(targetDir string) ([]*ClaudeSession, error) {
+ // Convert to absolute path for matching
+ absTargetDir, err := filepath.Abs(targetDir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get absolute path: %w", err)
+ }
+
+ // Build project directory path from target directory
+ // Example: /Users/julian/Astronomer/cwt-cli/.cwt/worktrees/test -> -Users-julian-Astronomer-cwt-cli--cwt-worktrees-test
+ // Claude converts /.cwt/ to --cwt- (double dash for hidden dirs)
+ projectName := strings.ReplaceAll(absTargetDir, "/", "-")
+ projectName = strings.ReplaceAll(projectName, "-.cwt-", "--cwt-")
+ projectDir := filepath.Join(s.claudeDir, "projects", projectName)
+
+ // Check if project directory exists
+ if _, err := os.Stat(projectDir); os.IsNotExist(err) {
+ return []*ClaudeSession{}, nil // No Claude sessions for this directory
+ }
+
+ // Scan all .jsonl files in the project directory
+ files, err := filepath.Glob(filepath.Join(projectDir, "*.jsonl"))
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan claude sessions: %w", err)
+ }
+
+ var sessions []*ClaudeSession
+ for _, file := range files {
+ session, err := s.parseSessionFile(file, absTargetDir)
+ if err != nil {
+ continue // Skip problematic files
+ }
+ if session != nil {
+ sessions = append(sessions, session)
+ }
+ }
+
+ // Sort by most recent activity first
+ sort.Slice(sessions, func(i, j int) bool {
+ return sessions[i].LastSeen.After(sessions[j].LastSeen)
+ })
+
+ return sessions, nil
+}
+
+// GetMostRecentSession returns the most recently active Claude session for a directory
+func (s *SessionScanner) GetMostRecentSession(targetDir string) (*ClaudeSession, error) {
+ sessions, err := s.FindSessionsForDirectory(targetDir)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(sessions) == 0 {
+ return nil, nil // No sessions found
+ }
+
+ return sessions[0], nil // Most recent is first due to sorting
+}
+
+// parseSessionFile extracts session metadata from a JSONL file
+func (s *SessionScanner) parseSessionFile(filePath, targetDir string) (*ClaudeSession, error) {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ var sessionID string
+ var cwd string
+ var lastSeen time.Time
+ var messageCount int
+
+ // Read each line of JSONL
+ for scanner.Scan() {
+ var line map[string]interface{}
+ if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
+ continue // Skip invalid JSON lines
+ }
+
+ // Extract session metadata from first message
+ if sessionID == "" {
+ if sid, ok := line["sessionId"].(string); ok {
+ sessionID = sid
+ }
+ if c, ok := line["cwd"].(string); ok {
+ cwd = c
+ }
+ }
+
+ // Track last activity timestamp
+ if timestampStr, ok := line["timestamp"].(string); ok {
+ if t, err := time.Parse(time.RFC3339, timestampStr); err == nil {
+ if t.After(lastSeen) {
+ lastSeen = t
+ }
+ }
+ }
+
+ messageCount++
+ }
+
+ // Only return session if it matches our target directory exactly
+ if cwd != targetDir {
+ return nil, nil
+ }
+
+ // Must have valid session ID and recent activity
+ if sessionID == "" || lastSeen.IsZero() {
+ return nil, nil
+ }
+
+ return &ClaudeSession{
+ SessionID: sessionID,
+ CWD: cwd,
+ LastSeen: lastSeen,
+ FilePath: filePath,
+ MessageCount: messageCount,
+ }, nil
+}
+
+// IsClaudeAvailable checks if claude command is available
+func (s *SessionScanner) IsClaudeAvailable() bool {
+ // Check common installation paths
+ claudePaths := []string{
+ "/usr/local/bin/claude",
+ os.ExpandEnv("$HOME/.claude/local/claude"),
+ os.ExpandEnv("$HOME/.claude/local/node_modules/.bin/claude"),
+ }
+
+ for _, path := range claudePaths {
+ if _, err := os.Stat(path); err == nil {
+ return true
+ }
+ }
+
+ return false
+}
+
+
+
package git
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// Checker defines the interface for git operations
+type Checker interface {
+ GetStatus(worktreePath string) types.GitStatus
+ CreateWorktree(branchName, worktreePath string) error
+ RemoveWorktree(worktreePath string) error
+ IsValidRepository(repoPath string) error
+ ListWorktrees() ([]WorktreeInfo, error)
+ BranchExists(branchName string) bool
+ CommitChanges(worktreePath, message string) error
+ CheckoutBranch(branchName string) error
+}
+
+// WorktreeInfo represents information about a git worktree
+type WorktreeInfo struct {
+ Path string
+ Branch string
+ Bare bool
+}
+
+// RealChecker implements Checker using actual git commands
+type RealChecker struct {
+ BaseBranch string // Default branch to create worktrees from
+}
+
+// NewRealChecker creates a new RealChecker
+func NewRealChecker(baseBranch string) *RealChecker {
+ if baseBranch == "" {
+ baseBranch = "main"
+ }
+ return &RealChecker{BaseBranch: baseBranch}
+}
+
+// GetStatus checks the git status of a worktree
+func (r *RealChecker) GetStatus(worktreePath string) types.GitStatus {
+ status := types.GitStatus{}
+
+ if !r.pathExists(worktreePath) {
+ return status
+ }
+
+ // Get porcelain status
+ cmd := exec.Command("git", "status", "--porcelain")
+ cmd.Dir = worktreePath
+ output, err := cmd.Output()
+ if err != nil {
+ return status
+ }
+
+ lines := strings.Split(strings.TrimRight(string(output), "\n"), "\n")
+ if len(lines) == 1 && lines[0] == "" {
+ // No changes
+ return status
+ }
+
+ for _, line := range lines {
+ if len(line) < 3 {
+ continue
+ }
+
+ statusCode := line[:2]
+ filename := line[3:]
+
+ // Ignore Claude-related files and directories
+ if strings.HasPrefix(filename, ".claude/") || filename == ".claude" {
+ continue
+ }
+
+ // We have a non-Claude change
+ status.HasChanges = true
+
+ switch {
+ case strings.HasPrefix(statusCode, "M") || strings.HasPrefix(statusCode, " M"):
+ status.ModifiedFiles = append(status.ModifiedFiles, filename)
+ case strings.HasPrefix(statusCode, "A"):
+ status.AddedFiles = append(status.AddedFiles, filename)
+ case strings.HasPrefix(statusCode, "??"):
+ status.UntrackedFiles = append(status.UntrackedFiles, filename)
+ case strings.HasPrefix(statusCode, "D") || strings.HasPrefix(statusCode, " D"):
+ status.DeletedFiles = append(status.DeletedFiles, filename)
+ }
+ }
+
+ // Count commits ahead of base branch
+ cmd = exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", r.BaseBranch))
+ cmd.Dir = worktreePath
+ output, err = cmd.Output()
+ if err == nil {
+ fmt.Sscanf(string(output), "%d", &status.CommitCount)
+ }
+
+ return status
+}
+
+// CreateWorktree creates a new git worktree with a new branch
+func (r *RealChecker) CreateWorktree(branchName, worktreePath string) error {
+ // Check if worktree directory already exists
+ if r.pathExists(worktreePath) {
+ return fmt.Errorf("worktree directory already exists: %s", worktreePath)
+ }
+
+ // Check if branch already exists
+ if r.BranchExists(branchName) {
+ return fmt.Errorf("branch '%s' already exists. Please use a different session name or delete the existing branch with: git branch -d %s", branchName, branchName)
+ }
+
+ // Ensure parent directory exists
+ parentDir := filepath.Dir(worktreePath)
+ if err := os.MkdirAll(parentDir, 0755); err != nil {
+ return fmt.Errorf("failed to create parent directory %s: %w", parentDir, err)
+ }
+
+ // Create worktree with new branch
+ cmd := exec.Command("git", "worktree", "add", "-b", branchName, worktreePath, r.BaseBranch)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to create worktree %s: %w\nOutput: %s", worktreePath, err, string(output))
+ }
+
+ return nil
+}
+
+// RemoveWorktree removes a git worktree
+func (r *RealChecker) RemoveWorktree(worktreePath string) error {
+ // Remove the worktree
+ cmd := exec.Command("git", "worktree", "remove", worktreePath, "--force")
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to remove worktree %s: %w\nOutput: %s", worktreePath, err, string(output))
+ }
+
+ return nil
+}
+
+// IsValidRepository checks if the current directory is a valid git repository
+func (r *RealChecker) IsValidRepository(repoPath string) error {
+ cmd := exec.Command("git", "rev-parse", "--git-dir")
+ if repoPath != "" {
+ cmd.Dir = repoPath
+ }
+ err := cmd.Run()
+ if err != nil {
+ return fmt.Errorf("not a git repository: %w", err)
+ }
+
+ // Check if repository has commits
+ cmd = exec.Command("git", "rev-parse", "HEAD")
+ if repoPath != "" {
+ cmd.Dir = repoPath
+ }
+ err = cmd.Run()
+ if err != nil {
+ return fmt.Errorf("repository has no commits: %w", err)
+ }
+
+ return nil
+}
+
+// ListWorktrees returns all git worktrees
+func (r *RealChecker) ListWorktrees() ([]WorktreeInfo, error) {
+ cmd := exec.Command("git", "worktree", "list", "--porcelain")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list worktrees: %w", err)
+ }
+
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ var worktrees []WorktreeInfo
+ var current WorktreeInfo
+
+ for _, line := range lines {
+ if line == "" {
+ if current.Path != "" {
+ worktrees = append(worktrees, current)
+ current = WorktreeInfo{}
+ }
+ continue
+ }
+
+ if strings.HasPrefix(line, "worktree ") {
+ current.Path = strings.TrimPrefix(line, "worktree ")
+ } else if strings.HasPrefix(line, "branch ") {
+ current.Branch = strings.TrimPrefix(line, "branch ")
+ } else if line == "bare" {
+ current.Bare = true
+ }
+ }
+
+ // Add final worktree if exists
+ if current.Path != "" {
+ worktrees = append(worktrees, current)
+ }
+
+ return worktrees, nil
+}
+
+func (r *RealChecker) pathExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+// BranchExists checks if a git branch exists (local or remote)
+func (r *RealChecker) BranchExists(branchName string) bool {
+ // Check local branches first
+ cmd := exec.Command("git", "branch", "--list", branchName)
+ output, err := cmd.Output()
+ if err == nil && strings.TrimSpace(string(output)) != "" {
+ return true
+ }
+
+ // Check remote branches
+ cmd = exec.Command("git", "branch", "-r", "--list", "*"+branchName)
+ output, err = cmd.Output()
+ if err == nil && strings.TrimSpace(string(output)) != "" {
+ return true
+ }
+
+ return false
+}
+
+// CommitChanges stages all changes and commits them with the given message
+func (r *RealChecker) CommitChanges(worktreePath, message string) error {
+ // Stage all changes
+ cmd := exec.Command("git", "add", ".")
+ cmd.Dir = worktreePath
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to stage changes: %w\nOutput: %s", err, string(output))
+ }
+
+ // Get git user configuration
+ name, email := r.getGitUserConfig()
+
+ // Create commit
+ cmd = exec.Command("git", "commit", "-m", message)
+ if name != "" {
+ cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_AUTHOR_NAME=%s", name))
+ cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_COMMITTER_NAME=%s", name))
+ }
+ if email != "" {
+ cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_AUTHOR_EMAIL=%s", email))
+ cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_COMMITTER_EMAIL=%s", email))
+ }
+ cmd.Dir = worktreePath
+ output, err = cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to commit changes: %w\nOutput: %s", err, string(output))
+ }
+
+ return nil
+}
+
+// CheckoutBranch switches to the specified branch
+func (r *RealChecker) CheckoutBranch(branchName string) error {
+ cmd := exec.Command("git", "checkout", branchName)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to checkout branch %s: %w\nOutput: %s", branchName, err, string(output))
+ }
+ return nil
+}
+
+// getGitUserConfig gets the git user name and email from config
+func (r *RealChecker) getGitUserConfig() (string, string) {
+ var name, email string
+
+ // Get user name
+ cmd := exec.Command("git", "config", "user.name")
+ if output, err := cmd.Output(); err == nil {
+ name = strings.TrimSpace(string(output))
+ }
+
+ // Get user email
+ cmd = exec.Command("git", "config", "user.email")
+ if output, err := cmd.Output(); err == nil {
+ email = strings.TrimSpace(string(output))
+ }
+
+ // Fallback values if not configured
+ if name == "" {
+ name = "CWT User"
+ }
+ if email == "" {
+ email = "user@example.com"
+ }
+
+ return name, email
+}
+
+// MockChecker implements Checker for testing
+type MockChecker struct {
+ Statuses map[string]types.GitStatus
+ Worktrees map[string]bool
+ ShouldFail map[string]bool
+ Delay time.Duration
+ ValidRepo bool
+}
+
+// NewMockChecker creates a new MockChecker
+func NewMockChecker() *MockChecker {
+ return &MockChecker{
+ Statuses: make(map[string]types.GitStatus),
+ Worktrees: make(map[string]bool),
+ ShouldFail: make(map[string]bool),
+ ValidRepo: true,
+ }
+}
+
+// GetStatus returns the mocked status
+func (m *MockChecker) GetStatus(worktreePath string) types.GitStatus {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ status, exists := m.Statuses[worktreePath]
+ if !exists {
+ return types.GitStatus{} // Empty status
+ }
+ return status
+}
+
+// CreateWorktree mocks worktree creation
+func (m *MockChecker) CreateWorktree(branchName, worktreePath string) error {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ if m.ShouldFail[worktreePath] {
+ return fmt.Errorf("mock create failure for worktree %s", worktreePath)
+ }
+ m.Worktrees[worktreePath] = true
+ return nil
+}
+
+// RemoveWorktree mocks worktree removal
+func (m *MockChecker) RemoveWorktree(worktreePath string) error {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ if m.ShouldFail[worktreePath] {
+ return fmt.Errorf("mock remove failure for worktree %s", worktreePath)
+ }
+ delete(m.Worktrees, worktreePath)
+ return nil
+}
+
+// IsValidRepository returns the mocked validity
+func (m *MockChecker) IsValidRepository(repoPath string) error {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ if !m.ValidRepo {
+ return fmt.Errorf("mock repository validation failure")
+ }
+ return nil
+}
+
+// ListWorktrees returns mocked worktree list
+func (m *MockChecker) ListWorktrees() ([]WorktreeInfo, error) {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ var worktrees []WorktreeInfo
+ for path := range m.Worktrees {
+ worktrees = append(worktrees, WorktreeInfo{
+ Path: path,
+ Branch: filepath.Base(path),
+ })
+ }
+ return worktrees, nil
+}
+
+// SetStatus sets the git status for testing
+func (m *MockChecker) SetStatus(worktreePath string, status types.GitStatus) {
+ m.Statuses[worktreePath] = status
+}
+
+// SetWorktreeExists sets whether a worktree exists
+func (m *MockChecker) SetWorktreeExists(worktreePath string, exists bool) {
+ if exists {
+ m.Worktrees[worktreePath] = true
+ } else {
+ delete(m.Worktrees, worktreePath)
+ }
+}
+
+// SetShouldFail sets whether operations should fail
+func (m *MockChecker) SetShouldFail(worktreePath string, shouldFail bool) {
+ m.ShouldFail[worktreePath] = shouldFail
+}
+
+// SetDelay sets a delay for all operations
+func (m *MockChecker) SetDelay(delay time.Duration) {
+ m.Delay = delay
+}
+
+// BranchExists returns whether a branch exists (always false for mock)
+func (m *MockChecker) BranchExists(branchName string) bool {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ // Mock implementation - can be configured if needed
+ return false
+}
+
+// CommitChanges mocks committing changes
+func (m *MockChecker) CommitChanges(worktreePath, message string) error {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ if m.ShouldFail[worktreePath] {
+ return fmt.Errorf("mock commit failure for worktree %s", worktreePath)
+ }
+ return nil
+}
+
+// CheckoutBranch mocks checking out a branch
+func (m *MockChecker) CheckoutBranch(branchName string) error {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ // Mock implementation - always succeeds unless configured otherwise
+ return nil
+}
+
+
+
package tmux
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// Checker defines the interface for tmux operations
+type Checker interface {
+ IsSessionAlive(sessionName string) bool
+ CaptureOutput(sessionName string) (string, error)
+ CreateSession(name, workdir, command string) error
+ KillSession(sessionName string) error
+ ListSessions() ([]string, error)
+}
+
+// RealChecker implements Checker using actual tmux commands
+type RealChecker struct{}
+
+// NewRealChecker creates a new RealChecker
+func NewRealChecker() *RealChecker {
+ return &RealChecker{}
+}
+
+// IsSessionAlive checks if a tmux session exists and is running
+func (r *RealChecker) IsSessionAlive(sessionName string) bool {
+ cmd := exec.Command("tmux", "has-session", "-t", sessionName)
+ err := cmd.Run()
+ return err == nil
+}
+
+// CaptureOutput captures the current pane output from a tmux session
+func (r *RealChecker) CaptureOutput(sessionName string) (string, error) {
+ cmd := exec.Command("tmux", "capture-pane", "-t", sessionName, "-p")
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("failed to capture tmux output for session %s: %w", sessionName, err)
+ }
+ return string(output), nil
+}
+
+// CreateSession creates a new tmux session with the specified command
+func (r *RealChecker) CreateSession(name, workdir, command string) error {
+ args := []string{
+ "new-session",
+ "-d", // detached
+ "-s", name, // session name
+ "-c", workdir, // working directory
+ }
+
+ if command != "" {
+ args = append(args, command)
+ }
+
+ cmd := exec.Command("tmux", args...)
+ err := cmd.Run()
+ if err != nil {
+ return fmt.Errorf("failed to create tmux session %s: %w", name, err)
+ }
+
+ // Enable mouse mode for the session to allow scrolling
+ mouseCmd := exec.Command("tmux", "set-option", "-t", name, "mouse", "on")
+ if err := mouseCmd.Run(); err != nil {
+ // Non-fatal error - session still usable without mouse mode
+ fmt.Printf("Warning: Failed to enable mouse mode for session %s: %v\n", name, err)
+ }
+
+ return nil
+}
+
+// KillSession terminates a tmux session
+func (r *RealChecker) KillSession(sessionName string) error {
+ cmd := exec.Command("tmux", "kill-session", "-t", sessionName)
+ err := cmd.Run()
+ if err != nil {
+ return fmt.Errorf("failed to kill tmux session %s: %w", sessionName, err)
+ }
+ return nil
+}
+
+// ListSessions returns a list of all active tmux sessions
+func (r *RealChecker) ListSessions() ([]string, error) {
+ cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}")
+ output, err := cmd.Output()
+ if err != nil {
+ // tmux returns exit code 1 when no sessions exist
+ if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
+ return []string{}, nil
+ }
+ return nil, fmt.Errorf("failed to list tmux sessions: %w", err)
+ }
+
+ sessions := strings.Split(strings.TrimSpace(string(output)), "\n")
+ if len(sessions) == 1 && sessions[0] == "" {
+ return []string{}, nil
+ }
+ return sessions, nil
+}
+
+// MockChecker implements Checker for testing
+type MockChecker struct {
+ AliveSessions map[string]bool
+ Output map[string]string
+ CreatedSessions []string
+ KilledSessions []string
+ ShouldFailCreate bool
+ Delay time.Duration
+}
+
+// NewMockChecker creates a new MockChecker
+func NewMockChecker() *MockChecker {
+ return &MockChecker{
+ AliveSessions: make(map[string]bool),
+ Output: make(map[string]string),
+ CreatedSessions: []string{},
+ KilledSessions: []string{},
+ }
+}
+
+// IsSessionAlive returns the mocked status
+func (m *MockChecker) IsSessionAlive(sessionName string) bool {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ return m.AliveSessions[sessionName]
+}
+
+// CaptureOutput returns the mocked output
+func (m *MockChecker) CaptureOutput(sessionName string) (string, error) {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ output, exists := m.Output[sessionName]
+ if !exists {
+ return "", fmt.Errorf("no output configured for session %s", sessionName)
+ }
+ return output, nil
+}
+
+// CreateSession mocks session creation
+func (m *MockChecker) CreateSession(name, workdir, command string) error {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ if m.ShouldFailCreate {
+ return fmt.Errorf("mock create failure for session %s", name)
+ }
+ m.CreatedSessions = append(m.CreatedSessions, name)
+ m.AliveSessions[name] = true
+ return nil
+}
+
+// KillSession mocks session termination
+func (m *MockChecker) KillSession(sessionName string) error {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ m.KilledSessions = append(m.KilledSessions, sessionName)
+ m.AliveSessions[sessionName] = false
+ return nil
+}
+
+// SetSessionAlive sets the alive status for a session
+func (m *MockChecker) SetSessionAlive(sessionName string, alive bool) {
+ m.AliveSessions[sessionName] = alive
+}
+
+// ListSessions returns all alive sessions
+func (m *MockChecker) ListSessions() ([]string, error) {
+ if m.Delay > 0 {
+ time.Sleep(m.Delay)
+ }
+ sessions := make([]string, 0)
+ for name, alive := range m.AliveSessions {
+ if alive {
+ sessions = append(sessions, name)
+ }
+ }
+ return sessions, nil
+}
+
+// SetAlive sets the alive status for testing
+func (m *MockChecker) SetAlive(sessionName string, alive bool) {
+ m.AliveSessions[sessionName] = alive
+}
+
+// SetOutput sets the output for testing
+func (m *MockChecker) SetOutput(sessionName, output string) {
+ m.Output[sessionName] = output
+}
+
+// SetDelay sets a delay for all operations (for performance testing)
+func (m *MockChecker) SetDelay(delay time.Duration) {
+ m.Delay = delay
+}
+
+
+
package events
+
+import (
+ "sync"
+
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// Bus provides a simple event bus for publishing and subscribing to events
+type Bus struct {
+ subscribers []chan types.Event
+ mu sync.RWMutex
+}
+
+// NewBus creates a new event bus
+func NewBus() *Bus {
+ return &Bus{
+ subscribers: make([]chan types.Event, 0),
+ }
+}
+
+// Subscribe returns a channel that will receive all published events
+func (b *Bus) Subscribe() <-chan types.Event {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ ch := make(chan types.Event, 100) // Buffered to prevent blocking
+ b.subscribers = append(b.subscribers, ch)
+ return ch
+}
+
+// Publish sends an event to all subscribers
+func (b *Bus) Publish(event types.Event) {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ for _, ch := range b.subscribers {
+ select {
+ case ch <- event:
+ // Event sent successfully
+ default:
+ // Subscriber channel is full, skip to prevent blocking
+ // In a production system, you might want to log this
+ }
+ }
+}
+
+// Close closes all subscriber channels
+func (b *Bus) Close() {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ for _, ch := range b.subscribers {
+ close(ch)
+ }
+ b.subscribers = nil
+}
+
+// SubscriberCount returns the number of active subscribers
+func (b *Bus) SubscriberCount() int {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+ return len(b.subscribers)
+}
+
+
+
package operations
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+)
+
+// CleanupStats tracks the results of a cleanup operation
+type CleanupStats struct {
+ StaleSessions int
+ OrphanedTmux int
+ OrphanedWorktrees int
+ Cleaned int
+ Failed int
+ Errors []string
+}
+
+// CleanupOperations provides business logic for cleanup operations
+type CleanupOperations struct {
+ stateManager *state.Manager
+}
+
+// NewCleanupOperations creates a new CleanupOperations instance
+func NewCleanupOperations(sm *state.Manager) *CleanupOperations {
+ return &CleanupOperations{
+ stateManager: sm,
+ }
+}
+
+// FindAndCleanupStaleResources finds and optionally cleans up stale CWT resources
+func (c *CleanupOperations) FindAndCleanupStaleResources(dryRun bool) (*CleanupStats, error) {
+ stats := &CleanupStats{
+ Errors: make([]string, 0),
+ }
+
+ // Find stale sessions
+ staleSessions, err := c.stateManager.FindStaleSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to find stale sessions: %w", err)
+ }
+ stats.StaleSessions = len(staleSessions)
+
+ // Clean up stale sessions
+ for _, session := range staleSessions {
+ if dryRun {
+ fmt.Printf("Would clean up stale session: %s (tmux: %s, worktree: %s)\n",
+ session.Core.Name, session.Core.TmuxSession, session.Core.WorktreePath)
+ continue
+ }
+
+ if err := c.stateManager.DeleteSession(session.Core.ID); err != nil {
+ stats.Failed++
+ errMsg := fmt.Sprintf("Failed to delete session %s: %v", session.Core.Name, err)
+ stats.Errors = append(stats.Errors, errMsg)
+ } else {
+ stats.Cleaned++
+ }
+ }
+
+ // Find orphaned tmux sessions
+ orphanedTmux, err := c.findOrphanedTmuxSessions()
+ if err != nil {
+ return stats, fmt.Errorf("failed to find orphaned tmux sessions: %w", err)
+ }
+ stats.OrphanedTmux = len(orphanedTmux)
+
+ // Clean up orphaned tmux sessions
+ for _, tmuxSession := range orphanedTmux {
+ if dryRun {
+ fmt.Printf("Would kill orphaned tmux session: %s\n", tmuxSession)
+ continue
+ }
+
+ if err := c.killTmuxSession(tmuxSession); err != nil {
+ stats.Failed++
+ errMsg := fmt.Sprintf("Failed to kill tmux session %s: %v", tmuxSession, err)
+ stats.Errors = append(stats.Errors, errMsg)
+ } else {
+ stats.Cleaned++
+ }
+ }
+
+ // Find orphaned worktrees
+ orphanedWorktrees, err := c.findOrphanedWorktrees()
+ if err != nil {
+ return stats, fmt.Errorf("failed to find orphaned worktrees: %w", err)
+ }
+ stats.OrphanedWorktrees = len(orphanedWorktrees)
+
+ // Clean up orphaned worktrees
+ for _, worktree := range orphanedWorktrees {
+ if dryRun {
+ fmt.Printf("Would remove orphaned worktree: %s\n", worktree)
+ continue
+ }
+
+ if err := c.removeWorktree(worktree); err != nil {
+ stats.Failed++
+ errMsg := fmt.Sprintf("Failed to remove worktree %s: %v", worktree, err)
+ stats.Errors = append(stats.Errors, errMsg)
+ } else {
+ stats.Cleaned++
+ }
+ }
+
+ return stats, nil
+}
+
+// findOrphanedTmuxSessions finds tmux sessions that start with "cwt-" but don't have corresponding CWT sessions
+func (c *CleanupOperations) findOrphanedTmuxSessions() ([]string, error) {
+ // Get all tmux sessions
+ tmuxSessions, err := c.stateManager.GetTmuxChecker().ListSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list tmux sessions: %w", err)
+ }
+
+ // Get current CWT sessions
+ sessions, err := c.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current sessions: %w", err)
+ }
+
+ // Create a map of active CWT tmux session names
+ activeTmux := make(map[string]bool)
+ for _, session := range sessions {
+ activeTmux[session.Core.TmuxSession] = true
+ }
+
+ // Find orphaned sessions
+ var orphaned []string
+ for _, tmuxSession := range tmuxSessions {
+ if strings.HasPrefix(tmuxSession, "cwt-") && !activeTmux[tmuxSession] {
+ orphaned = append(orphaned, tmuxSession)
+ }
+ }
+
+ return orphaned, nil
+}
+
+// findOrphanedWorktrees finds git worktrees in .cwt/worktrees/ that don't have corresponding CWT sessions
+func (c *CleanupOperations) findOrphanedWorktrees() ([]string, error) {
+ worktreesDir := filepath.Join(c.stateManager.GetDataDir(), "worktrees")
+
+ // Check if worktrees directory exists
+ if _, err := os.Stat(worktreesDir); os.IsNotExist(err) {
+ return nil, nil // No worktrees directory means no orphaned worktrees
+ }
+
+ // Get all worktree directories
+ entries, err := os.ReadDir(worktreesDir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read worktrees directory: %w", err)
+ }
+
+ // Get current CWT sessions
+ sessions, err := c.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current sessions: %w", err)
+ }
+
+ // Create a map of active session names
+ activeNames := make(map[string]bool)
+ for _, session := range sessions {
+ activeNames[session.Core.Name] = true
+ }
+
+ // Find orphaned worktrees
+ var orphaned []string
+ for _, entry := range entries {
+ if entry.IsDir() && !activeNames[entry.Name()] {
+ orphaned = append(orphaned, entry.Name())
+ }
+ }
+
+ return orphaned, nil
+}
+
+// killTmuxSession kills a tmux session
+func (c *CleanupOperations) killTmuxSession(sessionName string) error {
+ return c.stateManager.GetTmuxChecker().KillSession(sessionName)
+}
+
+// removeWorktree removes a git worktree
+func (c *CleanupOperations) removeWorktree(name string) error {
+ worktreePath := filepath.Join(c.stateManager.GetDataDir(), "worktrees", name)
+
+ // Use git worktree remove command
+ cmd := exec.Command("git", "worktree", "remove", worktreePath)
+ if err := cmd.Run(); err != nil {
+ // If git worktree remove fails, try force removal
+ cmd = exec.Command("git", "worktree", "remove", "--force", worktreePath)
+ if err := cmd.Run(); err != nil {
+ // If that also fails, remove the directory manually
+ return os.RemoveAll(worktreePath)
+ }
+ }
+
+ return nil
+}
+
+
package operations
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// StatusFormat defines how to format session status information
+type StatusFormat struct {
+ // Add configuration options if needed in the future
+}
+
+// NewStatusFormat creates a new StatusFormat instance
+func NewStatusFormat() *StatusFormat {
+ return &StatusFormat{}
+}
+
+// FormatTmuxStatus formats the tmux status with appropriate emoji and color
+func (f *StatusFormat) FormatTmuxStatus(isAlive bool) string {
+ if isAlive {
+ return "π’ alive"
+ }
+ return "π΄ dead"
+}
+
+// FormatClaudeStatus formats the Claude status with appropriate emoji and details
+func (f *StatusFormat) FormatClaudeStatus(claudeStatus types.ClaudeStatus) string {
+ switch claudeStatus.State {
+ case types.ClaudeWorking:
+ return "π΅ working"
+ case types.ClaudeWaiting:
+ if claudeStatus.StatusMessage != "" {
+ return fmt.Sprintf("βΈοΈ %s", claudeStatus.StatusMessage)
+ }
+ return "βΈοΈ waiting"
+ case types.ClaudeComplete:
+ return "β
complete"
+ case types.ClaudeIdle:
+ return "π‘ idle"
+ case types.ClaudeUnknown:
+ return "β unknown"
+ default:
+ return "β unknown"
+ }
+}
+
+// FormatGitStatus formats the git status with file change information
+func (f *StatusFormat) FormatGitStatus(gitStatus types.GitStatus) string {
+ if !gitStatus.HasChanges {
+ return "π’ clean"
+ }
+
+ parts := []string{}
+
+ if len(gitStatus.ModifiedFiles) > 0 {
+ if len(gitStatus.ModifiedFiles) == 1 {
+ parts = append(parts, "1 file")
+ } else {
+ parts = append(parts, fmt.Sprintf("%d files", len(gitStatus.ModifiedFiles)))
+ }
+ }
+
+ if len(gitStatus.UntrackedFiles) > 0 {
+ if len(gitStatus.UntrackedFiles) == 1 {
+ parts = append(parts, "1 untracked")
+ } else {
+ parts = append(parts, fmt.Sprintf("%d untracked", len(gitStatus.UntrackedFiles)))
+ }
+ }
+
+ if len(parts) == 0 {
+ return "π‘ changes"
+ }
+
+ return fmt.Sprintf("π‘ %s", strings.Join(parts, ", "))
+}
+
+// FormatActivity formats the last activity time
+func (f *StatusFormat) FormatActivity(lastActivity time.Time) string {
+ if lastActivity.IsZero() {
+ return "never"
+ }
+
+ duration := time.Since(lastActivity)
+ return f.FormatDuration(duration) + " ago"
+}
+
+// FormatDuration formats a duration in a human-readable way
+func (f *StatusFormat) FormatDuration(duration time.Duration) string {
+ if duration < time.Minute {
+ return "just now"
+ } else if duration < time.Hour {
+ minutes := int(duration.Minutes())
+ if minutes == 1 {
+ return "1 minute"
+ }
+ return fmt.Sprintf("%d minutes", minutes)
+ } else if duration < 24*time.Hour {
+ hours := int(duration.Hours())
+ if hours == 1 {
+ return "1 hour"
+ }
+ return fmt.Sprintf("%d hours", hours)
+ } else {
+ days := int(duration.Hours() / 24)
+ if days == 1 {
+ return "1 day"
+ }
+ return fmt.Sprintf("%d days", days)
+ }
+}
+
+// FormatSessionSummary creates a one-line summary of a session's status
+func (f *StatusFormat) FormatSessionSummary(session types.Session) string {
+ tmux := f.FormatTmuxStatus(session.IsAlive)
+ claude := f.FormatClaudeStatus(session.ClaudeStatus)
+ git := f.FormatGitStatus(session.GitStatus)
+ activity := f.FormatActivity(session.LastActivity)
+
+ return fmt.Sprintf("tmux: %s | claude: %s | git: %s | activity: %s",
+ tmux, claude, git, activity)
+}
+
+// FormatSessionList formats a list of sessions for display
+func (f *StatusFormat) FormatSessionList(sessions []types.Session, detailed bool) string {
+ if len(sessions) == 0 {
+ return "No sessions found."
+ }
+
+ var result strings.Builder
+
+ for i, session := range sessions {
+ if i > 0 {
+ result.WriteString("\n")
+ }
+
+ result.WriteString(fmt.Sprintf("π %s", session.Core.Name))
+
+ if detailed {
+ result.WriteString(fmt.Sprintf(" (%s)", session.Core.ID))
+ result.WriteString(fmt.Sprintf("\n %s", f.FormatSessionSummary(session)))
+ result.WriteString(fmt.Sprintf("\n π %s", session.Core.WorktreePath))
+ } else {
+ result.WriteString(fmt.Sprintf(" - %s", f.FormatSessionSummary(session)))
+ }
+ }
+
+ return result.String()
+}
+
+
package operations
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// SessionOperations provides business logic for session management
+type SessionOperations struct {
+ stateManager *state.Manager
+}
+
+// NewSessionOperations creates a new SessionOperations instance
+func NewSessionOperations(sm *state.Manager) *SessionOperations {
+ return &SessionOperations{
+ stateManager: sm,
+ }
+}
+
+// CreateSession creates a new session with the given name
+func (s *SessionOperations) CreateSession(name string) error {
+ return s.stateManager.CreateSession(name)
+}
+
+// DeleteSession deletes the session with the given ID
+func (s *SessionOperations) DeleteSession(sessionID string) error {
+ return s.stateManager.DeleteSession(sessionID)
+}
+
+// FindSessionByName finds a session by its name
+// Returns the session and its ID, or an error if not found
+func (s *SessionOperations) FindSessionByName(name string) (*types.Session, string, error) {
+ sessions, err := s.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ for _, session := range sessions {
+ if session.Core.Name == name {
+ return &session, session.Core.ID, nil
+ }
+ }
+
+ return nil, "", fmt.Errorf("session '%s' not found", name)
+}
+
+// FindSessionByID finds a session by its ID
+func (s *SessionOperations) FindSessionByID(sessionID string) (*types.Session, error) {
+ sessions, err := s.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ for _, session := range sessions {
+ if session.Core.ID == sessionID {
+ return &session, nil
+ }
+ }
+
+ return nil, fmt.Errorf("session with ID '%s' not found", sessionID)
+}
+
+// GetAllSessions returns all current sessions
+func (s *SessionOperations) GetAllSessions() ([]types.Session, error) {
+ return s.stateManager.DeriveFreshSessions()
+}
+
+// RecreateDeadSession recreates a tmux session for a session that has died
+// This handles Claude session resumption if a previous session exists
+func (s *SessionOperations) RecreateDeadSession(session *types.Session) error {
+ claudeExec := FindClaudeExecutable()
+ if claudeExec == "" {
+ return fmt.Errorf("claude executable not found in PATH")
+ }
+
+ command := claudeExec
+
+ // Check if there's an existing Claude session to resume
+ if existingSessionID, err := s.stateManager.GetClaudeChecker().FindSessionID(session.Core.WorktreePath); err == nil && existingSessionID != "" {
+ command = fmt.Sprintf("%s -r %s", claudeExec, existingSessionID)
+ }
+
+ // Create the tmux session
+ tmuxChecker := s.stateManager.GetTmuxChecker()
+ return tmuxChecker.CreateSession(session.Core.TmuxSession, session.Core.WorktreePath, command)
+}
+
+// FindClaudeExecutable searches for the Claude CLI executable in common locations
+func FindClaudeExecutable() string {
+ claudePaths := []string{
+ "claude",
+ os.ExpandEnv("$HOME/.claude/local/claude"),
+ os.ExpandEnv("$HOME/.claude/local/node_modules/.bin/claude"),
+ "/usr/local/bin/claude",
+ }
+
+ for _, path := range claudePaths {
+ if _, err := exec.LookPath(path); err == nil {
+ return path
+ }
+ }
+
+ return ""
+}
+
+
package state
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/jlaneve/cwt-cli/internal/clients/claude"
+ "github.com/jlaneve/cwt-cli/internal/clients/git"
+ "github.com/jlaneve/cwt-cli/internal/clients/tmux"
+ "github.com/jlaneve/cwt-cli/internal/events"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// Config holds configuration for the StateManager
+type Config struct {
+ DataDir string // Directory for storing session data (e.g., ".cwt")
+ TmuxChecker tmux.Checker // Injectable tmux operations
+ ClaudeChecker claude.Checker // Injectable Claude operations
+ GitChecker git.Checker // Injectable git operations
+ BaseBranch string // Base branch for creating worktrees (default: "main")
+}
+
+// Manager handles all session state operations
+type Manager struct {
+ config Config
+ eventBus *events.Bus
+ mu sync.RWMutex
+ dataFile string
+}
+
+// NewManager creates a new StateManager with the given configuration
+func NewManager(config Config) *Manager {
+ if config.DataDir == "" {
+ config.DataDir = ".cwt"
+ }
+ if config.BaseBranch == "" {
+ config.BaseBranch = "main"
+ }
+
+ // Use real checkers if not provided
+ if config.TmuxChecker == nil {
+ config.TmuxChecker = tmux.NewRealChecker()
+ }
+ if config.GitChecker == nil {
+ config.GitChecker = git.NewRealChecker(config.BaseBranch)
+ }
+ if config.ClaudeChecker == nil {
+ config.ClaudeChecker = claude.NewRealChecker(config.TmuxChecker)
+ }
+
+ return &Manager{
+ config: config,
+ eventBus: events.NewBus(),
+ dataFile: filepath.Join(config.DataDir, "sessions.json"),
+ }
+}
+
+// EventBus returns the event bus for subscribing to events
+func (m *Manager) EventBus() <-chan types.Event {
+ return m.eventBus.Subscribe()
+}
+
+// DeriveFreshSessions loads core sessions and derives complete state from external systems
+func (m *Manager) DeriveFreshSessions() ([]types.Session, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ cores, err := m.loadCoreSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to load core sessions: %w", err)
+ }
+
+ sessions := make([]types.Session, len(cores))
+ for i, core := range cores {
+ sessions[i] = m.deriveSession(core)
+ }
+
+ return sessions, nil
+}
+
+// CreateSession creates a new session with all required resources
+func (m *Manager) CreateSession(name string) error {
+ // Validate session name
+ if err := validateSessionName(name); err != nil {
+ return fmt.Errorf("invalid session name: %w", err)
+ }
+
+ // Emit immediate event for UI feedback
+ m.eventBus.Publish(types.SessionCreationStarted{
+ Name: name,
+ })
+
+ // Generate core session
+ core := types.CoreSession{
+ ID: generateSessionID(),
+ Name: name,
+ WorktreePath: filepath.Join(m.config.DataDir, "worktrees", name),
+ TmuxSession: fmt.Sprintf("cwt-%s", name),
+ CreatedAt: time.Now(),
+ }
+
+ // Check for duplicate session name
+ if err := m.checkDuplicateName(name); err != nil {
+ m.eventBus.Publish(types.SessionCreationFailed{
+ Name: name,
+ Error: err.Error(),
+ })
+ return err
+ }
+
+ // Create external resources with rollback on failure
+ if err := m.createExternalResources(core); err != nil {
+ m.eventBus.Publish(types.SessionCreationFailed{
+ Name: name,
+ Error: err.Error(),
+ })
+ return err
+ }
+
+ // Save to persistent storage
+ if err := m.addCoreSession(core); err != nil {
+ // Rollback external resources
+ m.cleanupExternalResources(core)
+ m.eventBus.Publish(types.SessionCreationFailed{
+ Name: name,
+ Error: err.Error(),
+ })
+ return fmt.Errorf("failed to save session: %w", err)
+ }
+
+ // Emit success event with derived session
+ session := m.deriveSession(core)
+ m.eventBus.Publish(types.SessionCreated{Session: session})
+
+ return nil
+}
+
+// DeleteSession removes a session and all its resources
+func (m *Manager) DeleteSession(sessionID string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ cores, err := m.loadCoreSessions()
+ if err != nil {
+ return fmt.Errorf("failed to load sessions: %w", err)
+ }
+
+ // Find session to delete
+ var sessionToDelete *types.CoreSession
+ var newCores []types.CoreSession
+
+ for _, core := range cores {
+ if core.ID == sessionID {
+ sessionToDelete = &core
+ } else {
+ newCores = append(newCores, core)
+ }
+ }
+
+ if sessionToDelete == nil {
+ err := fmt.Errorf("session with ID %s not found", sessionID)
+ m.eventBus.Publish(types.SessionDeletionFailed{
+ SessionID: sessionID,
+ Error: err.Error(),
+ })
+ return err
+ }
+
+ // Clean up external resources
+ m.cleanupExternalResources(*sessionToDelete)
+
+ // Save updated session list
+ if err := m.saveCoreSessions(newCores); err != nil {
+ err := fmt.Errorf("failed to save updated sessions: %w", err)
+ m.eventBus.Publish(types.SessionDeletionFailed{
+ SessionID: sessionID,
+ Error: err.Error(),
+ })
+ return err
+ }
+
+ // Emit success event
+ m.eventBus.Publish(types.SessionDeleted{SessionID: sessionID})
+
+ return nil
+}
+
+// FindStaleSessions returns sessions that have dead tmux sessions
+func (m *Manager) FindStaleSessions() ([]types.Session, error) {
+ sessions, err := m.DeriveFreshSessions()
+ if err != nil {
+ return nil, err
+ }
+
+ var stale []types.Session
+ for _, session := range sessions {
+ if !session.IsAlive {
+ stale = append(stale, session)
+ }
+ }
+
+ return stale, nil
+}
+
+// Private methods
+
+func (m *Manager) deriveSession(core types.CoreSession) types.Session {
+ session := types.Session{
+ Core: core,
+ IsAlive: m.config.TmuxChecker.IsSessionAlive(core.TmuxSession),
+ GitStatus: m.config.GitChecker.GetStatus(core.WorktreePath),
+ }
+
+ // Load Claude status from session state file (preferred) or fallback to checker
+ if sessionState, err := types.LoadSessionState(m.config.DataDir, core.ID); err == nil && sessionState != nil {
+ session.ClaudeStatus = types.GetClaudeStatusFromState(sessionState)
+ } else {
+ // Fallback to old JSONL scanning if no session state
+ session.ClaudeStatus = m.config.ClaudeChecker.GetStatus(core.WorktreePath)
+ }
+
+ // Calculate last activity from available timestamps
+ session.LastActivity = m.calculateLastActivity(session)
+
+ return session
+}
+
+func (m *Manager) calculateLastActivity(session types.Session) time.Time {
+ lastActivity := session.Core.CreatedAt
+
+ // Consider Claude activity
+ if !session.ClaudeStatus.LastMessage.IsZero() && session.ClaudeStatus.LastMessage.After(lastActivity) {
+ lastActivity = session.ClaudeStatus.LastMessage
+ }
+
+ // Consider git activity (would need file stat times, simplified for now)
+ // In a full implementation, you'd check git log timestamps
+
+ return lastActivity
+}
+
+func (m *Manager) loadCoreSessions() ([]types.CoreSession, error) {
+ if _, err := os.Stat(m.dataFile); os.IsNotExist(err) {
+ return []types.CoreSession{}, nil
+ }
+
+ data, err := os.ReadFile(m.dataFile)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read sessions file: %w", err)
+ }
+
+ var sessionData types.SessionData
+ if err := json.Unmarshal(data, &sessionData); err != nil {
+ return nil, fmt.Errorf("sessions file corrupted: %w", err)
+ }
+
+ return sessionData.Sessions, nil
+}
+
+func (m *Manager) saveCoreSessions(sessions []types.CoreSession) error {
+ // Ensure data directory exists
+ if err := os.MkdirAll(m.config.DataDir, 0755); err != nil {
+ return fmt.Errorf("failed to create data directory: %w", err)
+ }
+
+ sessionData := types.SessionData{Sessions: sessions}
+ data, err := json.MarshalIndent(sessionData, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal sessions: %w", err)
+ }
+
+ // Atomic write using temporary file
+ tempFile := m.dataFile + ".tmp"
+ if err := os.WriteFile(tempFile, data, 0644); err != nil {
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ if err := os.Rename(tempFile, m.dataFile); err != nil {
+ os.Remove(tempFile) // Cleanup temp file
+ return fmt.Errorf("failed to rename temp file: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) addCoreSession(core types.CoreSession) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ sessions, err := m.loadCoreSessions()
+ if err != nil {
+ return err
+ }
+
+ sessions = append(sessions, core)
+ return m.saveCoreSessions(sessions)
+}
+
+func (m *Manager) checkDuplicateName(name string) error {
+ sessions, err := m.loadCoreSessions()
+ if err != nil {
+ return err
+ }
+
+ for _, session := range sessions {
+ if session.Name == name {
+ return fmt.Errorf("session with name '%s' already exists", name)
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) createExternalResources(core types.CoreSession) error {
+ // Validate git repository first
+ if err := m.config.GitChecker.IsValidRepository(""); err != nil {
+ return fmt.Errorf("git repository validation failed: %w", err)
+ }
+
+ // Create git worktree
+ if err := m.config.GitChecker.CreateWorktree(core.Name, core.WorktreePath); err != nil {
+ return fmt.Errorf("failed to create git worktree: %w", err)
+ }
+
+ // Create Claude settings with hooks in the worktree
+ if err := m.createClaudeSettings(core.WorktreePath, core.ID); err != nil {
+ // Rollback git worktree
+ m.config.GitChecker.RemoveWorktree(core.WorktreePath)
+ return fmt.Errorf("failed to create Claude settings: %w", err)
+ }
+
+ // Create tmux session
+ // Check if claude is available, otherwise create session without it
+ var command string
+ if claudeExec := m.findClaudeExecutable(); claudeExec != "" {
+ command = claudeExec
+ }
+
+ err := m.config.TmuxChecker.CreateSession(core.TmuxSession, core.WorktreePath, command)
+ if err != nil {
+ // Rollback git worktree
+ m.config.GitChecker.RemoveWorktree(core.WorktreePath)
+ return fmt.Errorf("failed to create tmux session: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) cleanupExternalResources(core types.CoreSession) {
+ // Kill tmux session (ignore errors)
+ m.config.TmuxChecker.KillSession(core.TmuxSession)
+
+ // Remove git worktree (ignore errors)
+ m.config.GitChecker.RemoveWorktree(core.WorktreePath)
+
+ // Remove session state file (ignore errors)
+ types.RemoveSessionState(m.config.DataDir, core.ID)
+}
+
+func generateSessionID() string {
+ return fmt.Sprintf("session-%d", time.Now().UnixNano())
+}
+
+// createClaudeSettings creates a settings.json file in the worktree with CWT hooks configured
+func (m *Manager) createClaudeSettings(worktreePath, sessionID string) error {
+ claudeDir := filepath.Join(worktreePath, ".claude")
+ settingsPath := filepath.Join(claudeDir, "settings.json")
+
+ // Ensure .claude directory exists
+ if err := os.MkdirAll(claudeDir, 0755); err != nil {
+ return fmt.Errorf("failed to create .claude directory: %w", err)
+ }
+
+ // Get the current cwt executable path
+ cwtPath := m.getCwtExecutablePath()
+
+ settings := map[string]interface{}{
+ "hooks": map[string]interface{}{
+ "Notification": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s notification", cwtPath, sessionID),
+ },
+ },
+ },
+ },
+ "Stop": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s stop", cwtPath, sessionID),
+ },
+ },
+ },
+ },
+ "PreToolUse": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s pre_tool_use", cwtPath, sessionID),
+ },
+ },
+ },
+ },
+ "PostToolUse": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s post_tool_use", cwtPath, sessionID),
+ },
+ },
+ },
+ },
+ "SubagentStop": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s subagent_stop", cwtPath, sessionID),
+ },
+ },
+ },
+ },
+ "PreCompact": []map[string]interface{}{
+ {
+ "matcher": "",
+ "hooks": []map[string]interface{}{
+ {
+ "type": "command",
+ "command": fmt.Sprintf("%s __hook %s pre_compact", cwtPath, sessionID),
+ },
+ },
+ },
+ },
+ },
+ }
+
+ data, err := json.MarshalIndent(settings, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal Claude settings: %w", err)
+ }
+
+ if err := os.WriteFile(settingsPath, data, 0644); err != nil {
+ return fmt.Errorf("failed to write Claude settings file: %w", err)
+ }
+
+ return nil
+}
+
+// getCwtExecutablePath determines the best path to use for cwt executable
+func (m *Manager) getCwtExecutablePath() string {
+ // First, try to find cwt in PATH (most reliable for installed binaries)
+ if path, err := exec.LookPath("cwt"); err == nil {
+ return path
+ }
+
+ // Check if we're running from go run (has temp executable path)
+ if execPath, err := os.Executable(); err == nil {
+ // If it's a temp path from go run, use absolute path to "go run cmd/cwt/main.go"
+ if strings.Contains(execPath, "go-build") || strings.Contains(execPath, "/tmp/") {
+ // Get current working directory to build absolute path
+ if wd, err := os.Getwd(); err == nil {
+ // Check if we're in the cwt project directory
+ mainGoPath := filepath.Join(wd, "cmd/cwt/main.go")
+ if _, err := os.Stat(mainGoPath); err == nil {
+ return fmt.Sprintf("cd %s && go run cmd/cwt/main.go", wd)
+ }
+ }
+ } else {
+ // It's a real executable path
+ return execPath
+ }
+ }
+
+ // Final fallback to "cwt" in PATH
+ return "cwt"
+}
+
+// findClaudeExecutable searches for claude in common installation paths
+func (m *Manager) findClaudeExecutable() string {
+ // Check common installation paths
+ claudePaths := []string{
+ "claude",
+ os.ExpandEnv("$HOME/.claude/local/claude"),
+ os.ExpandEnv("$HOME/.claude/local/node_modules/.bin/claude"),
+ "/usr/local/bin/claude",
+ }
+
+ for _, path := range claudePaths {
+ cmd := exec.Command(path, "--version")
+ if err := cmd.Run(); err == nil {
+ return path
+ }
+ }
+
+ return ""
+}
+
+// GetDataDir returns the data directory path
+func (m *Manager) GetDataDir() string {
+ return m.config.DataDir
+}
+
+// GetTmuxChecker returns the tmux checker for direct access
+func (m *Manager) GetTmuxChecker() tmux.Checker {
+ return m.config.TmuxChecker
+}
+
+// GetClaudeChecker returns the claude checker for direct access
+func (m *Manager) GetClaudeChecker() claude.Checker {
+ return m.config.ClaudeChecker
+}
+
+// Close cleans up the manager resources
+func (m *Manager) Close() {
+ m.eventBus.Close()
+}
+
+
+
package state
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+// validateSessionName validates a session name according to git branch naming rules
+// Based on the validation logic from archive/internal/cli/new.go
+func validateSessionName(name string) error {
+ if name == "" {
+ return fmt.Errorf("session name cannot be empty")
+ }
+
+ if len(name) > 50 {
+ return fmt.Errorf("session name too long (max 50 characters)")
+ }
+
+ // Check for invalid characters
+ invalidChars := []string{" ", "~", "^", ":", "?", "*", "[", "\\", "..", "@{"}
+ for _, char := range invalidChars {
+ if strings.Contains(name, char) {
+ return fmt.Errorf("invalid characters in session name: '%s' not allowed", char)
+ }
+ }
+
+ // Cannot start or end with certain characters
+ invalidStartEnd := []string{"-", "/", "."}
+ for _, char := range invalidStartEnd {
+ if strings.HasPrefix(name, char) || strings.HasSuffix(name, char) {
+ return fmt.Errorf("session name cannot start or end with '%s'", char)
+ }
+ }
+
+ // Cannot be just numbers
+ if isNumericOnly(name) {
+ return fmt.Errorf("session name cannot be just numbers")
+ }
+
+ // Check for reserved names
+ reservedNames := []string{"main", "master", "HEAD", "refs"}
+ for _, reserved := range reservedNames {
+ if strings.EqualFold(name, reserved) {
+ return fmt.Errorf("'%s' is a reserved name and cannot be used", name)
+ }
+ }
+
+ // Additional git ref name validation
+ if !isValidGitRefName(name) {
+ return fmt.Errorf("session name must be a valid git branch name")
+ }
+
+ return nil
+}
+
+func isNumericOnly(s string) bool {
+ _, err := strconv.Atoi(s)
+ return err == nil
+}
+
+func isValidGitRefName(name string) bool {
+ // Git ref name rules (simplified):
+ // - ASCII control characters (< 32 or 127) are not allowed
+ // - Space, ~, ^, :, ?, *, [, \ are not allowed (already checked above)
+ // - Cannot be empty or start with /
+ // - Cannot end with .lock
+ // - Cannot contain .. or @{
+ // - Cannot start or end with /
+ // - No zero-width or format characters
+
+ if strings.HasSuffix(name, ".lock") {
+ return false
+ }
+
+ // Check for ASCII control characters and problematic Unicode characters
+ for _, r := range name {
+ if r < 32 || r == 127 {
+ return false
+ }
+ // Only reject zero-width and format characters that cause issues
+ // Cf = Format characters (includes zero-width space, etc.)
+ if unicode.In(r, unicode.Cf) {
+ return false
+ }
+ }
+
+ // Must contain at least one valid character that's not a special character
+ validCharRegex := regexp.MustCompile(`[a-zA-Z0-9_-]`)
+ if !validCharRegex.MatchString(name) {
+ return false
+ }
+
+ return true
+}
+
+
+
package tui
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/fsnotify/fsnotify"
+
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// startEventChannelListener creates a command that listens for file events
+func (m Model) startEventChannelListener() tea.Cmd {
+ return func() tea.Msg {
+ // This will block until an event is received
+ return <-m.eventChan
+ }
+}
+
+// File watching setup
+func (m Model) setupFileWatching() tea.Cmd {
+ return func() tea.Msg {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return errorMsg{err: fmt.Errorf("failed to create file watcher: %w", err)}
+ }
+
+ // Watch session state directory for hook events
+ sessionStateDir := filepath.Join(m.stateManager.GetDataDir(), "session-state")
+ if err := os.MkdirAll(sessionStateDir, 0755); err == nil {
+ if err := watcher.Add(sessionStateDir); err != nil {
+ return errorMsg{err: fmt.Errorf("failed to watch session state directory: %w", err)}
+ }
+ if debugLogger != nil {
+ debugLogger.Printf("Watching session state directory: %s", sessionStateDir)
+ }
+ }
+
+ // Watch sessions.json for session CRUD
+ sessionsFile := filepath.Join(m.stateManager.GetDataDir(), "sessions.json")
+ if _, err := os.Stat(sessionsFile); err == nil {
+ if err := watcher.Add(sessionsFile); err != nil {
+ return errorMsg{err: fmt.Errorf("failed to watch sessions file: %w", err)}
+ }
+ if debugLogger != nil {
+ debugLogger.Printf("Watching sessions file: %s", sessionsFile)
+ }
+ }
+
+ // Watch git index files for each session
+ for _, session := range m.sessions {
+ m.addSessionWatches(watcher, session)
+ if debugLogger != nil {
+ gitIndexPath := filepath.Join(session.Core.WorktreePath, ".git", "index")
+ debugLogger.Printf("Watching git index for session %s: %s", session.Core.Name, gitIndexPath)
+ }
+ }
+
+ // Store the eventChan in the watcher context
+ eventChan := m.eventChan
+
+ // Start listening for file events
+ go func() {
+ for {
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return
+ }
+
+ // Debug logging for file events
+ if debugLogger != nil {
+ debugLogger.Printf("File event: %s %s", event.Op, event.Name)
+ }
+
+ // Determine event type based on file path and send appropriate message
+ if filepath.Base(filepath.Dir(event.Name)) == "session-state" {
+ // Session state change (hook event)
+ go func() {
+ time.Sleep(100 * time.Millisecond) // Debounce
+ if debugLogger != nil {
+ debugLogger.Printf("Sending sessionStateChangedMsg for: %s", event.Name)
+ }
+ select {
+ case eventChan <- sessionStateChangedMsg{}:
+ default: // Channel full, skip this event
+ if debugLogger != nil {
+ debugLogger.Printf("Event channel full, skipping sessionStateChangedMsg")
+ }
+ }
+ }()
+ } else if filepath.Base(event.Name) == "sessions.json" {
+ // Session list change
+ go func() {
+ time.Sleep(100 * time.Millisecond) // Debounce
+ if debugLogger != nil {
+ debugLogger.Printf("Sending sessionListChangedMsg for: %s", event.Name)
+ }
+ select {
+ case eventChan <- sessionListChangedMsg{}:
+ default: // Channel full, skip this event
+ if debugLogger != nil {
+ debugLogger.Printf("Event channel full, skipping sessionListChangedMsg")
+ }
+ }
+ }()
+ } else if filepath.Base(event.Name) == "index" {
+ // Git index change
+ sessionID := m.getSessionIDFromPath(event.Name)
+ if sessionID != "" {
+ go func(sID string) {
+ time.Sleep(100 * time.Millisecond) // Debounce
+ if debugLogger != nil {
+ debugLogger.Printf("Sending gitIndexChangedMsg for session: %s", sID)
+ }
+ select {
+ case eventChan <- gitIndexChangedMsg{sessionID: sID}:
+ default: // Channel full, skip this event
+ if debugLogger != nil {
+ debugLogger.Printf("Event channel full, skipping gitIndexChangedMsg")
+ }
+ }
+ }(sessionID)
+ }
+ }
+
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return
+ }
+ // Send error message to TUI
+ select {
+ case eventChan <- errorMsg{err: fmt.Errorf("file watcher error: %w", err)}:
+ default: // Channel full, skip this event
+ }
+ }
+ }
+ }()
+
+ // Return the watcher setup message so the model can store it
+ return fileWatcherSetupMsg{watcher: watcher}
+ }
+}
+
+// Helper to add git index watching for a session
+func (m Model) addSessionWatches(watcher *fsnotify.Watcher, session types.Session) {
+ gitIndexPath := filepath.Join(session.Core.WorktreePath, ".git", "index")
+ if _, err := os.Stat(gitIndexPath); err == nil {
+ watcher.Add(gitIndexPath)
+ }
+}
+
+// addNewSessionWatches adds file watches for a newly created session
+func (m Model) addNewSessionWatches(session types.Session) {
+ if m.fileWatcher != nil {
+ m.addSessionWatches(m.fileWatcher, session)
+ }
+}
+
+// Helper to extract session ID from git index path
+func (m Model) getSessionIDFromPath(path string) string {
+ // Extract session ID from path like .cwt/worktrees/session-name/.git/index
+ // This is a simplified version - in practice, we'd need to map paths to session IDs
+ for _, session := range m.sessions {
+ if filepath.Dir(path) == filepath.Join(session.Core.WorktreePath, ".git") {
+ return session.Core.ID
+ }
+ }
+ return ""
+}
+
+// Polling commands
+func (m Model) startGitPolling() tea.Cmd {
+ return tea.Every(10*time.Second, func(time.Time) tea.Msg {
+ return gitStatusRefreshMsg{}
+ })
+}
+
+func (m Model) startTmuxPolling() tea.Cmd {
+ return tea.Every(30*time.Second, func(time.Time) tea.Msg {
+ return tmuxStatusRefreshMsg{}
+ })
+}
+
+// Session management commands
+func (m Model) refreshSessions() tea.Cmd {
+ return func() tea.Msg {
+ sessions, err := m.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return errorMsg{err: fmt.Errorf("failed to refresh sessions: %w", err)}
+ }
+ return refreshCompleteMsg{sessions: sessions}
+ }
+}
+
+func (m Model) refreshSessionGitStatus(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ // Refresh just git status for specific session
+ // For now, refresh all sessions (optimize later)
+ sessions, err := m.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return errorMsg{err: fmt.Errorf("failed to refresh git status: %w", err)}
+ }
+ return refreshCompleteMsg{sessions: sessions}
+ }
+}
+
+func (m Model) refreshAllGitStatus() tea.Cmd {
+ return m.refreshSessions() // For now, just refresh everything
+}
+
+func (m Model) refreshTmuxStatus() tea.Cmd {
+ return m.refreshSessions() // For now, just refresh everything
+}
+
+// User action commands
+func (m Model) handleAttach(sessionID string) tea.Cmd {
+ // Get access to the logger from model.go
+ return func() tea.Msg {
+ if debugLogger != nil {
+ debugLogger.Printf("handleAttach: Called with sessionID: %s", sessionID)
+ }
+
+ session := m.findSession(sessionID)
+ if session == nil {
+ if debugLogger != nil {
+ debugLogger.Printf("handleAttach: Session not found for ID: %s", sessionID)
+ }
+ return errorMsg{err: fmt.Errorf("session not found")}
+ }
+
+ if debugLogger != nil {
+ debugLogger.Printf("handleAttach: Found session: %s, IsAlive: %v", session.Core.Name, session.IsAlive)
+ }
+
+ if !session.IsAlive {
+ if debugLogger != nil {
+ debugLogger.Printf("handleAttach: Session %s is dead, showing confirmation dialog", session.Core.Name)
+ }
+ // Return a command to show confirmation dialog
+ return showConfirmDialogMsg{
+ message: fmt.Sprintf("Session '%s' tmux is not running. Recreate it?", session.Core.Name),
+ onYes: func() tea.Cmd {
+ return m.recreateAndAttach(sessionID)
+ },
+ onNo: func() tea.Cmd {
+ return nil
+ },
+ }
+ }
+
+ if debugLogger != nil {
+ debugLogger.Printf("handleAttach: Session %s is alive, proceeding to attach", session.Core.Name)
+ }
+ // Attach to alive session
+ return m.attachToSession(sessionID)
+ }
+}
+
+func (m Model) recreateAndAttach(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ session := m.findSession(sessionID)
+ if session == nil {
+ return errorMsg{err: fmt.Errorf("session not found")}
+ }
+
+ // Recreate the tmux session directly (worktree already exists)
+ // Find claude executable
+ claudeExec := m.findClaudeExecutable()
+ var command string
+ if claudeExec != "" {
+ // Check if there's an existing Claude session to resume for this worktree
+ if existingSessionID, err := m.stateManager.GetClaudeChecker().FindSessionID(session.Core.WorktreePath); err == nil && existingSessionID != "" {
+ command = fmt.Sprintf("%s -r %s", claudeExec, existingSessionID)
+ if debugLogger != nil {
+ debugLogger.Printf("Resuming Claude session %s for worktree %s", existingSessionID, session.Core.WorktreePath)
+ }
+ } else {
+ command = claudeExec
+ if debugLogger != nil {
+ debugLogger.Printf("Starting new Claude session for worktree %s", session.Core.WorktreePath)
+ }
+ }
+ }
+
+ // Create new tmux session
+ if err := m.stateManager.GetTmuxChecker().CreateSession(
+ session.Core.TmuxSession,
+ session.Core.WorktreePath,
+ command,
+ ); err != nil {
+ return errorMsg{err: fmt.Errorf("failed to recreate tmux session: %w", err)}
+ }
+
+ // Now request attachment
+ return attachRequestMsg{sessionName: session.Core.TmuxSession}
+ }
+}
+
+func (m Model) attachToSession(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ if debugLogger != nil {
+ debugLogger.Printf("attachToSession: Called with sessionID: %s", sessionID)
+ }
+
+ session := m.findSession(sessionID)
+ if session == nil {
+ if debugLogger != nil {
+ debugLogger.Printf("attachToSession: Session not found for ID: %s", sessionID)
+ }
+ return errorMsg{err: fmt.Errorf("session not found")}
+ }
+
+ if debugLogger != nil {
+ debugLogger.Printf("attachToSession: Returning attachRequestMsg for tmux session: %s", session.Core.TmuxSession)
+ }
+
+ // Return a special message that tells the TUI to exit and attach
+ return attachRequestMsg{sessionName: session.Core.TmuxSession}
+ }
+}
+
+// startSessionCreation is no longer needed - replaced with overlay dialog
+
+func (m Model) confirmDelete(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ session := m.findSession(sessionID)
+ if session == nil {
+ return errorMsg{err: fmt.Errorf("session not found")}
+ }
+
+ return showConfirmDialogMsg{
+ message: fmt.Sprintf("Delete session '%s' and all its resources?", session.Core.Name),
+ onYes: func() tea.Cmd {
+ return m.deleteSession(sessionID)
+ },
+ onNo: func() tea.Cmd {
+ return nil
+ },
+ }
+ }
+}
+
+func (m Model) deleteSession(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ if err := m.stateManager.DeleteSession(sessionID); err != nil {
+ return errorMsg{err: fmt.Errorf("failed to delete session: %w", err)}
+ }
+
+ // Refresh session list after deletion
+ sessions, err := m.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return errorMsg{err: fmt.Errorf("failed to refresh after deletion: %w", err)}
+ }
+
+ return refreshCompleteMsg{sessions: sessions}
+ }
+}
+
+func (m Model) runCleanup() tea.Cmd {
+ return func() tea.Msg {
+ // Find and clean up stale sessions
+ staleSessions, err := m.stateManager.FindStaleSessions()
+ if err != nil {
+ return errorMsg{err: fmt.Errorf("failed to find stale sessions: %w", err)}
+ }
+
+ if len(staleSessions) == 0 {
+ return errorMsg{err: fmt.Errorf("no orphaned sessions found")}
+ }
+
+ // Clean up each stale session
+ for _, session := range staleSessions {
+ if err := m.stateManager.DeleteSession(session.Core.ID); err != nil {
+ return errorMsg{err: fmt.Errorf("failed to cleanup session %s: %w", session.Core.Name, err)}
+ }
+ }
+
+ // Refresh session list after cleanup
+ sessions, err := m.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return errorMsg{err: fmt.Errorf("failed to refresh after cleanup: %w", err)}
+ }
+
+ return refreshCompleteMsg{sessions: sessions}
+ }
+}
+
+// findClaudeExecutable searches for claude in common installation paths
+func (m Model) findClaudeExecutable() string {
+ // Check common installation paths
+ claudePaths := []string{
+ "claude",
+ os.ExpandEnv("$HOME/.claude/local/claude"),
+ os.ExpandEnv("$HOME/.claude/local/node_modules/.bin/claude"),
+ "/usr/local/bin/claude",
+ }
+
+ for _, path := range claudePaths {
+ cmd := exec.Command(path, "--version")
+ if err := cmd.Run(); err == nil {
+ return path
+ }
+ }
+
+ return ""
+}
+
+
+
package tui
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/fsnotify/fsnotify"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// Global logger for debugging
+var debugLogger *log.Logger
+
+func init() {
+ // Create debug log file
+ logFile, err := os.OpenFile("cwt-tui-debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
+ if err == nil {
+ debugLogger = log.New(logFile, "[TUI-DEBUG] ", log.LstdFlags|log.Lshortfile)
+ debugLogger.Println("=== TUI Debug Session Started ===")
+ }
+}
+
+// Model represents the main TUI state
+type Model struct {
+ stateManager *state.Manager
+ sessions []types.Session
+ fileWatcher *fsnotify.Watcher
+ showHelp bool
+ confirmDialog *ConfirmDialog
+ newSessionDialog *NewSessionDialog
+ lastError string
+ successMessage string // For success toast notifications
+ ready bool
+ attachOnExit string // Session name to attach to when exiting TUI
+
+ // Terminal dimensions
+ width int
+ height int
+
+ // Split-pane state
+ selectedIndex int // Which session is selected in the left panel
+
+ // Session creation tracking
+ creatingSessions map[string]bool // Track sessions being created
+
+ // Event channel for file watching
+ eventChan chan tea.Msg
+}
+
+// ConfirmDialog represents a yes/no confirmation dialog
+type ConfirmDialog struct {
+ Message string
+ OnYes func() tea.Cmd
+ OnNo func() tea.Cmd
+}
+
+// NewSessionDialog represents a new session creation dialog
+type NewSessionDialog struct {
+ NameInput string
+ Error string
+}
+
+// Event messages for BubbleTea
+type (
+ // Immediate events (fsnotify)
+ sessionStateChangedMsg struct{}
+ sessionListChangedMsg struct{}
+ gitIndexChangedMsg struct{ sessionID string }
+
+ // Polling events
+ gitStatusRefreshMsg struct{}
+ tmuxStatusRefreshMsg struct{}
+
+ // User actions
+ attachMsg struct{ sessionID string }
+ deleteMsg struct{ sessionID string }
+ createSessionMsg struct{ name string }
+
+ // Internal events
+ refreshCompleteMsg struct{ sessions []types.Session }
+ errorMsg struct{ err error }
+ confirmYesMsg struct{}
+ confirmNoMsg struct{}
+
+ // Session creation status
+ sessionCreatingMsg struct{ name string }
+ sessionCreatedMsg struct{ name string }
+ sessionCreationFailedMsg struct {
+ name string
+ err error
+ }
+
+ // Toast messages
+ clearSuccessMsg struct{}
+
+ // Dialog events
+ showConfirmDialogMsg struct {
+ message string
+ onYes func() tea.Cmd
+ onNo func() tea.Cmd
+ }
+
+ // New session dialog events
+ showNewSessionDialogMsg struct{}
+ newSessionDialogInputMsg struct{ input string }
+ newSessionDialogSubmitMsg struct{}
+ newSessionDialogCancelMsg struct{}
+
+ // Clear error message after delay
+ clearErrorMsg struct{}
+
+ // Attach request (exits TUI and attaches)
+ attachRequestMsg struct{ sessionName string }
+
+ // File watcher setup
+ fileWatcherSetupMsg struct{ watcher *fsnotify.Watcher }
+)
+
+// NewModel creates a new TUI model
+func NewModel(stateManager *state.Manager) (*Model, error) {
+ if debugLogger != nil {
+ debugLogger.Println("NewModel: Starting TUI model creation")
+ }
+
+ // Load initial sessions
+ sessions, err := stateManager.DeriveFreshSessions()
+ if err != nil {
+ if debugLogger != nil {
+ debugLogger.Printf("NewModel: Failed to load sessions: %v", err)
+ }
+ return nil, fmt.Errorf("failed to load initial sessions: %w", err)
+ }
+
+ if debugLogger != nil {
+ debugLogger.Printf("NewModel: Loaded %d sessions", len(sessions))
+ for i, s := range sessions {
+ debugLogger.Printf("NewModel: Session %d: ID=%s, Name=%s, IsAlive=%v", i, s.Core.ID, s.Core.Name, s.IsAlive)
+ }
+ }
+
+ if debugLogger != nil {
+ debugLogger.Printf("NewModel: No table needed for split-pane layout")
+ }
+
+ return &Model{
+ stateManager: stateManager,
+ sessions: sessions,
+ ready: false,
+ creatingSessions: make(map[string]bool),
+ eventChan: make(chan tea.Msg, 100), // Buffered channel for file events
+ }, nil
+}
+
+// Init initializes the TUI model with necessary setup
+func (m Model) Init() tea.Cmd {
+ return tea.Batch(
+ m.setupFileWatching(),
+ m.startEventChannelListener(),
+ m.startGitPolling(),
+ m.startTmuxPolling(),
+ func() tea.Msg { return refreshCompleteMsg{sessions: m.sessions} },
+ )
+}
+
+// Update handles all TUI events and state changes
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ return m, nil
+
+ case tea.KeyMsg:
+ return m.handleKeyPress(msg)
+
+ case refreshCompleteMsg:
+ // Store old sessions to detect new ones
+ oldSessionIDs := make(map[string]bool)
+ for _, session := range m.sessions {
+ oldSessionIDs[session.Core.ID] = true
+ }
+
+ // Update sessions
+ m.sessions = msg.sessions
+
+ // Ensure selectedIndex is within bounds
+ totalItems := len(m.sessions) + len(m.creatingSessions)
+ if m.selectedIndex >= totalItems {
+ m.selectedIndex = totalItems - 1
+ }
+ if m.selectedIndex < 0 {
+ m.selectedIndex = 0
+ }
+
+ m.ready = true
+
+ // Add watches for any new sessions
+ if m.fileWatcher != nil {
+ for _, session := range m.sessions {
+ if !oldSessionIDs[session.Core.ID] {
+ // This is a new session, add watches
+ m.addNewSessionWatches(session)
+ }
+ }
+ }
+
+ return m, nil
+
+ case sessionStateChangedMsg:
+ // High priority: Claude state changes (hook events)
+ return m, tea.Batch(
+ m.refreshSessions(),
+ m.startEventChannelListener(), // Restart listener
+ )
+
+ case sessionListChangedMsg:
+ // High priority: Session CRUD operations
+ return m, tea.Batch(
+ m.refreshSessions(),
+ m.startEventChannelListener(), // Restart listener
+ )
+
+ case gitIndexChangedMsg:
+ // Medium priority: Git staging operations
+ return m, tea.Batch(
+ m.refreshSessionGitStatus(msg.sessionID),
+ m.startEventChannelListener(), // Restart listener
+ )
+
+ case gitStatusRefreshMsg:
+ // Low priority: Working tree changes (polling)
+ return m, m.refreshAllGitStatus()
+
+ case tmuxStatusRefreshMsg:
+ // Low priority: Tmux status (polling)
+ return m, m.refreshTmuxStatus()
+
+ case errorMsg:
+ m.lastError = msg.err.Error()
+ // Clear error after a few seconds and restart event listener if it was from file watcher
+ return m, tea.Batch(
+ tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return clearErrorMsg{}
+ }),
+ m.startEventChannelListener(), // Restart listener in case error came from file watcher
+ )
+
+ case clearErrorMsg:
+ m.lastError = ""
+ return m, nil
+
+ case clearSuccessMsg:
+ m.successMessage = ""
+ return m, nil
+
+ case confirmYesMsg:
+ if m.confirmDialog != nil && m.confirmDialog.OnYes != nil {
+ cmd := m.confirmDialog.OnYes()
+ m.confirmDialog = nil
+ return m, cmd
+ }
+ return m, nil
+
+ case confirmNoMsg:
+ if m.confirmDialog != nil && m.confirmDialog.OnNo != nil {
+ cmd := m.confirmDialog.OnNo()
+ m.confirmDialog = nil
+ return m, cmd
+ }
+ return m, nil
+
+ case showConfirmDialogMsg:
+ return m.handleShowConfirmDialog(msg)
+
+ case showNewSessionDialogMsg:
+ return m.handleShowNewSessionDialog()
+
+ case newSessionDialogInputMsg:
+ return m.handleNewSessionDialogInput(msg.input)
+
+ case newSessionDialogSubmitMsg:
+ return m.handleNewSessionDialogSubmit()
+
+ case newSessionDialogCancelMsg:
+ return m.handleNewSessionDialogCancel()
+
+ case sessionCreatingMsg:
+ // Mark session as being created
+ m.creatingSessions[msg.name] = true
+ return m, nil
+
+ case sessionCreatedMsg:
+ // Remove from creating list, show success message, and refresh
+ delete(m.creatingSessions, msg.name)
+ m.successMessage = fmt.Sprintf("Session '%s' created successfully", msg.name)
+ return m, tea.Batch(
+ m.refreshSessions(),
+ tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return clearSuccessMsg{}
+ }),
+ )
+
+ case sessionCreationFailedMsg:
+ // Remove from creating list and show error
+ delete(m.creatingSessions, msg.name)
+ m.lastError = fmt.Sprintf("Failed to create session '%s': %s", msg.name, msg.err.Error())
+ return m, tea.Tick(5*time.Second, func(time.Time) tea.Msg {
+ return clearErrorMsg{}
+ })
+
+ case attachRequestMsg:
+ if debugLogger != nil {
+ debugLogger.Printf("Update: Received attachRequestMsg for session: %s", msg.sessionName)
+ }
+ // Store the session to attach to and quit
+ m.attachOnExit = msg.sessionName
+ if debugLogger != nil {
+ debugLogger.Printf("Update: Set attachOnExit=%s, calling tea.Quit", msg.sessionName)
+ }
+ return m, tea.Quit
+
+ case fileWatcherSetupMsg:
+ // Store the file watcher in the model
+ m.fileWatcher = msg.watcher
+ return m, m.startEventChannelListener()
+ }
+
+ return m, nil
+}
+
+// handleKeyPress processes keyboard input
+func (m Model) handleKeyPress(msg tea.KeyMsg) (Model, tea.Cmd) {
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: Key pressed: '%s'", msg.String())
+ }
+
+ // Handle confirmation dialog first
+ if m.confirmDialog != nil {
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: In confirmation dialog, key: '%s'", msg.String())
+ }
+ switch msg.String() {
+ case "y", "Y", "enter":
+ if debugLogger != nil {
+ debugLogger.Println("handleKeyPress: Confirmation Yes")
+ }
+ return m, func() tea.Msg { return confirmYesMsg{} }
+ case "n", "N", "esc":
+ if debugLogger != nil {
+ debugLogger.Println("handleKeyPress: Confirmation No")
+ }
+ return m, func() tea.Msg { return confirmNoMsg{} }
+ }
+ return m, nil
+ }
+
+ // Handle new session dialog
+ if m.newSessionDialog != nil {
+ return m.handleNewSessionDialogKeys(msg)
+ }
+
+ // Handle help overlay
+ if m.showHelp {
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: In help overlay, key: '%s'", msg.String())
+ }
+ switch msg.String() {
+ case "?", "esc", "q":
+ m.showHelp = false
+ }
+ return m, nil
+ }
+
+ // Handle action keys first (before table navigation)
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: Processing action key: '%s', sessions: %d", msg.String(), len(m.sessions))
+ }
+
+ switch msg.String() {
+ case "q", "ctrl+c":
+ if debugLogger != nil {
+ debugLogger.Println("handleKeyPress: Quit requested")
+ }
+ return m, tea.Quit
+
+ case "enter", "a":
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: Attach requested, sessions available: %d", len(m.sessions))
+ }
+ if len(m.sessions) > 0 {
+ sessionID := m.getSelectedSessionID()
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: Selected session ID: '%s'", sessionID)
+ }
+ if sessionID == "" {
+ if debugLogger != nil {
+ debugLogger.Println("handleKeyPress: No session selected - setting error")
+ }
+ m.lastError = "No session selected"
+ return m, nil
+ }
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: Processing attach directly for session: %s", sessionID)
+ }
+
+ // Handle attach directly instead of through a command
+ session := m.findSession(sessionID)
+ if session == nil {
+ m.lastError = "Session not found"
+ return m, nil
+ }
+
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: Found session %s, IsAlive: %v", session.Core.Name, session.IsAlive)
+ }
+
+ if !session.IsAlive {
+ // Show confirmation dialog for dead sessions
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: Session %s is dead, showing dialog", session.Core.Name)
+ }
+ m.confirmDialog = &ConfirmDialog{
+ Message: fmt.Sprintf("Session '%s' tmux is not running. Recreate it?", session.Core.Name),
+ OnYes: func() tea.Cmd {
+ return m.recreateAndAttach(sessionID)
+ },
+ OnNo: func() tea.Cmd {
+ return nil
+ },
+ }
+ return m, nil
+ }
+
+ // Alive session - exit and attach
+ if debugLogger != nil {
+ debugLogger.Printf("handleKeyPress: Session %s is alive, setting attachOnExit", session.Core.Name)
+ }
+ m.attachOnExit = session.Core.TmuxSession
+ return m, tea.Quit
+ }
+ if debugLogger != nil {
+ debugLogger.Println("handleKeyPress: No sessions available")
+ }
+ m.lastError = "No sessions available"
+ return m, nil
+
+ case "n":
+ return m, func() tea.Msg { return showNewSessionDialogMsg{} }
+
+ case "d":
+ if len(m.sessions) > 0 {
+ return m, m.confirmDelete(m.getSelectedSessionID())
+ }
+ return m, nil
+
+ case "c":
+ return m, m.runCleanup()
+
+ case "?":
+ m.showHelp = true
+ return m, nil
+
+ case "r":
+ return m, m.refreshSessions()
+
+ case "s":
+ // Switch to session branch
+ if len(m.sessions) > 0 {
+ return m, m.switchToSessionBranch(m.getSelectedSessionID())
+ }
+ return m, nil
+
+ case "m":
+ // Merge session changes
+ if len(m.sessions) > 0 {
+ return m, m.mergeSessionChanges(m.getSelectedSessionID())
+ }
+ return m, nil
+
+ case "u":
+ // Publish (commit + push) session
+ if len(m.sessions) > 0 {
+ return m, m.publishSession(m.getSelectedSessionID())
+ }
+ return m, nil
+
+ case "t":
+ // Toggle between detailed/compact view (placeholder for now)
+ return m, nil
+
+ case "/":
+ // Search/filter sessions (placeholder for now)
+ return m, nil
+ }
+
+ // Handle navigation keys for the left panel
+ switch msg.String() {
+ case "up", "k":
+ if m.selectedIndex > 0 {
+ m.selectedIndex--
+ }
+ return m, nil
+ case "down", "j":
+ totalItems := len(m.sessions) + len(m.creatingSessions)
+ if m.selectedIndex < totalItems-1 {
+ m.selectedIndex++
+ }
+ return m, nil
+ }
+
+ return m, nil
+}
+
+// Session selection helpers
+func (m Model) getSelectedSessionID() string {
+ if debugLogger != nil {
+ debugLogger.Printf("getSelectedSessionID: Sessions count: %d, Creating: %d", len(m.sessions), len(m.creatingSessions))
+ }
+
+ totalItems := len(m.sessions) + len(m.creatingSessions)
+ if totalItems == 0 {
+ if debugLogger != nil {
+ debugLogger.Println("getSelectedSessionID: No sessions available")
+ }
+ return ""
+ }
+
+ selectedIdx := m.selectedIndex
+ if debugLogger != nil {
+ debugLogger.Printf("getSelectedSessionID: Selected index: %d", selectedIdx)
+ }
+
+ // Check if selecting a creating session
+ if selectedIdx < len(m.creatingSessions) {
+ if debugLogger != nil {
+ debugLogger.Println("getSelectedSessionID: Selected creating session, returning empty")
+ }
+ return ""
+ }
+
+ // Adjust for regular sessions
+ sessionIndex := selectedIdx - len(m.creatingSessions)
+ if sessionIndex >= len(m.sessions) {
+ if debugLogger != nil {
+ debugLogger.Printf("getSelectedSessionID: Adjusted index %d >= sessions %d", sessionIndex, len(m.sessions))
+ }
+ return ""
+ }
+
+ sessionID := m.sessions[sessionIndex].Core.ID
+ if debugLogger != nil {
+ debugLogger.Printf("getSelectedSessionID: Returning session ID: %s (name: %s)", sessionID, m.sessions[sessionIndex].Core.Name)
+ }
+
+ return sessionID
+}
+
+// No longer needed - using custom split-pane layout
+
+// GetAttachOnExit returns the session to attach to when exiting TUI
+func (m Model) GetAttachOnExit() string {
+ return m.attachOnExit
+}
+
+func (m Model) findSession(sessionID string) *types.Session {
+ for i := range m.sessions {
+ if m.sessions[i].Core.ID == sessionID {
+ return &m.sessions[i]
+ }
+ }
+ return nil
+}
+
+// handleShowConfirmDialog sets up a confirmation dialog
+func (m Model) handleShowConfirmDialog(msg showConfirmDialogMsg) (Model, tea.Cmd) {
+ m.confirmDialog = &ConfirmDialog{
+ Message: msg.message,
+ OnYes: msg.onYes,
+ OnNo: msg.onNo,
+ }
+ return m, nil
+}
+
+// handleShowNewSessionDialog sets up a new session dialog
+func (m Model) handleShowNewSessionDialog() (Model, tea.Cmd) {
+ m.newSessionDialog = &NewSessionDialog{
+ NameInput: "",
+ Error: "",
+ }
+ return m, nil
+}
+
+// handleNewSessionDialogKeys handles keyboard input for the new session dialog
+func (m Model) handleNewSessionDialogKeys(msg tea.KeyMsg) (Model, tea.Cmd) {
+ dialog := m.newSessionDialog
+
+ switch msg.String() {
+ case "esc":
+ return m, func() tea.Msg { return newSessionDialogCancelMsg{} }
+
+ case "enter":
+ return m, func() tea.Msg { return newSessionDialogSubmitMsg{} }
+
+ case "backspace":
+ if len(dialog.NameInput) > 0 {
+ dialog.NameInput = dialog.NameInput[:len(dialog.NameInput)-1]
+ }
+ // Clear error when user starts typing
+ dialog.Error = ""
+ return m, nil
+
+ default:
+ // Handle regular character input
+ if len(msg.String()) == 1 {
+ char := msg.String()
+ dialog.NameInput += char
+ // Clear error when user starts typing
+ dialog.Error = ""
+ }
+ return m, nil
+ }
+}
+
+// handleNewSessionDialogInput handles text input for the dialog
+func (m Model) handleNewSessionDialogInput(input string) (Model, tea.Cmd) {
+ if m.newSessionDialog != nil {
+ m.newSessionDialog.NameInput = input
+ // Clear error when user types
+ m.newSessionDialog.Error = ""
+ }
+ return m, nil
+}
+
+// handleNewSessionDialogSubmit processes the dialog submission
+func (m Model) handleNewSessionDialogSubmit() (Model, tea.Cmd) {
+ dialog := m.newSessionDialog
+ if dialog == nil {
+ return m, nil
+ }
+
+ // Validate input
+ if strings.TrimSpace(dialog.NameInput) == "" {
+ dialog.Error = "Session name is required"
+ return m, nil
+ }
+
+ // Check for duplicate session names
+ for _, session := range m.sessions {
+ if session.Core.Name == strings.TrimSpace(dialog.NameInput) {
+ dialog.Error = "Session name already exists"
+ return m, nil
+ }
+ }
+
+ // Create the session
+ name := strings.TrimSpace(dialog.NameInput)
+
+ // Clear the dialog
+ m.newSessionDialog = nil
+
+ // Create session using state manager
+ return m, tea.Batch(
+ // Show immediate "creating" status
+ func() tea.Msg {
+ return sessionCreatingMsg{name: name}
+ },
+ // Create session in background
+ func() tea.Msg {
+ err := m.stateManager.CreateSession(name)
+ if err != nil {
+ return sessionCreationFailedMsg{name: name, err: err}
+ }
+
+ // Signal completion
+ return sessionCreatedMsg{name: name}
+ },
+ )
+}
+
+// handleNewSessionDialogCancel cancels the dialog
+func (m Model) handleNewSessionDialogCancel() (Model, tea.Cmd) {
+ m.newSessionDialog = nil
+ return m, nil
+}
+
+// switchToSessionBranch switches to a session's branch
+func (m Model) switchToSessionBranch(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ session := m.findSession(sessionID)
+ if session == nil {
+ return errorMsg{err: fmt.Errorf("session not found")}
+ }
+
+ // Show confirmation dialog
+ return showConfirmDialogMsg{
+ message: fmt.Sprintf("Switch to session '%s' branch?", session.Core.Name),
+ onYes: func() tea.Cmd {
+ return func() tea.Msg {
+ // Execute cwt switch command
+ if err := executeCommand("cwt", "switch", session.Core.Name); err != nil {
+ return errorMsg{err: fmt.Errorf("failed to switch: %w", err)}
+ }
+ m.successMessage = fmt.Sprintf("Switched to session '%s' branch", session.Core.Name)
+ return clearSuccessMsg{}
+ }
+ },
+ onNo: func() tea.Cmd { return nil },
+ }
+ }
+}
+
+// mergeSessionChanges merges a session's changes
+func (m Model) mergeSessionChanges(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ session := m.findSession(sessionID)
+ if session == nil {
+ return errorMsg{err: fmt.Errorf("session not found")}
+ }
+
+ if !session.GitStatus.HasChanges {
+ return errorMsg{err: fmt.Errorf("session '%s' has no changes to merge", session.Core.Name)}
+ }
+
+ // Show confirmation dialog
+ return showConfirmDialogMsg{
+ message: fmt.Sprintf("Merge session '%s' into current branch?", session.Core.Name),
+ onYes: func() tea.Cmd {
+ return func() tea.Msg {
+ // Execute cwt merge command
+ if err := executeCommand("cwt", "merge", session.Core.Name); err != nil {
+ return errorMsg{err: fmt.Errorf("failed to merge: %w", err)}
+ }
+ m.successMessage = fmt.Sprintf("Merged session '%s'", session.Core.Name)
+ return clearSuccessMsg{}
+ }
+ },
+ onNo: func() tea.Cmd { return nil },
+ }
+ }
+}
+
+// publishSession publishes a session (commit + push)
+func (m Model) publishSession(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ session := m.findSession(sessionID)
+ if session == nil {
+ return errorMsg{err: fmt.Errorf("session not found")}
+ }
+
+ if !session.GitStatus.HasChanges {
+ return errorMsg{err: fmt.Errorf("session '%s' has no changes to publish", session.Core.Name)}
+ }
+
+ // Show confirmation dialog
+ return showConfirmDialogMsg{
+ message: fmt.Sprintf("Publish session '%s' (commit + push)?", session.Core.Name),
+ onYes: func() tea.Cmd {
+ return func() tea.Msg {
+ // Execute cwt publish command
+ if err := executeCommand("cwt", "publish", session.Core.Name); err != nil {
+ return errorMsg{err: fmt.Errorf("failed to publish: %w", err)}
+ }
+ m.successMessage = fmt.Sprintf("Published session '%s'", session.Core.Name)
+ return clearSuccessMsg{}
+ }
+ },
+ onNo: func() tea.Cmd { return nil },
+ }
+ }
+}
+
+// executeCommand executes a shell command
+func executeCommand(command string, args ...string) error {
+ cmd := exec.Command(command, args...)
+ return cmd.Run()
+}
+
+
+
package tui
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+)
+
+// Run starts the TUI with the given state manager, creating a seamless loop
+func Run(stateManager *state.Manager) error {
+ for {
+ // Create the TUI model
+ model, err := NewModel(stateManager)
+ if err != nil {
+ return fmt.Errorf("failed to create TUI model: %w", err)
+ }
+
+ // Configure the program
+ p := tea.NewProgram(
+ model,
+ tea.WithAltScreen(), // Use alternate screen buffer
+ tea.WithMouseCellMotion(), // Enable mouse support
+ )
+
+ // Run the program
+ finalModel, err := p.Run()
+ if err != nil {
+ return fmt.Errorf("TUI error: %w", err)
+ }
+
+ // Check if we need to attach to a session after TUI exit
+ if m, ok := finalModel.(Model); ok {
+ if sessionName := m.GetAttachOnExit(); sessionName != "" {
+ // Create logger for this function (reuse same log file)
+ logFile, err := os.OpenFile("cwt-tui-debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
+ if err == nil {
+ logger := log.New(logFile, "[TUI-DEBUG] ", log.LstdFlags|log.Lshortfile)
+ logger.Printf("Run: TUI exited with attachOnExit: %s", sessionName)
+ logger.Printf("Run: Calling attachToTmuxSession")
+ logFile.Close()
+ }
+
+ // Attach to tmux session
+ if err := attachToTmuxSession(sessionName); err != nil {
+ return err
+ }
+
+ // When tmux exits, show transition message and restart TUI
+ fmt.Println("\nπ Tmux session ended. Returning to CWT dashboard...")
+
+ // Continue the loop to restart TUI
+ continue
+ }
+ }
+
+ // If we get here, user quit TUI without attaching - exit the loop
+ break
+ }
+
+ return nil
+}
+
+// attachToTmuxSession attaches to a tmux session
+func attachToTmuxSession(sessionName string) error {
+ cmd := exec.Command("tmux", "attach-session", "-t", sessionName)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to attach to tmux session '%s': %w", sessionName, err)
+ }
+
+ return nil
+}
+
+
+
package tui
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/jlaneve/cwt-cli/internal/types"
+)
+
+// Minimal styles for the TUI
+var (
+ headerStyle = lipgloss.NewStyle().
+ Bold(true).
+ Margin(0, 0, 1, 0)
+
+ actionsStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("240")).
+ Margin(1, 0, 0, 0)
+
+ helpStyle = lipgloss.NewStyle().
+ Border(lipgloss.NormalBorder()).
+ Padding(1, 2).
+ Margin(1, 2)
+
+ confirmStyle = lipgloss.NewStyle().
+ Border(lipgloss.NormalBorder()).
+ Padding(1, 2).
+ Margin(2, 4)
+
+ errorStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("9")).
+ Bold(true)
+
+ // Simple status colors
+ waitingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
+ workingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
+ deadStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
+ aliveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
+ changesStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
+ cleanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+ idleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+)
+
+// View renders the entire TUI
+func (m Model) View() string {
+ if !m.ready {
+ return "Loading sessions..."
+ }
+
+ // HEADER - Dashboard info
+ header := m.renderHeader()
+
+ // Calculate exact middle height (no separate status area now)
+ middleHeight := m.height - 5 - 1 // header=3, actions=1
+
+ // MIDDLE PANEL - Combined left and right panels
+ middle := m.renderMiddlePanel(m.width, middleHeight)
+
+ // ACTIONS BAR - Navigation help
+ actions := m.renderActions()
+
+ // Assemble everything
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ middle,
+ actions,
+ )
+
+ // Overlay dialogs
+ if m.confirmDialog != nil {
+ return m.renderWithConfirmDialog(content)
+ }
+
+ if m.newSessionDialog != nil {
+ return m.renderWithNewSessionDialog(content)
+ }
+
+ if m.showHelp {
+ return m.renderWithHelp(content)
+ }
+
+ return content
+}
+
+// renderHeader renders the dashboard header with summary info
+func (m Model) renderHeader() string {
+ totalSessions := len(m.sessions)
+ activeSessions := 0
+ needsAttention := 0
+
+ for _, session := range m.sessions {
+ if session.IsAlive {
+ activeSessions++
+ }
+ if session.ClaudeStatus.State == types.ClaudeWaiting {
+ needsAttention++
+ }
+ }
+
+ summary := fmt.Sprintf("CWT Dashboard - %d sessions, %d active", totalSessions, activeSessions)
+ if needsAttention > 0 {
+ summary += fmt.Sprintf(", %d need attention", needsAttention)
+ }
+
+ // Header with proper styling and natural height
+ return lipgloss.NewStyle().
+ Bold(true).
+ Width(m.width).
+ Padding(1).
+ Height(3). // one line plus top + bottom padding
+ Render(summary)
+}
+
+// renderMiddlePanel renders the combined left and right panels
+func (m Model) renderMiddlePanel(width int, height int) string {
+ statusHeight := 0
+
+ // if we need to render the status area, we need to account for it in the height
+ if m.lastError != "" || m.successMessage != "" {
+ statusHeight = 2 // Reserve 2 lines for status messages
+ }
+
+ height -= statusHeight
+
+ // LEFT PANEL - Session list with border
+ leftPanel := m.renderLeftPanel(40, height)
+
+ // RIGHT PANEL - Session details with border (includes status area at bottom)
+ rightPanel := m.renderRightPanel(width-40-1, height)
+
+ middleSection := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel)
+
+ if statusHeight > 0 {
+ // render the status area
+ statusArea := m.renderStatusArea()
+ middleSection = lipgloss.JoinVertical(lipgloss.Top, middleSection, statusArea)
+ }
+
+ // Assemble middle section
+ return middleSection
+}
+
+// renderLeftPanel renders the session list on the left side
+func (m Model) renderLeftPanel(width int, height int) string {
+ totalItems := len(m.sessions) + len(m.creatingSessions)
+ if totalItems == 0 {
+ content := "No sessions found.\n\nPress 'n' to create a new session."
+ return lipgloss.NewStyle().
+ Width(width).
+ Height(height).
+ Border(lipgloss.NormalBorder()).
+ Padding(1).
+ Render(content)
+ }
+
+ var lines []string
+ lines = append(lines, "Sessions:")
+ lines = append(lines, "")
+
+ // Track current item index for selection
+ itemIndex := 0
+
+ // Show creating sessions first
+ for name := range m.creatingSessions {
+ // Selection indicator on the far left
+ var selectionIndicator string
+ if itemIndex == m.selectedIndex {
+ selectionIndicator = "βΆ"
+ } else {
+ selectionIndicator = " "
+ }
+
+ // Creating indicator
+ creatingIndicator := workingStyle.Render("β")
+
+ // Session name with creating status
+ sessionName := name + " (creating...)"
+
+ // Build the session line
+ sessionPart := fmt.Sprintf("%s %s %s", selectionIndicator, creatingIndicator, sessionName)
+
+ // Calculate spacing - no git indicator for creating sessions
+ contentWidth := width - 4 // Account for border and padding
+ sessionPartVisual := 1 + 1 + 1 + 1 + len(sessionName) // selection + space + indicator + space + name
+
+ spacesNeeded := contentWidth - sessionPartVisual
+ if spacesNeeded < 0 {
+ spacesNeeded = 0
+ }
+
+ line := sessionPart + strings.Repeat(" ", spacesNeeded)
+ lines = append(lines, line)
+ itemIndex++
+ }
+
+ // Show existing sessions
+ for _, session := range m.sessions {
+ // Selection indicator on the far left
+ var selectionIndicator string
+ if itemIndex == m.selectedIndex {
+ selectionIndicator = "βΆ"
+ } else {
+ selectionIndicator = " "
+ }
+
+ // Claude status indicator
+ claudeIndicator := getClaudeIndicator(session.ClaudeStatus.State)
+
+ // Session name with tmux status
+ name := session.Core.Name
+ if !session.IsAlive {
+ name += " (closed)"
+ }
+
+ // Git changes indicator on the right
+ gitIndicator := getGitIndicator(session.GitStatus)
+
+ // Build the session part with selection and claude indicators
+ sessionPart := fmt.Sprintf("%s %s %s", selectionIndicator, claudeIndicator, name)
+
+ // Calculate spacing for right-aligned git indicator
+ contentWidth := width - 4 // Account for border and padding
+ sessionPartVisual := 1 + 1 + 1 + 1 + len(name) // selection + space + claude + space + name
+ gitIndicatorVisual := getGitIndicatorVisualLength(session.GitStatus)
+
+ spacesNeeded := contentWidth - sessionPartVisual - gitIndicatorVisual
+ if spacesNeeded < 1 {
+ spacesNeeded = 1
+ }
+
+ line := sessionPart + strings.Repeat(" ", spacesNeeded) + gitIndicator
+ lines = append(lines, line)
+ itemIndex++
+ }
+
+ content := strings.Join(lines, "\n")
+
+ return lipgloss.NewStyle().
+ Width(width).
+ Height(height).
+ Border(lipgloss.NormalBorder()).
+ Padding(1).
+ Render(content)
+}
+
+// renderRightPanel renders the detailed view of the selected session
+func (m Model) renderRightPanel(width int, height int) string {
+ totalItems := len(m.sessions) + len(m.creatingSessions)
+ if totalItems == 0 || m.selectedIndex >= totalItems {
+ var lines []string
+ lines = append(lines, "No session selected")
+
+ // Add status area at the bottom
+ if m.lastError != "" {
+ lines = append(lines, "")
+ lines = append(lines, "---")
+ // Use the same 2-line error rendering logic
+ errorLines := m.renderErrorMessageForPanel(width - 6)
+ lines = append(lines, errorLines...)
+ } else if m.successMessage != "" {
+ lines = append(lines, "")
+ lines = append(lines, "---")
+ successMsg := "β " + sanitizeMessage(m.successMessage)
+ maxWidth := width - 6 // Account for border, padding, and prefix
+ if len(successMsg) > maxWidth {
+ successMsg = successMsg[:maxWidth-3] + "..."
+ }
+ successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
+ lines = append(lines, successStyle.Render(successMsg))
+ }
+
+ content := strings.Join(lines, "\n")
+ return lipgloss.NewStyle().
+ Width(width).
+ Height(height).
+ Border(lipgloss.NormalBorder()).
+ Padding(1).
+ Render(content)
+ }
+
+ // Check if we're selecting a creating session
+ if m.selectedIndex < len(m.creatingSessions) {
+ // Get the creating session name (map iteration order isn't guaranteed, but for display it's okay)
+ var creatingName string
+ i := 0
+ for name := range m.creatingSessions {
+ if i == m.selectedIndex {
+ creatingName = name
+ break
+ }
+ i++
+ }
+
+ var lines []string
+ lines = append(lines, fmt.Sprintf("Session: %s", creatingName))
+ lines = append(lines, "")
+ lines = append(lines, "Status: Creating session...")
+ lines = append(lines, "")
+ lines = append(lines, "Please wait while the session is being set up with:")
+ lines = append(lines, "β’ Git worktree")
+ lines = append(lines, "β’ Claude configuration")
+ lines = append(lines, "β’ Tmux session")
+
+ // Add status area at the bottom
+ if m.lastError != "" {
+ lines = append(lines, "")
+ lines = append(lines, "---")
+ // Use the same 2-line error rendering logic
+ errorLines := m.renderErrorMessageForPanel(width - 6)
+ lines = append(lines, errorLines...)
+ } else if m.successMessage != "" {
+ lines = append(lines, "")
+ lines = append(lines, "---")
+ successMsg := "β " + sanitizeMessage(m.successMessage)
+ maxWidth := width - 6 // Account for border, padding, and prefix
+ if len(successMsg) > maxWidth {
+ successMsg = successMsg[:maxWidth-3] + "..."
+ }
+ successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
+ lines = append(lines, successStyle.Render(successMsg))
+ }
+
+ content := strings.Join(lines, "\n")
+ return lipgloss.NewStyle().
+ Width(width).
+ Height(height).
+ Border(lipgloss.NormalBorder()).
+ Padding(1).
+ Render(content)
+ }
+
+ // Regular session - adjust index to account for creating sessions
+ sessionIndex := m.selectedIndex - len(m.creatingSessions)
+ if sessionIndex >= len(m.sessions) {
+ var lines []string
+ lines = append(lines, "Session not found")
+
+ // Add status area at the bottom
+ if m.lastError != "" {
+ lines = append(lines, "")
+ lines = append(lines, "---")
+ // Use the same 2-line error rendering logic
+ errorLines := m.renderErrorMessageForPanel(width - 6)
+ lines = append(lines, errorLines...)
+ } else if m.successMessage != "" {
+ lines = append(lines, "")
+ lines = append(lines, "---")
+ successMsg := "β " + sanitizeMessage(m.successMessage)
+ maxWidth := width - 6 // Account for border, padding, and prefix
+ if len(successMsg) > maxWidth {
+ successMsg = successMsg[:maxWidth-3] + "..."
+ }
+ successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
+ lines = append(lines, successStyle.Render(successMsg))
+ }
+
+ content := strings.Join(lines, "\n")
+ return lipgloss.NewStyle().
+ Width(width).
+ Height(height).
+ Border(lipgloss.NormalBorder()).
+ Padding(1).
+ Render(content)
+ }
+
+ session := m.sessions[sessionIndex]
+
+ var lines []string
+ lines = append(lines, fmt.Sprintf("Session: %s", session.Core.Name))
+ lines = append(lines, fmt.Sprintf("ID: %s", session.Core.ID))
+ lines = append(lines, fmt.Sprintf("Created: %s", session.Core.CreatedAt.Format("2006-01-02 15:04:05")))
+ lines = append(lines, "")
+
+ // Tmux status
+ tmuxStatus := "alive"
+ if !session.IsAlive {
+ tmuxStatus = deadStyle.Render("dead")
+ } else {
+ tmuxStatus = aliveStyle.Render("alive")
+ }
+ lines = append(lines, fmt.Sprintf("Tmux: %s (%s)", tmuxStatus, session.Core.TmuxSession))
+ lines = append(lines, "")
+
+ // Claude status
+ claudeStatus := formatClaudeStatusDetail(session.ClaudeStatus)
+ lines = append(lines, fmt.Sprintf("Claude: %s", claudeStatus))
+ if session.ClaudeStatus.StatusMessage != "" {
+ lines = append(lines, fmt.Sprintf("Message: %s", session.ClaudeStatus.StatusMessage))
+ }
+ if !session.ClaudeStatus.LastMessage.IsZero() {
+ lines = append(lines, fmt.Sprintf("Last activity: %s", formatActivity(session.ClaudeStatus.LastMessage)))
+ }
+ lines = append(lines, "")
+
+ // Git status
+ gitStatus := "clean"
+ if session.GitStatus.HasChanges {
+ gitStatus = changesStyle.Render("has changes")
+ } else {
+ gitStatus = cleanStyle.Render("clean")
+ }
+ lines = append(lines, fmt.Sprintf("Git: %s", gitStatus))
+
+ if session.GitStatus.HasChanges {
+ // Calculate available width for file names (account for border, padding, and git prefix)
+ availableWidth := width - 10 // Border(2) + Padding(2) + Indentation(4) + GitPrefix(2)
+
+ if len(session.GitStatus.ModifiedFiles) > 0 {
+ lines = append(lines, fmt.Sprintf(" Modified (%d):", len(session.GitStatus.ModifiedFiles)))
+ for _, file := range session.GitStatus.ModifiedFiles {
+ displayFile := truncateFileName(file, availableWidth)
+ lines = append(lines, fmt.Sprintf(" M %s", displayFile))
+ }
+ }
+ if len(session.GitStatus.AddedFiles) > 0 {
+ lines = append(lines, fmt.Sprintf(" Added (%d):", len(session.GitStatus.AddedFiles)))
+ for _, file := range session.GitStatus.AddedFiles {
+ displayFile := truncateFileName(file, availableWidth)
+ lines = append(lines, fmt.Sprintf(" A %s", displayFile))
+ }
+ }
+ if len(session.GitStatus.DeletedFiles) > 0 {
+ lines = append(lines, fmt.Sprintf(" Deleted (%d):", len(session.GitStatus.DeletedFiles)))
+ for _, file := range session.GitStatus.DeletedFiles {
+ displayFile := truncateFileName(file, availableWidth)
+ lines = append(lines, fmt.Sprintf(" D %s", displayFile))
+ }
+ }
+ if len(session.GitStatus.UntrackedFiles) > 0 {
+ lines = append(lines, fmt.Sprintf(" Untracked (%d):", len(session.GitStatus.UntrackedFiles)))
+ for _, file := range session.GitStatus.UntrackedFiles {
+ displayFile := truncateFileName(file, availableWidth)
+ lines = append(lines, fmt.Sprintf(" ? %s", displayFile))
+ }
+ }
+ }
+
+ lines = append(lines, "")
+ lines = append(lines, fmt.Sprintf("Worktree: %s", session.Core.WorktreePath))
+
+ content := strings.Join(lines, "\n")
+
+ return lipgloss.NewStyle().
+ Width(width).
+ Height(height).
+ Border(lipgloss.NormalBorder()).
+ Padding(1).
+ Render(content)
+}
+
+// renderStatusArea renders the status/notification area between main content and actions
+// Now supports up to 2 lines for error messages
+func (m Model) renderStatusArea() string {
+ if m.lastError != "" {
+ return m.renderErrorMessage()
+ } else if m.successMessage != "" {
+ return m.renderSuccessMessage()
+ }
+ // Return empty string to maintain spacing
+ return ""
+}
+
+// renderErrorMessage handles error message rendering with 2-line support
+func (m Model) renderErrorMessage() string {
+ maxWidth := m.width - 10 // Leave some margin
+ errorMsg := "β " + m.lastError // Don't sanitize - preserve newlines for wrapping
+
+ // Split message into words for intelligent wrapping
+ words := strings.Fields(errorMsg)
+ if len(words) == 0 {
+ return errorStyle.Height(2).Render("β Error")
+ }
+
+ var lines []string
+ currentLine := ""
+
+ for _, word := range words {
+ // Check if adding this word would exceed the width
+ testLine := currentLine
+ if testLine != "" {
+ testLine += " "
+ }
+ testLine += word
+
+ if len(testLine) <= maxWidth {
+ currentLine = testLine
+ } else {
+ // Start new line if we have room for 2 lines
+ if len(lines) < 1 {
+ lines = append(lines, currentLine)
+ currentLine = word
+ } else {
+ // Truncate if we're already at 2 lines
+ if len(currentLine)+4 <= maxWidth { // +4 for "..."
+ currentLine += "..."
+ } else {
+ currentLine = currentLine[:maxWidth-3] + "..."
+ }
+ break
+ }
+ }
+ }
+
+ // Add the last line if it has content
+ if currentLine != "" {
+ lines = append(lines, currentLine)
+ }
+
+ // Ensure we have exactly 2 lines for consistent spacing
+ for len(lines) < 2 {
+ lines = append(lines, "")
+ }
+
+ errorText := strings.Join(lines, "\n")
+ return errorStyle.Height(2).Render(errorText)
+}
+
+// renderSuccessMessage handles success message rendering (still 1 line)
+func (m Model) renderSuccessMessage() string {
+ // Keep success messages as single line
+ maxWidth := m.width - 10 // Leave some margin
+ successMsg := "β " + sanitizeMessage(m.successMessage)
+ if len(successMsg) > maxWidth {
+ successMsg = successMsg[:maxWidth-3] + "..."
+ }
+ successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
+ // Use 2 lines for consistent spacing with error messages
+ return successStyle.Height(2).Render(successMsg)
+}
+
+// renderErrorMessageForPanel renders error message for right panel with 2-line support
+func (m Model) renderErrorMessageForPanel(maxWidth int) []string {
+ errorMsg := "β " + m.lastError
+
+ // Split message into words for intelligent wrapping
+ words := strings.Fields(errorMsg)
+ if len(words) == 0 {
+ return []string{errorStyle.Render("β Error"), ""}
+ }
+
+ var lines []string
+ currentLine := ""
+
+ for _, word := range words {
+ // Check if adding this word would exceed the width
+ testLine := currentLine
+ if testLine != "" {
+ testLine += " "
+ }
+ testLine += word
+
+ if len(testLine) <= maxWidth {
+ currentLine = testLine
+ } else {
+ // Start new line if we have room for 2 lines
+ if len(lines) < 1 {
+ lines = append(lines, errorStyle.Render(currentLine))
+ currentLine = word
+ } else {
+ // Truncate if we're already at 2 lines
+ if len(currentLine)+4 <= maxWidth { // +4 for "..."
+ currentLine += "..."
+ } else {
+ currentLine = currentLine[:maxWidth-3] + "..."
+ }
+ break
+ }
+ }
+ }
+
+ // Add the last line if it has content
+ if currentLine != "" {
+ lines = append(lines, errorStyle.Render(currentLine))
+ }
+
+ // Ensure we have exactly 2 lines for consistent spacing
+ for len(lines) < 2 {
+ lines = append(lines, "")
+ }
+
+ return lines
+}
+
+// sanitizeMessage removes newlines and other problematic characters for single-line display
+func sanitizeMessage(msg string) string {
+ // Replace newlines and carriage returns with spaces
+ msg = strings.ReplaceAll(msg, "\n", " ")
+ msg = strings.ReplaceAll(msg, "\r", " ")
+
+ // Replace multiple spaces with single space
+ msg = strings.Join(strings.Fields(msg), " ")
+
+ return msg
+}
+
+// renderActions renders the action bar at the bottom
+func (m Model) renderActions() string {
+ content := "ββ: navigate a/enter: attach s: switch m: merge u: publish n: new d: delete c: cleanup r: refresh ?: help q: quit"
+ return lipgloss.NewStyle().
+ Height(1).
+ Width(m.width).
+ Foreground(lipgloss.Color("240")).
+ Render(content)
+}
+
+// renderWithConfirmDialog renders content with a confirmation dialog overlay
+func (m Model) renderWithConfirmDialog(content string) string {
+ dialog := fmt.Sprintf("%s\n\n[Y]es / [Enter] / [N]o", m.confirmDialog.Message)
+ dialogBox := confirmStyle.Render(dialog)
+
+ // Center the dialog on a clean screen
+ return lipgloss.Place(
+ m.width, m.height,
+ lipgloss.Center, lipgloss.Center,
+ dialogBox,
+ )
+}
+
+// renderWithNewSessionDialog renders content with a new session dialog on clean screen
+func (m Model) renderWithNewSessionDialog(content string) string {
+ dialog := m.newSessionDialog
+
+ var lines []string
+ lines = append(lines, "Create New Session")
+ lines = append(lines, "")
+
+ // Name field
+ lines = append(lines, "Name:")
+
+ nameValue := dialog.NameInput + "_" // Show cursor
+ lines = append(lines, nameValue)
+ lines = append(lines, "")
+
+ // Show error if present
+ if dialog.Error != "" {
+ lines = append(lines, errorStyle.Render("Error: "+dialog.Error))
+ lines = append(lines, "")
+ }
+
+ // Instructions
+ lines = append(lines, "Enter: create Esc: cancel")
+
+ dialogText := strings.Join(lines, "\n")
+ dialogBox := confirmStyle.Render(dialogText)
+
+ // Center the dialog on a clean screen
+ return lipgloss.Place(
+ m.width, m.height,
+ lipgloss.Center, lipgloss.Center,
+ dialogBox,
+ )
+}
+
+// Removed complex toast overlay system in favor of simpler status area
+
+// renderWithHelp renders content with help overlay
+func (m Model) renderWithHelp(content string) string {
+ helpText := `CWT Dashboard Help
+
+Navigation:
+ β/k Move up
+ β/j Move down
+ Enter/a Attach to session
+
+Session Actions:
+ s Switch to session branch
+ m Merge session into current branch
+ u Publish session (commit + push)
+
+Management:
+ n Create new session
+ d Delete session
+ c Cleanup orphaned resources
+ r Refresh session list
+ ? Toggle this help
+ q Quit
+
+Session Status:
+ π’ alive Tmux session running
+ π΄ dead Tmux session stopped
+ π needs input Claude waiting for response
+ π working Claude actively processing
+ β
complete Claude task finished
+ π changes Git working tree has changes
+ β¨ clean Git working tree clean
+
+Press ? or Esc to close help`
+
+ helpBox := helpStyle.Render(helpText)
+
+ // Center the help on a clean screen
+ return lipgloss.Place(
+ m.width, m.height,
+ lipgloss.Center, lipgloss.Center,
+ helpBox,
+ )
+}
+
+// Status formatting functions
+func formatTmuxStatus(isAlive bool) string {
+ if isAlive {
+ return aliveStyle.Render("alive")
+ }
+ return deadStyle.Render("dead")
+}
+
+func formatClaudeStatus(status types.ClaudeStatus) string {
+ switch status.State {
+ case types.ClaudeWorking:
+ return workingStyle.Render("working")
+ case types.ClaudeWaiting:
+ // Show truncated message if available
+ if status.StatusMessage != "" {
+ msg := status.StatusMessage
+ if len(msg) > 30 {
+ msg = msg[:27] + "..."
+ }
+ return waitingStyle.Render(msg)
+ }
+ return waitingStyle.Render("waiting")
+ case types.ClaudeComplete:
+ return "complete"
+ case types.ClaudeIdle:
+ return idleStyle.Render("idle")
+ default:
+ return idleStyle.Render("unknown")
+ }
+}
+
+func formatGitStatus(status types.GitStatus) string {
+ if status.HasChanges {
+ return changesStyle.Render("changes")
+ }
+ return cleanStyle.Render("clean")
+}
+
+func formatActivity(lastActivity time.Time) string {
+ if lastActivity.IsZero() {
+ return "unknown"
+ }
+
+ age := time.Since(lastActivity)
+ if age < time.Minute {
+ return "just now"
+ }
+ if age < time.Hour {
+ minutes := int(age.Minutes())
+ return fmt.Sprintf("%dm ago", minutes)
+ }
+ if age < 24*time.Hour {
+ hours := int(age.Hours())
+ return fmt.Sprintf("%dh ago", hours)
+ }
+ days := int(age.Hours() / 24)
+ return fmt.Sprintf("%dd ago", days)
+}
+
+// Helper functions for split-pane layout
+
+func getClaudeIndicator(state types.ClaudeState) string {
+ switch state {
+ case types.ClaudeWorking:
+ return workingStyle.Render("β")
+ case types.ClaudeWaiting:
+ return waitingStyle.Render("β")
+ case types.ClaudeComplete:
+ return "β"
+ case types.ClaudeIdle:
+ return idleStyle.Render("β")
+ default:
+ return idleStyle.Render("β")
+ }
+}
+
+func getGitIndicator(status types.GitStatus) string {
+ if !status.HasChanges {
+ return cleanStyle.Render("β¦")
+ }
+
+ // Calculate total changes
+ total := len(status.ModifiedFiles) + len(status.AddedFiles) + len(status.DeletedFiles) + len(status.UntrackedFiles)
+ if total == 0 {
+ return cleanStyle.Render("β¦")
+ }
+
+ return changesStyle.Render(fmt.Sprintf("+%d", total))
+}
+
+func formatClaudeStatusDetail(status types.ClaudeStatus) string {
+ switch status.State {
+ case types.ClaudeWorking:
+ return workingStyle.Render("working")
+ case types.ClaudeWaiting:
+ return waitingStyle.Render("waiting for input")
+ case types.ClaudeComplete:
+ return "complete"
+ case types.ClaudeIdle:
+ return idleStyle.Render("idle")
+ default:
+ return idleStyle.Render("unknown")
+ }
+}
+
+func getGitIndicatorVisualLength(status types.GitStatus) int {
+ if !status.HasChanges {
+ return 1 // "β¦"
+ }
+
+ // Calculate total changes
+ total := len(status.ModifiedFiles) + len(status.AddedFiles) + len(status.DeletedFiles) + len(status.UntrackedFiles)
+ if total == 0 {
+ return 1 // "β¦"
+ }
+
+ // "+N" where N is the number
+ return len(fmt.Sprintf("+%d", total))
+}
+
+// truncateFileName intelligently truncates file names to fit within available width
+func truncateFileName(filename string, maxWidth int) string {
+ if len(filename) <= maxWidth {
+ return filename
+ }
+
+ // If the filename is too long, show the beginning and end with "..." in the middle
+ if maxWidth < 10 {
+ // If very narrow, just truncate with ...
+ if maxWidth < 4 {
+ return "..."
+ }
+ return filename[:maxWidth-3] + "..."
+ }
+
+ // For longer filenames, show beginning and end
+ prefixLen := (maxWidth - 3) / 2
+ suffixLen := maxWidth - 3 - prefixLen
+
+ return filename[:prefixLen] + "..." + filename[len(filename)-suffixLen:]
+}
+
+
+
package types
+
+// Event represents all possible events in the system
+type Event interface {
+ EventType() string
+}
+
+// Immediate Events - triggered by user actions for instant UI feedback
+
+// SessionCreationStarted is emitted when session creation begins
+type SessionCreationStarted struct {
+ Name string `json:"name"`
+}
+
+func (e SessionCreationStarted) EventType() string { return "session_creation_started" }
+
+// SessionCreated is emitted when session creation completes successfully
+type SessionCreated struct {
+ Session Session `json:"session"`
+}
+
+func (e SessionCreated) EventType() string { return "session_created" }
+
+// SessionCreationFailed is emitted when session creation fails
+type SessionCreationFailed struct {
+ Name string `json:"name"`
+ Error string `json:"error"`
+}
+
+func (e SessionCreationFailed) EventType() string { return "session_creation_failed" }
+
+// SessionDeleted is emitted when session deletion completes
+type SessionDeleted struct {
+ SessionID string `json:"session_id"`
+}
+
+func (e SessionDeleted) EventType() string { return "session_deleted" }
+
+// SessionDeletionFailed is emitted when session deletion fails
+type SessionDeletionFailed struct {
+ SessionID string `json:"session_id"`
+ Error string `json:"error"`
+}
+
+func (e SessionDeletionFailed) EventType() string { return "session_deletion_failed" }
+
+// Periodic Events - triggered by external state changes
+
+// ClaudeStatusChanged is emitted when Claude status changes
+type ClaudeStatusChanged struct {
+ SessionID string `json:"session_id"`
+ OldStatus ClaudeStatus `json:"old_status"`
+ NewStatus ClaudeStatus `json:"new_status"`
+}
+
+func (e ClaudeStatusChanged) EventType() string { return "claude_status_changed" }
+
+// TmuxSessionDied is emitted when a tmux session dies
+type TmuxSessionDied struct {
+ SessionID string `json:"session_id"`
+ TmuxSession string `json:"tmux_session"`
+}
+
+func (e TmuxSessionDied) EventType() string { return "tmux_session_died" }
+
+// GitChangesDetected is emitted when git changes are detected
+type GitChangesDetected struct {
+ SessionID string `json:"session_id"`
+ NewStatus GitStatus `json:"new_status"`
+}
+
+func (e GitChangesDetected) EventType() string { return "git_changes_detected" }
+
+// RefreshCompleted is emitted when external state refresh completes
+type RefreshCompleted struct {
+ Sessions []Session `json:"sessions"`
+ Error string `json:"error,omitempty"`
+}
+
+func (e RefreshCompleted) EventType() string { return "refresh_completed" }
+
+
+
package types
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// SessionState represents real-time state for a session
+// This is updated by hooks and other external events
+type SessionState struct {
+ SessionID string `json:"session_id"`
+ ClaudeState string `json:"claude_state"` // "working", "waiting_for_input", "complete", "idle"
+ LastEvent string `json:"last_event"` // "notification", "stop", "preToolUse", etc.
+ LastEventTime time.Time `json:"last_event_time"`
+ LastEventData map[string]interface{} `json:"last_event_data,omitempty"`
+ LastMessage string `json:"last_message,omitempty"` // Human-readable message from Claude
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// LoadSessionState loads session state from the dedicated state file
+func LoadSessionState(dataDir, sessionID string) (*SessionState, error) {
+ stateFile := filepath.Join(dataDir, "session-state", sessionID+".json")
+
+ data, err := os.ReadFile(stateFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil // No state file yet
+ }
+ return nil, fmt.Errorf("failed to read session state file: %w", err)
+ }
+
+ var state SessionState
+ if err := json.Unmarshal(data, &state); err != nil {
+ return nil, fmt.Errorf("failed to parse session state: %w", err)
+ }
+
+ return &state, nil
+}
+
+// SaveSessionState saves session state to the dedicated state file
+func SaveSessionState(dataDir string, state *SessionState) error {
+ stateDir := filepath.Join(dataDir, "session-state")
+ if err := os.MkdirAll(stateDir, 0755); err != nil {
+ return fmt.Errorf("failed to create session state directory: %w", err)
+ }
+
+ stateFile := filepath.Join(stateDir, state.SessionID+".json")
+
+ data, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal session state: %w", err)
+ }
+
+ // Atomic write using temporary file
+ tempFile := stateFile + ".tmp"
+ if err := os.WriteFile(tempFile, data, 0644); err != nil {
+ return fmt.Errorf("failed to write temp state file: %w", err)
+ }
+
+ if err := os.Rename(tempFile, stateFile); err != nil {
+ os.Remove(tempFile) // Cleanup temp file
+ return fmt.Errorf("failed to rename temp state file: %w", err)
+ }
+
+ return nil
+}
+
+// RemoveSessionState removes the session state file
+func RemoveSessionState(dataDir, sessionID string) error {
+ stateFile := filepath.Join(dataDir, "session-state", sessionID+".json")
+ err := os.Remove(stateFile)
+ if os.IsNotExist(err) {
+ return nil // Already removed
+ }
+ return err
+}
+
+// ParseClaudeStateFromEvent determines Claude state from hook event data
+func ParseClaudeStateFromEvent(eventType string, eventData map[string]interface{}) string {
+ switch eventType {
+ case "notification":
+ // Check if this is a "waiting for input" notification
+ if reason, ok := eventData["reason"].(string); ok {
+ if reason == "idle" || reason == "waiting_for_permission" {
+ return "waiting_for_input"
+ }
+ }
+ // Check message content for permission requests
+ if message, ok := eventData["message"].(string); ok {
+ if strings.Contains(strings.ToLower(message), "permission") ||
+ strings.Contains(strings.ToLower(message), "needs your") {
+ return "waiting_for_input"
+ }
+ }
+ return "idle"
+ case "preToolUse":
+ return "working"
+ case "postToolUse":
+ return "idle"
+ case "stop":
+ return "complete"
+ default:
+ return "idle"
+ }
+}
+
+// GetClaudeStatusFromState converts session state to ClaudeStatus
+func GetClaudeStatusFromState(state *SessionState) ClaudeStatus {
+ if state == nil {
+ return ClaudeStatus{
+ State: ClaudeUnknown,
+ }
+ }
+
+ claudeState := ClaudeUnknown
+ switch state.ClaudeState {
+ case "working":
+ claudeState = ClaudeWorking
+ case "waiting_for_input":
+ claudeState = ClaudeWaiting
+ case "complete":
+ claudeState = ClaudeComplete
+ case "idle":
+ claudeState = ClaudeIdle
+ }
+
+ return ClaudeStatus{
+ State: claudeState,
+ LastMessage: state.LastEventTime,
+ SessionID: state.SessionID,
+ StatusMessage: state.LastMessage,
+ }
+}
+
+
+
+
+
+
diff --git a/cwt b/cwt
new file mode 100755
index 0000000..f0ccbf6
Binary files /dev/null and b/cwt differ
diff --git a/internal/cli/attach.go b/internal/cli/attach.go
index 0747849..b63d8be 100644
--- a/internal/cli/attach.go
+++ b/internal/cli/attach.go
@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
+ "github.com/jlaneve/cwt-cli/internal/operations"
"github.com/jlaneve/cwt-cli/internal/state"
"github.com/jlaneve/cwt-cli/internal/types"
)
@@ -165,46 +166,14 @@ func promptForAttachSelection(sessions []types.Session) (*types.Session, error)
// recreateSessionWithClaudeResume recreates a dead tmux session and resumes Claude if possible
func recreateSessionWithClaudeResume(sm *state.Manager, session *types.Session) error {
- // Find Claude executable
- claudeExec := findClaudeExecutable()
- if claudeExec == "" {
- return fmt.Errorf("claude executable not found")
- }
+ sessionOps := operations.NewSessionOperations(sm)
- // Check if there's an existing Claude session to resume for this worktree
- var command string
- if existingSessionID, err := sm.GetClaudeChecker().FindSessionID(session.Core.WorktreePath); err == nil && existingSessionID != "" {
- command = fmt.Sprintf("%s -r %s", claudeExec, existingSessionID)
- fmt.Printf("π Resuming Claude session %s\n", existingSessionID)
- } else {
- command = claudeExec
- fmt.Printf("π Starting new Claude session\n")
- }
+ // Use operations layer for recreation logic
+ fmt.Printf("π Recreating session with Claude resume...\n")
- // Recreate the tmux session
- tmuxChecker := sm.GetTmuxChecker()
- if err := tmuxChecker.CreateSession(session.Core.TmuxSession, session.Core.WorktreePath, command); err != nil {
- return fmt.Errorf("failed to recreate tmux session: %w", err)
+ if err := sessionOps.RecreateDeadSession(session); err != nil {
+ return fmt.Errorf("failed to recreate session: %w", err)
}
return nil
}
-
-// findClaudeExecutable searches for claude in common installation paths
-func findClaudeExecutable() string {
- claudePaths := []string{
- "claude",
- os.ExpandEnv("$HOME/.claude/local/claude"),
- os.ExpandEnv("$HOME/.claude/local/node_modules/.bin/claude"),
- "/usr/local/bin/claude",
- }
-
- for _, path := range claudePaths {
- cmd := exec.Command(path, "--version")
- if err := cmd.Run(); err == nil {
- return path
- }
- }
-
- return ""
-}
diff --git a/internal/cli/cleanup.go b/internal/cli/cleanup.go
index deabfef..8f9e310 100644
--- a/internal/cli/cleanup.go
+++ b/internal/cli/cleanup.go
@@ -2,12 +2,8 @@ package cli
import (
"fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "github.com/jlaneve/cwt-cli/internal/state"
+ "github.com/jlaneve/cwt-cli/internal/operations"
"github.com/spf13/cobra"
)
@@ -42,242 +38,47 @@ func runCleanupCmd(dryRun bool) error {
fmt.Println("π Scanning for orphaned resources...")
- cleanupStats := struct {
- staleSessions int
- orphanedTmux int
- orphanedWorktrees int
- cleaned int
- failed int
- }{}
-
- // 1. Find stale sessions (sessions with dead tmux sessions)
- staleSessions, err := sm.FindStaleSessions()
+ // Use operations layer for cleanup
+ cleanupOps := operations.NewCleanupOperations(sm)
+ stats, err := cleanupOps.FindAndCleanupStaleResources(dryRun)
if err != nil {
- return fmt.Errorf("failed to find stale sessions: %w", err)
- }
- cleanupStats.staleSessions = len(staleSessions)
-
- // 2. Find orphaned tmux sessions (cwt-* sessions not in our metadata)
- orphanedTmux, err := findOrphanedTmuxSessions(sm)
- if err != nil {
- fmt.Printf("Warning: failed to scan tmux sessions: %v\n", err)
- } else {
- cleanupStats.orphanedTmux = len(orphanedTmux)
- }
-
- // 3. Find orphaned worktrees (.cwt/worktrees/* not in our metadata)
- orphanedWorktrees, err := findOrphanedWorktrees(sm)
- if err != nil {
- fmt.Printf("Warning: failed to scan worktrees: %v\n", err)
- } else {
- cleanupStats.orphanedWorktrees = len(orphanedWorktrees)
+ return fmt.Errorf("cleanup failed: %w", err)
}
// Show what was found
- totalOrphans := cleanupStats.staleSessions + cleanupStats.orphanedTmux + cleanupStats.orphanedWorktrees
+ totalOrphans := stats.StaleSessions + stats.OrphanedTmux + stats.OrphanedWorktrees
if totalOrphans == 0 {
fmt.Println("β
No orphaned resources found. Everything looks clean!")
return nil
}
fmt.Printf("\nFound orphaned resources:\n")
- if cleanupStats.staleSessions > 0 {
- fmt.Printf(" π %d stale session(s) with dead tmux\n", cleanupStats.staleSessions)
+ if stats.StaleSessions > 0 {
+ fmt.Printf(" π %d stale session(s) with dead tmux\n", stats.StaleSessions)
}
- if cleanupStats.orphanedTmux > 0 {
- fmt.Printf(" π§ %d orphaned tmux session(s)\n", cleanupStats.orphanedTmux)
+ if stats.OrphanedTmux > 0 {
+ fmt.Printf(" π§ %d orphaned tmux session(s)\n", stats.OrphanedTmux)
}
- if cleanupStats.orphanedWorktrees > 0 {
- fmt.Printf(" π³ %d orphaned git worktree(s)\n", cleanupStats.orphanedWorktrees)
+ if stats.OrphanedWorktrees > 0 {
+ fmt.Printf(" π³ %d orphaned git worktree(s)\n", stats.OrphanedWorktrees)
}
fmt.Println()
- // Show details
- if cleanupStats.staleSessions > 0 {
- fmt.Printf("Stale sessions:\n")
- for _, session := range staleSessions {
- fmt.Printf(" ποΈ %s (tmux: %s, worktree: %s)\n",
- session.Core.Name, session.Core.TmuxSession, session.Core.WorktreePath)
- }
- fmt.Println()
- }
-
- if cleanupStats.orphanedTmux > 0 {
- fmt.Printf("Orphaned tmux sessions:\n")
- for _, tmuxSession := range orphanedTmux {
- fmt.Printf(" π§ %s\n", tmuxSession)
- }
- fmt.Println()
- }
-
- if cleanupStats.orphanedWorktrees > 0 {
- fmt.Printf("Orphaned worktrees:\n")
- for _, worktree := range orphanedWorktrees {
- fmt.Printf(" π³ %s\n", worktree)
- }
- fmt.Println()
- }
-
if dryRun {
fmt.Println("π Dry run mode - no changes made.")
fmt.Printf("Run 'cwt cleanup' to actually clean up these %d resource(s).\n", totalOrphans)
return nil
}
- // Clean up stale sessions
- if cleanupStats.staleSessions > 0 {
- fmt.Printf("Cleaning up %d stale session(s)...\n", cleanupStats.staleSessions)
- for _, session := range staleSessions {
- fmt.Printf(" Cleaning session '%s'...\n", session.Core.Name)
-
- if err := sm.DeleteSession(session.Core.ID); err != nil {
- fmt.Printf(" β Failed: %v\n", err)
- cleanupStats.failed++
- } else {
- fmt.Printf(" β
Cleaned\n")
- cleanupStats.cleaned++
- }
- }
- fmt.Println()
- }
-
- // Clean up orphaned tmux sessions
- if cleanupStats.orphanedTmux > 0 {
- fmt.Printf("Cleaning up %d orphaned tmux session(s)...\n", cleanupStats.orphanedTmux)
- tmuxChecker := sm.GetTmuxChecker()
- for _, tmuxSession := range orphanedTmux {
- fmt.Printf(" Killing tmux session '%s'...\n", tmuxSession)
-
- if err := tmuxChecker.KillSession(tmuxSession); err != nil {
- fmt.Printf(" β Failed: %v\n", err)
- cleanupStats.failed++
- } else {
- fmt.Printf(" β
Killed\n")
- cleanupStats.cleaned++
- }
+ // Show cleanup results
+ fmt.Printf("π§Ή Cleanup complete!\n")
+ fmt.Printf(" β
Cleaned: %d\n", stats.Cleaned)
+ if stats.Failed > 0 {
+ fmt.Printf(" β Failed: %d\n", stats.Failed)
+ for _, errMsg := range stats.Errors {
+ fmt.Printf(" - %s\n", errMsg)
}
- fmt.Println()
- }
-
- // Clean up orphaned worktrees
- if cleanupStats.orphanedWorktrees > 0 {
- fmt.Printf("Cleaning up %d orphaned worktree(s)...\n", cleanupStats.orphanedWorktrees)
- for _, worktree := range orphanedWorktrees {
- fmt.Printf(" Removing worktree '%s'...\n", worktree)
-
- if err := removeWorktreeWithFallback(worktree); err != nil {
- fmt.Printf(" β Failed: %v\n", err)
- cleanupStats.failed++
- } else {
- fmt.Printf(" β
Removed\n")
- cleanupStats.cleaned++
- }
- }
- fmt.Println()
- }
-
- fmt.Printf("π§Ή Cleanup complete: %d cleaned, %d failed\n", cleanupStats.cleaned, cleanupStats.failed)
-
- if cleanupStats.failed > 0 {
- return fmt.Errorf("some resources could not be cleaned up")
- }
-
- return nil
-}
-
-// findOrphanedTmuxSessions finds tmux sessions with "cwt-" prefix that aren't tracked by CWT
-func findOrphanedTmuxSessions(sm *state.Manager) ([]string, error) {
- // Get all tmux sessions
- tmuxChecker := sm.GetTmuxChecker()
- allSessions, err := tmuxChecker.ListSessions()
- if err != nil {
- return nil, fmt.Errorf("failed to list tmux sessions: %w", err)
}
- // Get all sessions tracked by CWT
- cwtSessions, err := sm.DeriveFreshSessions()
- if err != nil {
- return nil, fmt.Errorf("failed to get CWT sessions: %w", err)
- }
-
- // Build map of tracked tmux sessions
- trackedTmux := make(map[string]bool)
- for _, session := range cwtSessions {
- trackedTmux[session.Core.TmuxSession] = true
- }
-
- // Find orphaned tmux sessions
- var orphaned []string
- for _, tmuxSession := range allSessions {
- if strings.HasPrefix(tmuxSession, "cwt-") && !trackedTmux[tmuxSession] {
- orphaned = append(orphaned, tmuxSession)
- }
- }
-
- return orphaned, nil
-}
-
-// findOrphanedWorktrees finds git worktrees in .cwt/worktrees/ that aren't tracked by CWT
-func findOrphanedWorktrees(sm *state.Manager) ([]string, error) {
- // Get all git worktrees
- worktreesDir := filepath.Join(sm.GetDataDir(), "worktrees")
- if _, err := os.Stat(worktreesDir); os.IsNotExist(err) {
- return []string{}, nil // No worktrees directory
- }
-
- entries, err := os.ReadDir(worktreesDir)
- if err != nil {
- return nil, fmt.Errorf("failed to read worktrees directory: %w", err)
- }
-
- // Get all sessions tracked by CWT
- cwtSessions, err := sm.DeriveFreshSessions()
- if err != nil {
- return nil, fmt.Errorf("failed to get CWT sessions: %w", err)
- }
-
- // Build map of tracked worktree paths
- trackedWorktrees := make(map[string]bool)
- for _, session := range cwtSessions {
- // Normalize path for comparison
- absPath, _ := filepath.Abs(session.Core.WorktreePath)
- trackedWorktrees[absPath] = true
- }
-
- // Find orphaned worktrees
- var orphaned []string
- for _, entry := range entries {
- if !entry.IsDir() {
- continue
- }
-
- worktreePath := filepath.Join(worktreesDir, entry.Name())
- absPath, _ := filepath.Abs(worktreePath)
-
- if !trackedWorktrees[absPath] {
- orphaned = append(orphaned, worktreePath)
- }
- }
-
- return orphaned, nil
-}
-
-// removeWorktreeWithFallback tries to remove a worktree using git, falling back to directory removal
-func removeWorktreeWithFallback(worktreePath string) error {
- // First try to remove with git worktree command
- cmd := exec.Command("git", "worktree", "remove", worktreePath, "--force")
- if err := cmd.Run(); err == nil {
- return nil // Successfully removed with git
- }
-
- // Fallback to manual directory removal
- if err := os.RemoveAll(worktreePath); err != nil {
- return fmt.Errorf("failed to remove worktree directory: %w", err)
- }
-
- // Try to clean up git references
- cmd = exec.Command("git", "worktree", "prune")
- cmd.Run() // Ignore errors - this is just cleanup
-
return nil
}
diff --git a/internal/cli/delete.go b/internal/cli/delete.go
index d361c56..25d623a 100644
--- a/internal/cli/delete.go
+++ b/internal/cli/delete.go
@@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"
+ "github.com/jlaneve/cwt-cli/internal/operations"
"github.com/jlaneve/cwt-cli/internal/types"
)
@@ -42,8 +43,11 @@ func runDeleteCmd(args []string, force bool) error {
}
defer sm.Close()
+ // Create operations layer
+ sessionOps := operations.NewSessionOperations(sm)
+
// Get sessions
- sessions, err := sm.DeriveFreshSessions()
+ sessions, err := sessionOps.GetAllSessions()
if err != nil {
return fmt.Errorf("failed to load sessions: %w", err)
}
@@ -58,19 +62,14 @@ func runDeleteCmd(args []string, force bool) error {
var sessionID string
if len(args) > 0 {
- // Session name provided
+ // Session name provided - use operations layer
sessionName := args[0]
- for _, session := range sessions {
- if session.Core.Name == sessionName {
- sessionToDelete = &sessionName
- sessionID = session.Core.ID
- break
- }
- }
-
- if sessionToDelete == nil {
- return fmt.Errorf("session '%s' not found", sessionName)
+ session, id, err := sessionOps.FindSessionByName(sessionName)
+ if err != nil {
+ return err
}
+ sessionToDelete = &session.Core.Name
+ sessionID = id
} else {
// Interactive selection
sessionName, id, err := promptForSessionSelection(sessions)
@@ -89,10 +88,10 @@ func runDeleteCmd(args []string, force bool) error {
}
}
- // Delete session
+ // Delete session using operations layer
fmt.Printf("Deleting session '%s'...\n", *sessionToDelete)
- if err := sm.DeleteSession(sessionID); err != nil {
+ if err := sessionOps.DeleteSession(sessionID); err != nil {
return fmt.Errorf("failed to delete session: %w", err)
}
diff --git a/internal/cli/list.go b/internal/cli/list.go
index 53bc181..c8002ea 100644
--- a/internal/cli/list.go
+++ b/internal/cli/list.go
@@ -9,6 +9,7 @@ import (
"github.com/mattn/go-runewidth"
"github.com/spf13/cobra"
+ "github.com/jlaneve/cwt-cli/internal/operations"
"github.com/jlaneve/cwt-cli/internal/types"
)
@@ -42,12 +43,15 @@ func runListCmd(verbose bool) error {
}
defer sm.Close()
- // Derive fresh sessions
- sessions, err := sm.DeriveFreshSessions()
+ // Use operations layer for session retrieval and formatting
+ sessionOps := operations.NewSessionOperations(sm)
+ sessions, err := sessionOps.GetAllSessions()
if err != nil {
return fmt.Errorf("failed to load sessions: %w", err)
}
+ formatter := operations.NewStatusFormat()
+
if len(sessions) == 0 {
fmt.Println("No sessions found.")
fmt.Println("\nCreate a new session with: cwt new [session-name] [task-description]")
@@ -60,15 +64,15 @@ func runListCmd(verbose bool) error {
})
if verbose {
- renderVerboseSessionList(sessions)
+ renderVerboseSessionList(sessions, formatter)
} else {
- renderCompactSessionList(sessions)
+ renderCompactSessionList(sessions, formatter)
}
return nil
}
-func renderCompactSessionList(sessions []types.Session) {
+func renderCompactSessionList(sessions []types.Session, formatter *operations.StatusFormat) {
fmt.Printf("Found %d session(s):\n\n", len(sessions))
// Calculate max widths for each column based on content
@@ -91,10 +95,10 @@ func renderCompactSessionList(sessions []types.Session) {
for i, session := range sessions {
rows[i] = rowData{
name: truncate(session.Core.Name, 30),
- tmux: formatTmuxStatus(session.IsAlive),
- claude: formatClaudeStatus(session.ClaudeStatus),
- git: formatGitStatus(session.GitStatus),
- activity: FormatActivity(session.LastActivity),
+ tmux: formatter.FormatTmuxStatus(session.IsAlive),
+ claude: formatter.FormatClaudeStatus(session.ClaudeStatus),
+ git: formatter.FormatGitStatus(session.GitStatus),
+ activity: formatter.FormatActivity(session.LastActivity),
}
// Update max lengths (using visual length)
@@ -148,7 +152,7 @@ func renderCompactSessionList(sessions []types.Session) {
}
}
-func renderVerboseSessionList(sessions []types.Session) {
+func renderVerboseSessionList(sessions []types.Session, formatter *operations.StatusFormat) {
fmt.Printf("Found %d session(s):\n\n", len(sessions))
for i, session := range sessions {
@@ -164,7 +168,7 @@ func renderVerboseSessionList(sessions []types.Session) {
// Tmux status
fmt.Printf(" π₯οΈ Tmux: %s (session: %s)\n",
- formatTmuxStatus(session.IsAlive), session.Core.TmuxSession)
+ formatter.FormatTmuxStatus(session.IsAlive), session.Core.TmuxSession)
// Git status
gitDetails := ""
@@ -181,7 +185,7 @@ func renderVerboseSessionList(sessions []types.Session) {
}
gitDetails = fmt.Sprintf(" (%s)", strings.Join(changes, ", "))
}
- fmt.Printf(" π Git: %s%s\n", formatGitStatus(session.GitStatus), gitDetails)
+ fmt.Printf(" π Git: %s%s\n", formatter.FormatGitStatus(session.GitStatus), gitDetails)
// Claude status
claudeDetails := ""
@@ -190,9 +194,9 @@ func renderVerboseSessionList(sessions []types.Session) {
}
if !session.ClaudeStatus.LastMessage.IsZero() {
age := time.Since(session.ClaudeStatus.LastMessage)
- claudeDetails += fmt.Sprintf(" (last: %s ago)", FormatDuration(age))
+ claudeDetails += fmt.Sprintf(" (last: %s ago)", formatter.FormatDuration(age))
}
- fmt.Printf(" π€ Claude: %s%s\n", formatClaudeStatusShort(session.ClaudeStatus), claudeDetails)
+ fmt.Printf(" π€ Claude: %s%s\n", formatter.FormatClaudeStatus(session.ClaudeStatus), claudeDetails)
// Show full message in verbose mode if available
if session.ClaudeStatus.StatusMessage != "" {
@@ -200,95 +204,8 @@ func renderVerboseSessionList(sessions []types.Session) {
}
// Last activity
- fmt.Printf(" β° Activity: %s\n", FormatActivity(session.LastActivity))
- }
-}
-
-func formatTmuxStatus(isAlive bool) string {
- if isAlive {
- return "π’ alive"
- }
- return "π΄ dead"
-}
-
-func formatClaudeStatusShort(status types.ClaudeStatus) string {
- switch status.State {
- case types.ClaudeWorking:
- return "π working"
- case types.ClaudeWaiting:
- return "βΈοΈ waiting"
- case types.ClaudeComplete:
- return "β
complete"
- case types.ClaudeIdle:
- return "π€ idle"
- default:
- return "β unknown"
- }
-}
-
-func formatClaudeStatus(status types.ClaudeStatus) string {
- switch status.State {
- case types.ClaudeWorking:
- return "π working"
- case types.ClaudeWaiting:
- if status.StatusMessage != "" {
- // Truncate message for compact view
- msg := status.StatusMessage
- if len(msg) > 30 {
- msg = msg[:27] + "..."
- }
- return "βΈοΈ " + msg
- }
- return "βΈοΈ waiting"
- case types.ClaudeComplete:
- return "β
complete"
- case types.ClaudeIdle:
- return "π€ idle"
- default:
- return "β unknown"
- }
-}
-
-func formatGitStatus(status types.GitStatus) string {
- if status.HasChanges {
- // Calculate total changes
- total := len(status.ModifiedFiles) + len(status.AddedFiles) +
- len(status.DeletedFiles) + len(status.UntrackedFiles)
-
- if total == 1 {
- return "π 1 change"
- }
- return fmt.Sprintf("π %d changes", total)
- }
- return "β¨ clean"
-}
-
-func FormatActivity(lastActivity time.Time) string {
- if lastActivity.IsZero() {
- return "unknown"
- }
-
- age := time.Since(lastActivity)
- if age < time.Minute {
- return "just now"
- }
- return FormatDuration(age) + " ago"
-}
-
-func FormatDuration(d time.Duration) string {
- if d < time.Minute {
- return "just now"
- }
- if d < time.Hour {
- minutes := int(d.Minutes())
- return fmt.Sprintf("%dm", minutes)
- }
- if d < 24*time.Hour {
- hours := int(d.Hours())
- return fmt.Sprintf("%dh", hours)
+ fmt.Printf(" β° Activity: %s\n", formatter.FormatActivity(session.LastActivity))
}
- days := int(d.Hours() / 24)
- return fmt.Sprintf("%dd", days)
}
func truncate(s string, maxLen int) string {
diff --git a/internal/cli/new.go b/internal/cli/new.go
index 185d1c4..6b6f26c 100644
--- a/internal/cli/new.go
+++ b/internal/cli/new.go
@@ -7,6 +7,8 @@ import (
"strings"
"github.com/spf13/cobra"
+
+ "github.com/jlaneve/cwt-cli/internal/operations"
)
func newNewCmd() *cobra.Command {
@@ -44,10 +46,11 @@ func runNewCmd(cmd *cobra.Command, args []string) error {
}
}
- // Create session
+ // Create session using operations layer
fmt.Printf("Creating session '%s'...\n", sessionName)
- if err := sm.CreateSession(sessionName); err != nil {
+ sessionOps := operations.NewSessionOperations(sm)
+ if err := sessionOps.CreateSession(sessionName); err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
diff --git a/internal/cli/selector.go b/internal/cli/selector.go
index 38031d5..3016d41 100644
--- a/internal/cli/selector.go
+++ b/internal/cli/selector.go
@@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/jlaneve/cwt-cli/internal/operations"
"github.com/jlaneve/cwt-cli/internal/types"
)
@@ -114,7 +115,8 @@ func selectSessionFallback(sessions []types.Session, title string) (*types.Sessi
for i, session := range sessions {
status := getSessionStatusIndicator(session)
- activity := FormatActivity(session.LastActivity)
+ formatter := operations.NewStatusFormat()
+ activity := formatter.FormatActivity(session.LastActivity)
fmt.Printf(" %d. %s %s (%s)\n", i+1, session.Core.Name, status, activity)
}
@@ -198,7 +200,8 @@ func (m *sessionSelectorModel) View() string {
// Session info
status := getSessionStatusIndicator(session)
- activity := FormatActivity(session.LastActivity)
+ formatter := operations.NewStatusFormat()
+ activity := formatter.FormatActivity(session.LastActivity)
line := fmt.Sprintf("%s%s %s (%s)",
prefix,
diff --git a/internal/cli/status.go b/internal/cli/status.go
index 64c574d..1c5ff48 100644
--- a/internal/cli/status.go
+++ b/internal/cli/status.go
@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
+ "github.com/jlaneve/cwt-cli/internal/operations"
"github.com/jlaneve/cwt-cli/internal/state"
"github.com/jlaneve/cwt-cli/internal/types"
)
@@ -78,6 +79,7 @@ func showEnhancedStatus(sm *state.Manager, summary, showBranch bool) error {
// showStatusSummary shows a high-level summary of all sessions
func showStatusSummary(sessions []types.Session) error {
+ formatter := operations.NewStatusFormat()
fmt.Println("π Session Summary")
fmt.Println(strings.Repeat("=", 50))
@@ -134,7 +136,7 @@ func showStatusSummary(sessions []types.Session) error {
if i >= 3 { // Show top 3 most recent
break
}
- fmt.Printf(" β’ %s: %s\n", session.Core.Name, FormatActivity(session.LastActivity))
+ fmt.Printf(" β’ %s: %s\n", session.Core.Name, formatter.FormatActivity(session.LastActivity))
}
}
@@ -159,6 +161,7 @@ func showDetailedStatus(sessions []types.Session, showBranch bool) error {
// renderSessionStatus renders detailed status for a single session
func renderSessionStatus(session types.Session, showBranch bool) {
+ formatter := operations.NewStatusFormat()
// Session header
fmt.Printf("π·οΈ %s", session.Core.Name)
@@ -185,7 +188,7 @@ func renderSessionStatus(session types.Session, showBranch bool) {
fmt.Printf(" (%s)\n", strings.Join(statusIndicators, ", "))
// Show activity timing
- fmt.Printf(" β° Last activity: %s\n", FormatActivity(session.LastActivity))
+ fmt.Printf(" β° Last activity: %s\n", formatter.FormatActivity(session.LastActivity))
// Show Claude status
claudeIcon := getClaudeIcon(session.ClaudeStatus.State)
@@ -197,7 +200,7 @@ func renderSessionStatus(session types.Session, showBranch bool) {
if !session.ClaudeStatus.LastMessage.IsZero() {
age := time.Since(session.ClaudeStatus.LastMessage)
- fmt.Printf(" (last: %s ago)", FormatDuration(age))
+ fmt.Printf(" (last: %s ago)", formatter.FormatDuration(age))
}
fmt.Println()
diff --git a/internal/operations/cleanup.go b/internal/operations/cleanup.go
new file mode 100644
index 0000000..56670d1
--- /dev/null
+++ b/internal/operations/cleanup.go
@@ -0,0 +1,255 @@
+package operations
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/jlaneve/cwt-cli/internal/state"
+)
+
+// CleanupStats tracks the results of a cleanup operation
+type CleanupStats struct {
+ StaleSessions int
+ OrphanedTmux int
+ OrphanedWorktrees int
+ Cleaned int
+ Failed int
+ Errors []string
+}
+
+// CleanupOperations provides business logic for cleanup operations
+type CleanupOperations struct {
+ stateManager *state.Manager
+}
+
+// NewCleanupOperations creates a new CleanupOperations instance
+func NewCleanupOperations(sm *state.Manager) *CleanupOperations {
+ return &CleanupOperations{
+ stateManager: sm,
+ }
+}
+
+// FindAndCleanupStaleResources finds and optionally cleans up stale CWT resources
+func (c *CleanupOperations) FindAndCleanupStaleResources(dryRun bool) (*CleanupStats, error) {
+ stats := &CleanupStats{
+ Errors: make([]string, 0),
+ }
+
+ // Find stale sessions
+ staleSessions, err := c.stateManager.FindStaleSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to find stale sessions: %w", err)
+ }
+ stats.StaleSessions = len(staleSessions)
+
+ // Clean up stale sessions
+ for _, session := range staleSessions {
+ if dryRun {
+ fmt.Printf("Would clean up stale session: %s (tmux: %s, worktree: %s)\n",
+ session.Core.Name, session.Core.TmuxSession, session.Core.WorktreePath)
+ continue
+ }
+
+ if err := c.stateManager.DeleteSession(session.Core.ID); err != nil {
+ stats.Failed++
+ errMsg := fmt.Sprintf("Failed to delete session %s: %v", session.Core.Name, err)
+ stats.Errors = append(stats.Errors, errMsg)
+ } else {
+ stats.Cleaned++
+ }
+ }
+
+ // Find orphaned tmux sessions
+ orphanedTmux, err := c.findOrphanedTmuxSessions()
+ if err != nil {
+ return stats, fmt.Errorf("failed to find orphaned tmux sessions: %w", err)
+ }
+ stats.OrphanedTmux = len(orphanedTmux)
+
+ // Clean up orphaned tmux sessions
+ for _, tmuxSession := range orphanedTmux {
+ if dryRun {
+ fmt.Printf("Would kill orphaned tmux session: %s\n", tmuxSession)
+ continue
+ }
+
+ if err := c.killTmuxSession(tmuxSession); err != nil {
+ stats.Failed++
+ errMsg := fmt.Sprintf("Failed to kill tmux session %s: %v", tmuxSession, err)
+ stats.Errors = append(stats.Errors, errMsg)
+ } else {
+ stats.Cleaned++
+ }
+ }
+
+ // Find orphaned worktrees
+ orphanedWorktrees, err := c.findOrphanedWorktrees()
+ if err != nil {
+ return stats, fmt.Errorf("failed to find orphaned worktrees: %w", err)
+ }
+ stats.OrphanedWorktrees = len(orphanedWorktrees)
+
+ // Clean up orphaned worktrees
+ for _, worktree := range orphanedWorktrees {
+ if dryRun {
+ fmt.Printf("Would remove orphaned worktree: %s\n", worktree)
+ continue
+ }
+
+ if err := c.removeWorktree(worktree); err != nil {
+ stats.Failed++
+ errMsg := fmt.Sprintf("Failed to remove worktree %s: %v", worktree, err)
+ stats.Errors = append(stats.Errors, errMsg)
+ } else {
+ stats.Cleaned++
+ }
+ }
+
+ return stats, nil
+}
+
+// findOrphanedTmuxSessions finds tmux sessions that start with "cwt-" but don't have corresponding CWT sessions
+func (c *CleanupOperations) findOrphanedTmuxSessions() ([]string, error) {
+ // Get all tmux sessions
+ tmuxSessions, err := c.stateManager.GetTmuxChecker().ListSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list tmux sessions: %w", err)
+ }
+
+ // Get current CWT sessions
+ sessions, err := c.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current sessions: %w", err)
+ }
+
+ // Create a map of active CWT tmux session names
+ activeTmux := make(map[string]bool)
+ for _, session := range sessions {
+ activeTmux[session.Core.TmuxSession] = true
+ }
+
+ // Find orphaned sessions
+ var orphaned []string
+ for _, tmuxSession := range tmuxSessions {
+ if strings.HasPrefix(tmuxSession, "cwt-") && !activeTmux[tmuxSession] {
+ orphaned = append(orphaned, tmuxSession)
+ }
+ }
+
+ return orphaned, nil
+}
+
+// findOrphanedWorktrees finds git worktrees in .cwt/worktrees/ that don't have corresponding CWT sessions
+func (c *CleanupOperations) findOrphanedWorktrees() ([]string, error) {
+ worktreesDir := filepath.Join(c.stateManager.GetDataDir(), "worktrees")
+
+ // Check if worktrees directory exists
+ if _, err := os.Stat(worktreesDir); os.IsNotExist(err) {
+ return nil, nil // No worktrees directory means no orphaned worktrees
+ }
+
+ // Get all worktree directories
+ entries, err := os.ReadDir(worktreesDir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read worktrees directory: %w", err)
+ }
+
+ // Get current CWT sessions
+ sessions, err := c.stateManager.DeriveFreshSessions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current sessions: %w", err)
+ }
+
+ // Create a map of active session names
+ activeNames := make(map[string]bool)
+ for _, session := range sessions {
+ activeNames[session.Core.Name] = true
+ }
+
+ // Find orphaned worktrees
+ var orphaned []string
+ for _, entry := range entries {
+ if entry.IsDir() && !activeNames[entry.Name()] {
+ orphaned = append(orphaned, entry.Name())
+ }
+ }
+
+ return orphaned, nil
+}
+
+// killTmuxSession kills a tmux session
+func (c *CleanupOperations) killTmuxSession(sessionName string) error {
+ return c.stateManager.GetTmuxChecker().KillSession(sessionName)
+}
+
+// removeWorktree removes a git worktree
+func (c *CleanupOperations) removeWorktree(name string) error {
+ // Security: Validate worktree name to prevent command injection
+ if !isValidWorktreeName(name) {
+ return fmt.Errorf("invalid worktree name: %s", name)
+ }
+
+ worktreePath := filepath.Join(c.stateManager.GetDataDir(), "worktrees", name)
+
+ // Security: Validate that the path is within our data directory
+ if !isPathWithinDataDir(worktreePath, c.stateManager.GetDataDir()) {
+ return fmt.Errorf("worktree path outside data directory: %s", worktreePath)
+ }
+
+ // Use git worktree remove command
+ cmd := exec.Command("git", "worktree", "remove", worktreePath)
+ if err := cmd.Run(); err != nil {
+ // If git worktree remove fails, try force removal
+ cmd = exec.Command("git", "worktree", "remove", "--force", worktreePath)
+ if err := cmd.Run(); err != nil {
+ // If that also fails, remove the directory manually
+ return os.RemoveAll(worktreePath)
+ }
+ }
+
+ return nil
+}
+
+// isValidWorktreeName validates that a worktree name is safe
+func isValidWorktreeName(name string) bool {
+ // Reject empty names
+ if name == "" {
+ return false
+ }
+ // Reject paths with directory traversal patterns
+ if strings.Contains(name, "..") {
+ return false
+ }
+ // Reject paths with null bytes
+ if strings.Contains(name, "\x00") {
+ return false
+ }
+ // Reject names with shell metacharacters
+ dangerousChars := []string{";", "&", "|", "$", "`", "(", ")", "{", "}", "[", "]", "*", "?", "<", ">", "~", " ", "\t", "\n", "\r"}
+ for _, char := range dangerousChars {
+ if strings.Contains(name, char) {
+ return false
+ }
+ }
+ // Reject names starting with dash (could be interpreted as flags)
+ if strings.HasPrefix(name, "-") {
+ return false
+ }
+ return true
+}
+
+// isPathWithinDataDir validates that a path is within the expected data directory
+func isPathWithinDataDir(path, dataDir string) bool {
+ // Clean and resolve both paths
+ cleanPath := filepath.Clean(path)
+ cleanDataDir := filepath.Clean(dataDir)
+
+ // Make sure the path starts with the data directory
+ expectedPrefix := filepath.Join(cleanDataDir, "worktrees") + string(filepath.Separator)
+ cleanPathWithSep := cleanPath + string(filepath.Separator)
+
+ return strings.HasPrefix(cleanPathWithSep, expectedPrefix) || cleanPath == filepath.Join(cleanDataDir, "worktrees")
+}
diff --git a/internal/operations/cleanup_test.go b/internal/operations/cleanup_test.go
new file mode 100644
index 0000000..6a992a6
--- /dev/null
+++ b/internal/operations/cleanup_test.go
@@ -0,0 +1,266 @@
+package operations
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/jlaneve/cwt-cli/internal/clients/claude"
+ "github.com/jlaneve/cwt-cli/internal/clients/git"
+ "github.com/jlaneve/cwt-cli/internal/clients/tmux"
+ "github.com/jlaneve/cwt-cli/internal/state"
+)
+
+func TestCleanupOperations_FindAndCleanupStaleResources_NoOrphans(t *testing.T) {
+ tmpDir := t.TempDir()
+ dataDir := filepath.Join(tmpDir, ".cwt")
+
+ tmuxChecker := tmux.NewMockChecker()
+ config := state.Config{
+ DataDir: dataDir,
+ TmuxChecker: tmuxChecker,
+ GitChecker: git.NewMockChecker(),
+ ClaudeChecker: claude.NewMockChecker(),
+ BaseBranch: "main",
+ }
+
+ manager := state.NewManager(config)
+ defer manager.Close()
+
+ cleanupOps := NewCleanupOperations(manager)
+
+ // Test with no sessions (should find no orphans)
+ stats, err := cleanupOps.FindAndCleanupStaleResources(true) // dry run
+ if err != nil {
+ t.Fatalf("FindAndCleanupStaleResources() error = %v", err)
+ }
+
+ if stats.StaleSessions != 0 {
+ t.Errorf("Expected 0 stale sessions, got %d", stats.StaleSessions)
+ }
+ if stats.OrphanedTmux != 0 {
+ t.Errorf("Expected 0 orphaned tmux sessions, got %d", stats.OrphanedTmux)
+ }
+ if stats.OrphanedWorktrees != 0 {
+ t.Errorf("Expected 0 orphaned worktrees, got %d", stats.OrphanedWorktrees)
+ }
+}
+
+func TestCleanupOperations_FindAndCleanupStaleResources_WithStaleSession(t *testing.T) {
+ tmpDir := t.TempDir()
+ dataDir := filepath.Join(tmpDir, ".cwt")
+
+ tmuxChecker := tmux.NewMockChecker()
+ config := state.Config{
+ DataDir: dataDir,
+ TmuxChecker: tmuxChecker,
+ GitChecker: git.NewMockChecker(),
+ ClaudeChecker: claude.NewMockChecker(),
+ BaseBranch: "main",
+ }
+
+ manager := state.NewManager(config)
+ defer manager.Close()
+
+ // Create a session
+ sessionOps := NewSessionOperations(manager)
+ err := sessionOps.CreateSession("stale-session")
+ if err != nil {
+ t.Fatalf("CreateSession() error = %v", err)
+ }
+
+ // Make the tmux session appear dead
+ tmuxChecker.SetSessionAlive("cwt-stale-session", false)
+
+ cleanupOps := NewCleanupOperations(manager)
+
+ // Test dry run - should find stale session but not clean it
+ stats, err := cleanupOps.FindAndCleanupStaleResources(true)
+ if err != nil {
+ t.Fatalf("FindAndCleanupStaleResources(dry run) error = %v", err)
+ }
+
+ if stats.StaleSessions != 1 {
+ t.Errorf("Expected 1 stale session, got %d", stats.StaleSessions)
+ }
+ if stats.Cleaned != 0 {
+ t.Errorf("Expected 0 cleaned in dry run, got %d", stats.Cleaned)
+ }
+
+ // Verify session still exists
+ sessions, _ := sessionOps.GetAllSessions()
+ if len(sessions) != 1 {
+ t.Errorf("Expected session to still exist after dry run, got %d sessions", len(sessions))
+ }
+
+ // Test actual cleanup
+ stats, err = cleanupOps.FindAndCleanupStaleResources(false)
+ if err != nil {
+ t.Fatalf("FindAndCleanupStaleResources(cleanup) error = %v", err)
+ }
+
+ if stats.StaleSessions != 1 {
+ t.Errorf("Expected 1 stale session found, got %d", stats.StaleSessions)
+ }
+ if stats.Cleaned < 1 {
+ t.Errorf("Expected at least 1 session cleaned, got %d", stats.Cleaned)
+ }
+
+ // Verify session was deleted
+ sessions, _ = sessionOps.GetAllSessions()
+ if len(sessions) != 0 {
+ t.Errorf("Expected session to be deleted after cleanup, got %d sessions", len(sessions))
+ }
+}
+
+func TestCleanupOperations_FindAndCleanupStaleResources_WithOrphanedTmux(t *testing.T) {
+ tmpDir := t.TempDir()
+ dataDir := filepath.Join(tmpDir, ".cwt")
+
+ tmuxChecker := tmux.NewMockChecker()
+
+ // Add orphaned tmux sessions
+ tmuxChecker.SetSessionAlive("cwt-orphaned-1", true)
+ tmuxChecker.SetSessionAlive("cwt-orphaned-2", true)
+ tmuxChecker.SetSessionAlive("non-cwt-session", true) // Should be ignored
+
+ config := state.Config{
+ DataDir: dataDir,
+ TmuxChecker: tmuxChecker,
+ GitChecker: git.NewMockChecker(),
+ ClaudeChecker: claude.NewMockChecker(),
+ BaseBranch: "main",
+ }
+
+ manager := state.NewManager(config)
+ defer manager.Close()
+
+ cleanupOps := NewCleanupOperations(manager)
+
+ // Test dry run - should find orphaned tmux sessions
+ stats, err := cleanupOps.FindAndCleanupStaleResources(true)
+ if err != nil {
+ t.Fatalf("FindAndCleanupStaleResources(dry run) error = %v", err)
+ }
+
+ if stats.OrphanedTmux != 2 {
+ t.Errorf("Expected 2 orphaned tmux sessions, got %d", stats.OrphanedTmux)
+ }
+ if stats.Cleaned != 0 {
+ t.Errorf("Expected 0 cleaned in dry run, got %d", stats.Cleaned)
+ }
+
+ // Verify tmux sessions still exist
+ if len(tmuxChecker.KilledSessions) != 0 {
+ t.Errorf("Expected no sessions killed in dry run, got %d", len(tmuxChecker.KilledSessions))
+ }
+
+ // Test actual cleanup
+ stats, err = cleanupOps.FindAndCleanupStaleResources(false)
+ if err != nil {
+ t.Fatalf("FindAndCleanupStaleResources(cleanup) error = %v", err)
+ }
+
+ if stats.OrphanedTmux != 2 {
+ t.Errorf("Expected 2 orphaned tmux sessions found, got %d", stats.OrphanedTmux)
+ }
+ if stats.Cleaned != 2 {
+ t.Errorf("Expected 2 sessions cleaned, got %d", stats.Cleaned)
+ }
+
+ // Verify tmux sessions were killed
+ if len(tmuxChecker.KilledSessions) != 2 {
+ t.Errorf("Expected 2 sessions killed, got %d", len(tmuxChecker.KilledSessions))
+ }
+}
+
+func TestCleanupOperations_CleanupStats(t *testing.T) {
+ stats := &CleanupStats{
+ StaleSessions: 2,
+ OrphanedTmux: 1,
+ OrphanedWorktrees: 0,
+ Cleaned: 2,
+ Failed: 1,
+ Errors: []string{"Failed to delete session: permission denied"},
+ }
+
+ if len(stats.Errors) != 1 {
+ t.Errorf("Expected 1 error, got %d", len(stats.Errors))
+ }
+
+ if stats.Errors[0] != "Failed to delete session: permission denied" {
+ t.Errorf("Unexpected error message: %s", stats.Errors[0])
+ }
+
+ totalFound := stats.StaleSessions + stats.OrphanedTmux + stats.OrphanedWorktrees
+ if totalFound != 3 {
+ t.Errorf("Expected total found = 3, got %d", totalFound)
+ }
+}
+
+func TestIsValidWorktreeName(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected bool
+ }{
+ {"valid name", "valid-session", true},
+ {"valid with underscore", "valid_session", true},
+ {"valid with numbers", "session123", true},
+ {"empty name", "", false},
+ {"directory traversal", "../etc/passwd", false},
+ {"null byte", "session\x00name", false},
+ {"semicolon injection", "session;rm -rf /", false},
+ {"ampersand injection", "session&whoami", false},
+ {"pipe injection", "session|cat /etc/passwd", false},
+ {"dollar injection", "session$USER", false},
+ {"backtick injection", "session`whoami`", false},
+ {"parentheses injection", "session(whoami)", false},
+ {"braces injection", "session{whoami}", false},
+ {"brackets injection", "session[whoami]", false},
+ {"asterisk", "session*", false},
+ {"question mark", "session?", false},
+ {"less than", "session