From 1fdeb3ff84b3dfcf2aac86de9a12369057d70796 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Thu, 12 Feb 2026 10:50:23 -0600 Subject: [PATCH] Add multiple tmux panes support to task detail view This commit implements the ability to create multiple tmux panes (both shell and Claude) within a single task's detail view. Changes: - Add task_panes table to track multiple panes per task - Add DB layer for CRUD operations on task panes - Add keybindings: Ctrl+N (new shell), Ctrl+Shift+N (new Claude) - Implement pane creation and cleanup in detail view - Add comprehensive tests for pane operations - Add documentation for the feature The feature allows users to leverage tmux's full power by creating additional panes as needed, all tracked back to the task for proper cleanup when navigating away. Co-Authored-By: Claude Sonnet 4.5 --- docs/multiple-panes.md | 84 +++++++++++ internal/db/panes.go | 179 +++++++++++++++++++++++ internal/db/panes_test.go | 202 ++++++++++++++++++++++++++ internal/db/sqlite.go | 13 ++ internal/ui/app.go | 25 ++++ internal/ui/detail.go | 4 + internal/ui/pane_manager.go | 278 ++++++++++++++++++++++++++++++++++++ 7 files changed, 785 insertions(+) create mode 100644 docs/multiple-panes.md create mode 100644 internal/db/panes.go create mode 100644 internal/db/panes_test.go create mode 100644 internal/ui/pane_manager.go diff --git a/docs/multiple-panes.md b/docs/multiple-panes.md new file mode 100644 index 0000000..9879785 --- /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 0000000..43cc7cd --- /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 0000000..4186d2a --- /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 ad92f20..2b1f8f8 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 303843a..7a17a56 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 23e12a6..45c447c 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 0000000..90cb411 --- /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 = "" +}