diff --git a/docs/multiple-panes.md b/docs/multiple-panes.md new file mode 100644 index 00000000..98797855 --- /dev/null +++ b/docs/multiple-panes.md @@ -0,0 +1,84 @@ +# Multiple Tmux Panes Feature + +## Overview + +The task detail view now supports creating multiple tmux panes for a single task. This allows you to have multiple shell sessions or Claude instances running simultaneously within a task's context. + +## Usage + +### Keybindings + +When viewing a task in detail view: + +- **Ctrl+N**: Create a new shell pane +- **Ctrl+Shift+N** (or **Ctrl+N**): Create a new Claude pane +- **\\**: Toggle the primary shell pane (existing functionality) + +### Creating New Panes + +1. Open a task in detail view (press Enter on a task) +2. Press **Ctrl+N** to create a new shell pane + - The new pane will be created next to the Claude pane + - It will have the task's environment variables set (WORKTREE_TASK_ID, WORKTREE_PORT, WORKTREE_PATH) +3. Press **Ctrl+Shift+N** to create a new Claude pane + - A new Claude session will start in the new pane + - This allows you to have multiple AI assistants working on different aspects of the task + +### Cleanup + +All panes (including extra panes) are automatically cleaned up when: +- You navigate away from the task detail view +- You switch to a different task +- You close the application + +The panes are "broken" (moved to their own tmux windows) so they continue running in the background and can be manually attached to later if needed. + +## Technical Details + +### Database Schema + +The feature uses a new `task_panes` table to track all panes associated with a task: + +```sql +CREATE TABLE task_panes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + pane_id TEXT NOT NULL, + pane_type TEXT NOT NULL, + title TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) +``` + +### Pane Types + +- `claude`: Primary Claude/executor pane +- `shell`: Primary shell pane +- `claude-extra`: Additional Claude panes +- `shell-extra`: Additional shell panes + +### Backward Compatibility + +The existing `claude_pane_id` and `shell_pane_id` columns on the `tasks` table are maintained for backward compatibility and continue to track the primary panes. + +## Use Cases + +### Multiple Shell Sessions + +You might want multiple shell panes to: +- Run a development server in one pane +- Have a shell for git operations in another +- Monitor logs in a third pane + +### Multiple Claude Sessions + +Multiple Claude panes can be useful for: +- Having one Claude focused on implementation +- Another Claude reviewing code or documentation +- A third Claude investigating a specific problem or researching best practices + +## Implementation Notes + +- All extra panes are stored in the `task_panes` table +- Panes are automatically tracked and cleaned up when leaving the detail view +- The feature integrates seamlessly with the existing tmux-based task isolation system diff --git a/internal/db/panes.go b/internal/db/panes.go new file mode 100644 index 00000000..43cc7cd0 --- /dev/null +++ b/internal/db/panes.go @@ -0,0 +1,179 @@ +package db + +import ( + "database/sql" + "fmt" +) + +// TaskPane represents a tmux pane associated with a task. +// Each task can have multiple panes of different types (shell, claude, etc.). +type TaskPane struct { + ID int64 + TaskID int64 + PaneID string // tmux pane ID (e.g., "%1234") + PaneType string // "claude", "shell", "claude-extra", "shell-extra" + Title string // optional custom title + CreatedAt LocalTime +} + +// Pane types +const ( + PaneTypeClaude = "claude" // Primary Claude/executor pane + PaneTypeShell = "shell" // Primary shell pane + PaneTypeClaudeExtra = "claude-extra" // Additional Claude panes + PaneTypeShellExtra = "shell-extra" // Additional shell panes +) + +// CreateTaskPane creates a new pane record for a task. +func (db *DB) CreateTaskPane(pane *TaskPane) error { + result, err := db.Exec(` + INSERT INTO task_panes (task_id, pane_id, pane_type, title) + VALUES (?, ?, ?, ?) + `, pane.TaskID, pane.PaneID, pane.PaneType, pane.Title) + if err != nil { + return fmt.Errorf("insert task pane: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("get last insert id: %w", err) + } + pane.ID = id + return nil +} + +// GetTaskPanes retrieves all panes for a task. +func (db *DB) GetTaskPanes(taskID int64) ([]*TaskPane, error) { + rows, err := db.Query(` + SELECT id, task_id, pane_id, pane_type, COALESCE(title, ''), created_at + FROM task_panes + WHERE task_id = ? + ORDER BY created_at ASC + `, taskID) + if err != nil { + return nil, fmt.Errorf("query task panes: %w", err) + } + defer rows.Close() + + var panes []*TaskPane + for rows.Next() { + p := &TaskPane{} + if err := rows.Scan(&p.ID, &p.TaskID, &p.PaneID, &p.PaneType, &p.Title, &p.CreatedAt); err != nil { + return nil, fmt.Errorf("scan task pane: %w", err) + } + panes = append(panes, p) + } + return panes, nil +} + +// GetTaskPaneByID retrieves a specific pane by ID. +func (db *DB) GetTaskPaneByID(id int64) (*TaskPane, error) { + p := &TaskPane{} + err := db.QueryRow(` + SELECT id, task_id, pane_id, pane_type, COALESCE(title, ''), created_at + FROM task_panes + WHERE id = ? + `, id).Scan(&p.ID, &p.TaskID, &p.PaneID, &p.PaneType, &p.Title, &p.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query task pane: %w", err) + } + return p, nil +} + +// UpdateTaskPaneTitle updates the title of a pane. +func (db *DB) UpdateTaskPaneTitle(id int64, title string) error { + _, err := db.Exec(` + UPDATE task_panes SET title = ? + WHERE id = ? + `, title, id) + if err != nil { + return fmt.Errorf("update task pane title: %w", err) + } + return nil +} + +// DeleteTaskPane deletes a pane record. +func (db *DB) DeleteTaskPane(id int64) error { + _, err := db.Exec("DELETE FROM task_panes WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete task pane: %w", err) + } + return nil +} + +// DeleteTaskPaneByPaneID deletes a pane by its tmux pane ID. +func (db *DB) DeleteTaskPaneByPaneID(taskID int64, paneID string) error { + _, err := db.Exec("DELETE FROM task_panes WHERE task_id = ? AND pane_id = ?", taskID, paneID) + if err != nil { + return fmt.Errorf("delete task pane by pane id: %w", err) + } + return nil +} + +// ClearTaskPanes deletes all panes for a task. +func (db *DB) ClearTaskPanes(taskID int64) error { + _, err := db.Exec("DELETE FROM task_panes WHERE task_id = ?", taskID) + if err != nil { + return fmt.Errorf("clear task panes: %w", err) + } + return nil +} + +// GetPrimaryPanes returns the primary Claude and Shell panes for a task. +// Returns (claudePane, shellPane, error) +func (db *DB) GetPrimaryPanes(taskID int64) (*TaskPane, *TaskPane, error) { + panes, err := db.GetTaskPanes(taskID) + if err != nil { + return nil, nil, err + } + + var claudePane, shellPane *TaskPane + for _, p := range panes { + if p.PaneType == PaneTypeClaude && claudePane == nil { + claudePane = p + } else if p.PaneType == PaneTypeShell && shellPane == nil { + shellPane = p + } + } + return claudePane, shellPane, nil +} + +// SyncPaneFromTask syncs the primary pane IDs from the task table to task_panes. +// This is used during migration to populate the task_panes table from existing data. +func (db *DB) SyncPaneFromTask(taskID int64, claudePaneID, shellPaneID string) error { + // Clear existing primary panes + _, err := db.Exec(` + DELETE FROM task_panes + WHERE task_id = ? AND pane_type IN (?, ?) + `, taskID, PaneTypeClaude, PaneTypeShell) + if err != nil { + return fmt.Errorf("clear primary panes: %w", err) + } + + // Create Claude pane if it exists + if claudePaneID != "" { + _, err = db.Exec(` + INSERT INTO task_panes (task_id, pane_id, pane_type, title) + VALUES (?, ?, ?, ?) + `, taskID, claudePaneID, PaneTypeClaude, "Claude") + if err != nil { + return fmt.Errorf("insert claude pane: %w", err) + } + } + + // Create Shell pane if it exists + if shellPaneID != "" { + _, err = db.Exec(` + INSERT INTO task_panes (task_id, pane_id, pane_type, title) + VALUES (?, ?, ?, ?) + `, taskID, shellPaneID, PaneTypeShell, "Shell") + if err != nil { + return fmt.Errorf("insert shell pane: %w", err) + } + } + + return nil +} diff --git a/internal/db/panes_test.go b/internal/db/panes_test.go new file mode 100644 index 00000000..4186d2a6 --- /dev/null +++ b/internal/db/panes_test.go @@ -0,0 +1,202 @@ +package db + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTaskPanes(t *testing.T) { + // Create temporary database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + defer os.Remove(dbPath) + + // Create the test project first + if err := db.CreateProject(&Project{Name: "test", Path: tmpDir}); err != nil { + t.Fatalf("failed to create test project: %v", err) + } + + // Create a test task + task := &Task{ + Title: "Test Task", + Status: StatusBacklog, + Type: TypeCode, + Project: "test", + } + if err := db.CreateTask(task); err != nil { + t.Fatalf("CreateTask failed: %v", err) + } + + // Test creating a Claude pane + claudePane := &TaskPane{ + TaskID: task.ID, + PaneID: "%1234", + PaneType: PaneTypeClaude, + Title: "Claude", + } + if err := db.CreateTaskPane(claudePane); err != nil { + t.Fatalf("CreateTaskPane failed: %v", err) + } + + // Test creating a Shell pane + shellPane := &TaskPane{ + TaskID: task.ID, + PaneID: "%1235", + PaneType: PaneTypeShell, + Title: "Shell", + } + if err := db.CreateTaskPane(shellPane); err != nil { + t.Fatalf("CreateTaskPane failed: %v", err) + } + + // Test creating an extra pane + extraPane := &TaskPane{ + TaskID: task.ID, + PaneID: "%1236", + PaneType: PaneTypeShellExtra, + Title: "Extra Shell", + } + if err := db.CreateTaskPane(extraPane); err != nil { + t.Fatalf("CreateTaskPane failed: %v", err) + } + + // Test GetTaskPanes + panes, err := db.GetTaskPanes(task.ID) + if err != nil { + t.Fatalf("GetTaskPanes failed: %v", err) + } + if len(panes) != 3 { + t.Errorf("Expected 3 panes, got %d", len(panes)) + } + + // Test GetPrimaryPanes + claude, shell, err := db.GetPrimaryPanes(task.ID) + if err != nil { + t.Fatalf("GetPrimaryPanes failed: %v", err) + } + if claude == nil { + t.Error("Expected Claude pane, got nil") + } else if claude.PaneID != "%1234" { + t.Errorf("Expected Claude pane ID %%1234, got %s", claude.PaneID) + } + if shell == nil { + t.Error("Expected Shell pane, got nil") + } else if shell.PaneID != "%1235" { + t.Errorf("Expected Shell pane ID %%1235, got %s", shell.PaneID) + } + + // Test UpdateTaskPaneTitle + if err := db.UpdateTaskPaneTitle(extraPane.ID, "New Shell"); err != nil { + t.Fatalf("UpdateTaskPaneTitle failed: %v", err) + } + updatedPane, err := db.GetTaskPaneByID(extraPane.ID) + if err != nil { + t.Fatalf("GetTaskPaneByID failed: %v", err) + } + if updatedPane.Title != "New Shell" { + t.Errorf("Expected title 'New Shell', got '%s'", updatedPane.Title) + } + + // Test DeleteTaskPaneByPaneID + if err := db.DeleteTaskPaneByPaneID(task.ID, "%1236"); err != nil { + t.Fatalf("DeleteTaskPaneByPaneID failed: %v", err) + } + panes, err = db.GetTaskPanes(task.ID) + if err != nil { + t.Fatalf("GetTaskPanes failed: %v", err) + } + if len(panes) != 2 { + t.Errorf("Expected 2 panes after delete, got %d", len(panes)) + } + + // Test ClearTaskPanes + if err := db.ClearTaskPanes(task.ID); err != nil { + t.Fatalf("ClearTaskPanes failed: %v", err) + } + panes, err = db.GetTaskPanes(task.ID) + if err != nil { + t.Fatalf("GetTaskPanes failed: %v", err) + } + if len(panes) != 0 { + t.Errorf("Expected 0 panes after clear, got %d", len(panes)) + } +} + +func TestSyncPaneFromTask(t *testing.T) { + // Create temporary database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + defer os.Remove(dbPath) + + // Create the test project first + if err := db.CreateProject(&Project{Name: "test", Path: tmpDir}); err != nil { + t.Fatalf("failed to create test project: %v", err) + } + + // Create a test task + task := &Task{ + Title: "Test Task", + Status: StatusBacklog, + Type: TypeCode, + Project: "test", + } + if err := db.CreateTask(task); err != nil { + t.Fatalf("CreateTask failed: %v", err) + } + + // Sync panes from task (simulating migration) + if err := db.SyncPaneFromTask(task.ID, "%1234", "%1235"); err != nil { + t.Fatalf("SyncPaneFromTask failed: %v", err) + } + + // Verify panes were created + panes, err := db.GetTaskPanes(task.ID) + if err != nil { + t.Fatalf("GetTaskPanes failed: %v", err) + } + if len(panes) != 2 { + t.Errorf("Expected 2 panes, got %d", len(panes)) + } + + // Verify pane types + claude, shell, err := db.GetPrimaryPanes(task.ID) + if err != nil { + t.Fatalf("GetPrimaryPanes failed: %v", err) + } + if claude == nil || claude.PaneID != "%1234" { + t.Error("Claude pane not synced correctly") + } + if shell == nil || shell.PaneID != "%1235" { + t.Error("Shell pane not synced correctly") + } + + // Test re-syncing (should replace existing panes) + if err := db.SyncPaneFromTask(task.ID, "%5678", "%5679"); err != nil { + t.Fatalf("SyncPaneFromTask (re-sync) failed: %v", err) + } + + // Verify panes were updated + claude, shell, err = db.GetPrimaryPanes(task.ID) + if err != nil { + t.Fatalf("GetPrimaryPanes failed: %v", err) + } + if claude == nil || claude.PaneID != "%5678" { + t.Error("Claude pane not re-synced correctly") + } + if shell == nil || shell.PaneID != "%5679" { + t.Error("Shell pane not re-synced correctly") + } +} diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index ad92f200..2b1f8f81 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -198,6 +198,19 @@ func (db *DB) migrate() error { // Used by GetConversationHistoryLogs and HasContinuationMarker `CREATE INDEX IF NOT EXISTS idx_task_logs_task_line_type ON task_logs(task_id, line_type)`, + // Task panes table for tracking multiple tmux panes per task + `CREATE TABLE IF NOT EXISTS task_panes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + pane_id TEXT NOT NULL, + pane_type TEXT NOT NULL, + title TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + `CREATE INDEX IF NOT EXISTS idx_task_panes_task_id ON task_panes(task_id)`, + `CREATE INDEX IF NOT EXISTS idx_task_panes_pane_id ON task_panes(pane_id)`, + } for _, m := range migrations { diff --git a/internal/ui/app.go b/internal/ui/app.go index 303843ab..7a17a562 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -95,6 +95,9 @@ type KeyMap struct { // Spotlight mode Spotlight key.Binding SpotlightSync key.Binding + // Multiple panes support + NewShellPane key.Binding + NewClaudePane key.Binding } // ShortHelp returns key bindings to show in the mini help. @@ -271,6 +274,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("F"), key.WithHelp("F", "spotlight sync"), ), + NewShellPane: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new shell pane"), + ), + NewClaudePane: key.NewBinding( + key.WithKeys("ctrl+shift+n", "ctrl+N"), + key.WithHelp("ctrl+shift+n", "new claude pane"), + ), } } @@ -2405,6 +2416,20 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailView.ToggleShellPane() return m, nil } + if key.Matches(keyMsg, m.keys.NewShellPane) && m.detailView != nil { + _, err := m.detailView.CreateNewShellPane() + if err != nil { + GetLogger().Error("Failed to create new shell pane: %v", err) + } + return m, nil + } + if key.Matches(keyMsg, m.keys.NewClaudePane) && m.detailView != nil { + _, err := m.detailView.CreateNewClaudePane() + if err != nil { + GetLogger().Error("Failed to create new Claude pane: %v", err) + } + return m, nil + } // Arrow key navigation to prev/next task in the same column // Skip j/k in detail view - only use arrow keys (j/k reserved for other uses) diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 23e12a68..45c447cb 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -547,6 +547,8 @@ func (m *DetailModel) Cleanup() { if m.claudePaneID != "" || m.workdirPaneID != "" { m.breakTmuxPanes(true, true) // saveHeight=true, resizeTUI=true } + // Also break any extra panes + m.breakExtraPanes() } // CleanupWithoutSaving cleans up panes without saving the height. @@ -556,6 +558,8 @@ func (m *DetailModel) CleanupWithoutSaving() { if m.claudePaneID != "" || m.workdirPaneID != "" { m.breakTmuxPanes(false, true) // saveHeight=false, resizeTUI=true } + // Also break any extra panes + m.breakExtraPanes() } // ClearPaneState clears the cached pane state without breaking panes. diff --git a/internal/ui/pane_manager.go b/internal/ui/pane_manager.go new file mode 100644 index 00000000..90cb411c --- /dev/null +++ b/internal/ui/pane_manager.go @@ -0,0 +1,278 @@ +package ui + +import ( + "context" + "fmt" + "os" + osExec "os/exec" + "strings" + "time" + + "github.com/bborn/workflow/internal/db" +) + +// CreateNewShellPane creates a new shell pane for the current task. +// It splits from the Claude pane and returns the new pane ID. +func (m *DetailModel) CreateNewShellPane() (string, error) { + log := GetLogger() + if m.claudePaneID == "" { + return "", fmt.Errorf("no Claude pane available to split from") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + workdir := m.getWorkdir() + userShell := os.Getenv("SHELL") + if userShell == "" { + userShell = "/bin/zsh" + } + + shellWidth := m.getShellPaneWidth() + + log.Info("CreateNewShellPane: creating new shell pane, workdir=%q", workdir) + err := osExec.CommandContext(ctx, "tmux", "split-window", + "-h", "-l", shellWidth, + "-t", m.claudePaneID, + "-c", workdir, + userShell, + ).Run() + if err != nil { + log.Error("CreateNewShellPane: split-window failed: %v", err) + return "", fmt.Errorf("failed to create shell pane: %w", err) + } + + // Get the new pane ID + paneCmd := osExec.CommandContext(ctx, "tmux", "display-message", "-p", "#{pane_id}") + paneOut, err := paneCmd.Output() + if err != nil { + log.Error("CreateNewShellPane: failed to get pane ID: %v", err) + return "", fmt.Errorf("failed to get pane ID: %w", err) + } + paneID := strings.TrimSpace(string(paneOut)) + + // Set pane title + osExec.CommandContext(ctx, "tmux", "select-pane", "-t", paneID, "-T", "Shell").Run() + + // Set environment variables + if m.task != nil { + envCmd := fmt.Sprintf("export WORKTREE_TASK_ID=%d WORKTREE_PORT=%d WORKTREE_PATH=%q", m.task.ID, m.task.Port, m.task.WorktreePath) + osExec.CommandContext(ctx, "tmux", "send-keys", "-t", paneID, envCmd, "Enter").Run() + osExec.CommandContext(ctx, "tmux", "send-keys", "-t", paneID, "clear", "Enter").Run() + } + + // Store in database + if m.database != nil && m.task != nil { + pane := &db.TaskPane{ + TaskID: m.task.ID, + PaneID: paneID, + PaneType: db.PaneTypeShellExtra, + Title: "Shell", + } + if err := m.database.CreateTaskPane(pane); err != nil { + log.Error("CreateNewShellPane: failed to save pane to db: %v", err) + } else { + log.Info("CreateNewShellPane: saved pane %q to database", paneID) + } + } + + // Select back to TUI pane + if m.tuiPaneID != "" { + osExec.CommandContext(ctx, "tmux", "select-pane", "-t", m.tuiPaneID).Run() + } + + log.Info("CreateNewShellPane: created pane %q", paneID) + return paneID, nil +} + +// CreateNewClaudePane creates a new Claude pane for the current task. +// It splits from the existing Claude pane and starts a new Claude session. +func (m *DetailModel) CreateNewClaudePane() (string, error) { + log := GetLogger() + if m.claudePaneID == "" { + return "", fmt.Errorf("no Claude pane available to split from") + } + + if m.task == nil { + return "", fmt.Errorf("no task available") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + workdir := m.getWorkdir() + + log.Info("CreateNewClaudePane: creating new Claude pane, workdir=%q", workdir) + + // Split vertically to create a new pane + err := osExec.CommandContext(ctx, "tmux", "split-window", + "-v", // vertical split + "-t", m.claudePaneID, + "-c", workdir, + "sleep", "0.1", // temporary command to prevent immediate close + ).Run() + if err != nil { + log.Error("CreateNewClaudePane: split-window failed: %v", err) + return "", fmt.Errorf("failed to create Claude pane: %w", err) + } + + // Get the new pane ID + paneCmd := osExec.CommandContext(ctx, "tmux", "display-message", "-p", "#{pane_id}") + paneOut, err := paneCmd.Output() + if err != nil { + log.Error("CreateNewClaudePane: failed to get pane ID: %v", err) + return "", fmt.Errorf("failed to get pane ID: %w", err) + } + paneID := strings.TrimSpace(string(paneOut)) + + // Set pane title + osExec.CommandContext(ctx, "tmux", "select-pane", "-t", paneID, "-T", "Claude").Run() + + // Start Claude in the new pane + claudeCmd := m.buildClaudeCommand() + osExec.CommandContext(ctx, "tmux", "send-keys", "-t", paneID, claudeCmd, "Enter").Run() + + // Store in database + if m.database != nil { + pane := &db.TaskPane{ + TaskID: m.task.ID, + PaneID: paneID, + PaneType: db.PaneTypeClaudeExtra, + Title: "Claude", + } + if err := m.database.CreateTaskPane(pane); err != nil { + log.Error("CreateNewClaudePane: failed to save pane to db: %v", err) + } else { + log.Info("CreateNewClaudePane: saved pane %q to database", paneID) + } + } + + // Select back to TUI pane + if m.tuiPaneID != "" { + osExec.CommandContext(ctx, "tmux", "select-pane", "-t", m.tuiPaneID).Run() + } + + log.Info("CreateNewClaudePane: created pane %q", paneID) + return paneID, nil +} + +// buildClaudeCommand constructs the Claude command to run in a pane. +func (m *DetailModel) buildClaudeCommand() string { + // Build the same command that the executor would use + workdir := m.getWorkdir() + cmd := fmt.Sprintf("cd %q && claude", workdir) + return cmd +} + +// GetAllTaskPanes returns all pane IDs for the current task (including primary panes). +func (m *DetailModel) GetAllTaskPanes() []string { + var panes []string + + if m.claudePaneID != "" { + panes = append(panes, m.claudePaneID) + } + if m.workdirPaneID != "" { + panes = append(panes, m.workdirPaneID) + } + + // Get additional panes from database + if m.database != nil && m.task != nil { + dbPanes, err := m.database.GetTaskPanes(m.task.ID) + if err == nil { + for _, p := range dbPanes { + // Only include extra panes (not primary ones already tracked) + if p.PaneType == db.PaneTypeClaudeExtra || p.PaneType == db.PaneTypeShellExtra { + panes = append(panes, p.PaneID) + } + } + } + } + + return panes +} + +// breakExtraPanes breaks all extra panes (not the primary Claude and Shell panes). +// This is called during Cleanup to ensure all additional panes are broken. +func (m *DetailModel) breakExtraPanes() { + log := GetLogger() + if m.task == nil { + return + } + + if m.database == nil { + return + } + + // Get extra panes from database + panes, err := m.database.GetTaskPanes(m.task.ID) + if err != nil { + log.Error("breakExtraPanes: failed to get task panes: %v", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + for _, pane := range panes { + // Only break extra panes (not primary ones) + if pane.PaneType != db.PaneTypeClaudeExtra && pane.PaneType != db.PaneTypeShellExtra { + continue + } + + // Check if pane exists + checkCmd := osExec.CommandContext(ctx, "tmux", "display-message", "-t", pane.PaneID, "-p", "#{pane_id}") + if _, err := checkCmd.Output(); err != nil { + log.Debug("breakExtraPanes: pane %q doesn't exist, skipping", pane.PaneID) + continue + } + + // Break the pane + breakCmd := osExec.CommandContext(ctx, "tmux", "break-pane", "-d", "-s", pane.PaneID) + if err := breakCmd.Run(); err != nil { + log.Error("breakExtraPanes: failed to break pane %q: %v", pane.PaneID, err) + } else { + log.Debug("breakExtraPanes: broke pane %q", pane.PaneID) + } + } +} + +// CleanupAllPanes breaks all panes associated with this task. +func (m *DetailModel) CleanupAllPanes(saveHeight bool) { + log := GetLogger() + if m.task == nil { + return + } + + log.Info("CleanupAllPanes: breaking all panes for task %d", m.task.ID) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Get all panes + allPanes := m.GetAllTaskPanes() + + for _, paneID := range allPanes { + if paneID == "" { + continue + } + + // Check if pane exists + checkCmd := osExec.CommandContext(ctx, "tmux", "display-message", "-t", paneID, "-p", "#{pane_id}") + if _, err := checkCmd.Output(); err != nil { + log.Debug("CleanupAllPanes: pane %q doesn't exist, skipping", paneID) + continue + } + + // Break the pane + breakCmd := osExec.CommandContext(ctx, "tmux", "break-pane", "-d", "-s", paneID) + if err := breakCmd.Run(); err != nil { + log.Error("CleanupAllPanes: failed to break pane %q: %v", paneID, err) + } else { + log.Debug("CleanupAllPanes: broke pane %q", paneID) + } + } + + // Clear the cached pane IDs + m.claudePaneID = "" + m.workdirPaneID = "" +}