diff --git a/cmd/task/cli_test.go b/cmd/task/cli_test.go index 44122ed7..aea8715d 100644 --- a/cmd/task/cli_test.go +++ b/cmd/task/cli_test.go @@ -446,6 +446,117 @@ func TestCLIExecuteTask(t *testing.T) { } } +// TestCLIExecuteTaskDangerous tests queueing a task for execution in dangerous mode +func TestCLIExecuteTaskDangerous(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer database.Close() + defer os.Remove(dbPath) + + // Create a backlog task + task := &db.Task{ + Title: "Task to execute dangerously", + Status: db.StatusBacklog, + Type: db.TypeCode, + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + + // Set dangerous mode and queue (simulate execute --dangerous) + if err := database.UpdateTaskDangerousMode(task.ID, true); err != nil { + t.Fatalf("UpdateTaskDangerousMode() error = %v", err) + } + if err := database.UpdateTaskStatus(task.ID, db.StatusQueued); err != nil { + t.Fatalf("UpdateTaskStatus() error = %v", err) + } + + // Verify status and dangerous mode + fetched, err := database.GetTask(task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if fetched.Status != db.StatusQueued { + t.Errorf("Status = %v, want %v", fetched.Status, db.StatusQueued) + } + if !fetched.DangerousMode { + t.Error("DangerousMode = false, want true") + } +} + +// TestCLICreateTaskDangerous tests creating a task with --execute --dangerous +func TestCLICreateTaskDangerous(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer database.Close() + defer os.Remove(dbPath) + + // Create a task with dangerous mode (simulate create --execute --dangerous) + task := &db.Task{ + Title: "Dangerous task", + Status: db.StatusQueued, + Type: db.TypeCode, + DangerousMode: true, + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + + // Verify dangerous mode persists through CreateTask + fetched, err := database.GetTask(task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if fetched.Status != db.StatusQueued { + t.Errorf("Status = %v, want %v", fetched.Status, db.StatusQueued) + } + if !fetched.DangerousMode { + t.Error("DangerousMode = false, want true") + } +} + +// TestCLICreateTaskDangerousWithoutExecute tests that --dangerous without --execute doesn't set dangerous mode +func TestCLICreateTaskDangerousWithoutExecute(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer database.Close() + defer os.Remove(dbPath) + + // Simulate create --dangerous (without --execute): DangerousMode should be false + task := &db.Task{ + Title: "Not really dangerous", + Status: db.StatusBacklog, + Type: db.TypeCode, + DangerousMode: false, // --dangerous && --execute is false + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + + fetched, err := database.GetTask(task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if fetched.DangerousMode { + t.Error("DangerousMode = true, want false (--dangerous without --execute should not set dangerous mode)") + } +} + // TestCLICloseTask tests marking a task as done func TestCLICloseTask(t *testing.T) { tmpDir := t.TempDir() diff --git a/cmd/task/main.go b/cmd/task/main.go index b94985f0..2a4576a1 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -476,6 +476,7 @@ Examples: project, _ := cmd.Flags().GetString("project") taskExecutor, _ := cmd.Flags().GetString("executor") execute, _ := cmd.Flags().GetBool("execute") + createDangerous, _ := cmd.Flags().GetBool("dangerous") tags, _ := cmd.Flags().GetString("tags") pinned, _ := cmd.Flags().GetBool("pinned") branch, _ := cmd.Flags().GetString("branch") @@ -581,15 +582,16 @@ Examples: // Create the task task := &db.Task{ - Title: title, - Body: body, - Status: status, - Type: taskType, - Project: project, - Executor: taskExecutor, - Tags: tags, - Pinned: pinned, - SourceBranch: branch, + Title: title, + Body: body, + Status: status, + Type: taskType, + Project: project, + Executor: taskExecutor, + Tags: tags, + Pinned: pinned, + SourceBranch: branch, + DangerousMode: createDangerous && execute, } if err := database.CreateTask(task); err != nil { @@ -617,7 +619,11 @@ Examples: msg += fmt.Sprintf(" (branch: %s)", branch) } if execute { - msg += " (queued for execution)" + if createDangerous { + msg += " (queued for execution in dangerous mode)" + } else { + msg += " (queued for execution)" + } } fmt.Println(successStyle.Render(msg)) } @@ -628,6 +634,7 @@ Examples: createCmd.Flags().StringP("project", "p", "", "Project name (auto-detected from cwd if not specified)") createCmd.Flags().StringP("executor", "e", "", "Task executor: claude, codex, gemini, pi, opencode, openclaw (default: claude)") createCmd.Flags().BoolP("execute", "x", false, "Queue task for immediate execution") + createCmd.Flags().Bool("dangerous", false, "Execute in dangerous mode (requires --execute)") createCmd.Flags().String("tags", "", "Task tags (comma-separated)") createCmd.Flags().Bool("pinned", false, "Pin the task to the top of its column") createCmd.Flags().StringP("branch", "b", "", "Existing branch to checkout for worktree (e.g., fix/ui-overflow)") @@ -1343,6 +1350,7 @@ Examples: oldProject := task.Project execute, _ := cmd.Flags().GetBool("execute") + moveDangerous, _ := cmd.Flags().GetBool("dangerous") // Confirm unless --force flag is set force, _ := cmd.Flags().GetBool("force") @@ -1368,16 +1376,27 @@ Examples: // Queue for execution if requested if execute { + if moveDangerous { + if err := database.UpdateTaskDangerousMode(newTaskID, true); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error setting dangerous mode: "+err.Error())) + os.Exit(1) + } + } if err := database.UpdateTaskStatus(newTaskID, db.StatusQueued); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error queueing task: "+err.Error())) os.Exit(1) } - fmt.Println(successStyle.Render(fmt.Sprintf("Queued task #%d for execution", newTaskID))) + msg := fmt.Sprintf("Queued task #%d for execution", newTaskID) + if moveDangerous { + msg += " (dangerous mode)" + } + fmt.Println(successStyle.Render(msg)) } }, } moveCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") moveCmd.Flags().BoolP("execute", "e", false, "Queue the task for execution after moving") + moveCmd.Flags().Bool("dangerous", false, "Execute in dangerous mode (requires --execute)") rootCmd.AddCommand(moveCmd) // Execute subcommand - queue a task for execution @@ -1390,7 +1409,8 @@ Examples: Examples: task execute 42 task queue 42 - task run 42`, + task run 42 + task execute 42 --dangerous # Execute in dangerous mode`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { var taskID int64 @@ -1399,6 +1419,8 @@ Examples: os.Exit(1) } + executeDangerous, _ := cmd.Flags().GetBool("dangerous") + // Open database dbPath := db.DefaultPath() database, err := db.Open(dbPath) @@ -1428,14 +1450,27 @@ Examples: return } + // Set dangerous mode if requested + if executeDangerous { + if err := database.UpdateTaskDangerousMode(taskID, true); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error setting dangerous mode: "+err.Error())) + os.Exit(1) + } + } + if err := database.UpdateTaskStatus(taskID, db.StatusQueued); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) } - fmt.Println(successStyle.Render(fmt.Sprintf("Queued task #%d: %s", taskID, task.Title))) + msg := fmt.Sprintf("Queued task #%d: %s", taskID, task.Title) + if executeDangerous { + msg += " (dangerous mode)" + } + fmt.Println(successStyle.Render(msg)) }, } + executeCmd.Flags().Bool("dangerous", false, "Execute in dangerous mode (skip permission prompts)") rootCmd.AddCommand(executeCmd) statusCmd := &cobra.Command{ diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index b30aaabc..63c09d32 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -36,6 +36,7 @@ type KeybindingsConfig struct { ChangeStatus *KeybindingConfig `yaml:"change_status,omitempty"` CommandPalette *KeybindingConfig `yaml:"command_palette,omitempty"` ToggleDangerous *KeybindingConfig `yaml:"toggle_dangerous,omitempty"` + QueueDangerous *KeybindingConfig `yaml:"queue_dangerous,omitempty"` TogglePin *KeybindingConfig `yaml:"toggle_pin,omitempty"` Filter *KeybindingConfig `yaml:"filter,omitempty"` OpenWorktree *KeybindingConfig `yaml:"open_worktree,omitempty"` @@ -194,6 +195,10 @@ toggle_dangerous: keys: ["!"] help: "dangerous mode" +queue_dangerous: + keys: ["X"] + help: "execute dangerous" + toggle_pin: keys: ["t"] help: "pin/unpin" diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 3507e62b..881a2c99 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -129,9 +129,9 @@ func (db *DB) CreateTask(t *Task) error { t.Project = project.Name result, err := db.Exec(` - INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch) + INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch, dangerous_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch, t.DangerousMode) if err != nil { return fmt.Errorf("insert task: %w", err) } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 3430b7fa..5f137e55 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -236,6 +236,10 @@ func (s *Server) handleRequest(req *jsonRPCRequest) { "type": "string", "description": "Initial status (backlog, queued, defaults to backlog)", }, + "dangerous_mode": map[string]interface{}{ + "type": "boolean", + "description": "Execute in dangerous mode (skip permission prompts). Only applies when status is 'queued'.", + }, }, "required": []string{"title"}, }, @@ -545,6 +549,7 @@ func (s *Server) handleToolCall(id interface{}, params *toolCallParams) { project, _ := params.Arguments["project"].(string) taskType, _ := params.Arguments["type"].(string) status, _ := params.Arguments["status"].(string) + dangerousMode, _ := params.Arguments["dangerous_mode"].(bool) // Default project to current task's project if project == "" { @@ -559,11 +564,12 @@ func (s *Server) handleToolCall(id interface{}, params *toolCallParams) { } newTask := &db.Task{ - Title: title, - Body: body, - Project: project, - Type: taskType, - Status: status, + Title: title, + Body: body, + Project: project, + Type: taskType, + Status: status, + DangerousMode: dangerousMode && status == db.StatusQueued, } if err := s.db.CreateTask(newTask); err != nil { diff --git a/internal/ui/app.go b/internal/ui/app.go index c26c90ca..caefb86c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -72,6 +72,7 @@ type KeyMap struct { ChangeStatus key.Binding CommandPalette key.Binding ToggleDangerous key.Binding + QueueDangerous key.Binding TogglePin key.Binding Filter key.Binding OpenWorktree key.Binding @@ -111,7 +112,7 @@ func (k KeyMap) FullHelp() [][]key.Binding { {k.Left, k.Right, k.Up, k.Down}, {k.JumpToPinned, k.JumpToUnpinned}, {k.FocusBacklog, k.FocusInProgress, k.FocusBlocked, k.FocusDone, k.CollapseBacklog, k.CollapseDone}, - {k.Enter, k.New, k.Queue, k.Close}, + {k.Enter, k.New, k.Queue, k.QueueDangerous, k.Close}, {k.Retry, k.Archive, k.Delete, k.OpenWorktree, k.OpenBrowser, k.Spotlight}, {k.Filter, k.CommandPalette, k.Settings}, {k.ChangeStatus, k.TogglePin, k.Refresh, k.Help}, @@ -202,6 +203,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("!"), key.WithHelp("!", "dangerous mode"), ), + QueueDangerous: key.NewBinding( + key.WithKeys("X"), + key.WithHelp("X", "execute dangerous"), + ), TogglePin: key.NewBinding( key.WithKeys("t"), key.WithHelp("t", "pin/unpin"), @@ -325,6 +330,7 @@ func ApplyKeybindingsConfig(km KeyMap, cfg *config.KeybindingsConfig) KeyMap { km.ChangeStatus = applyBinding(km.ChangeStatus, cfg.ChangeStatus) km.CommandPalette = applyBinding(km.CommandPalette, cfg.CommandPalette) km.ToggleDangerous = applyBinding(km.ToggleDangerous, cfg.ToggleDangerous) + km.QueueDangerous = applyBinding(km.QueueDangerous, cfg.QueueDangerous) km.TogglePin = applyBinding(km.TogglePin, cfg.TogglePin) km.Filter = applyBinding(km.Filter, cfg.Filter) km.OpenWorktree = applyBinding(km.OpenWorktree, cfg.OpenWorktree) @@ -425,7 +431,7 @@ type AppModel struct { pendingTask *db.Task pendingAttachments []string queueConfirm *huh.Form - queueValue bool + queueValue string // Edit task form state editTaskForm *FormModel @@ -2008,6 +2014,19 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.queueTask(task.ID) } + case key.Matches(msg, m.keys.QueueDangerous): + if task := m.kanban.SelectedTask(); task != nil { + // Don't allow queueing if task is already processing + if task.Status == db.StatusProcessing { + return m, nil + } + // Immediately update UI for responsiveness + task.Status = db.StatusQueued + task.DangerousMode = true + m.updateTaskInList(task) + return m, m.queueTaskDangerous(task.ID) + } + case key.Matches(msg, m.keys.TogglePin): if task := m.kanban.SelectedTask(); task != nil { return m, m.toggleTaskPinned(task.ID) @@ -2567,6 +2586,21 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateTaskInList(m.selectedTask) return m, m.queueTask(m.selectedTask.ID) } + if key.Matches(keyMsg, m.keys.QueueDangerous) && m.selectedTask != nil { + // Don't allow queueing if task is already processing + if m.selectedTask.Status == db.StatusProcessing { + return m, nil + } + // Immediately update UI for responsiveness + m.selectedTask.Status = db.StatusQueued + m.selectedTask.DangerousMode = true + if m.detailView != nil { + m.detailView.UpdateTask(m.selectedTask) + } + // Update task in the list and kanban + m.updateTaskInList(m.selectedTask) + return m, m.queueTaskDangerous(m.selectedTask.ID) + } if key.Matches(keyMsg, m.keys.Retry) && m.selectedTask != nil { task := m.selectedTask if task.Status == db.StatusBlocked || task.Status == db.StatusDone || @@ -2716,15 +2750,21 @@ func (m *AppModel) updateNewTaskForm(msg tea.Msg) (tea.Model, tea.Cmd) { // Store pending task and create confirmation form m.pendingTask = form.GetDBTask() m.pendingAttachments = form.GetAttachments() - m.queueValue = false + // Default to last queue choice for this project + m.queueValue = "no" + if last, err := m.db.GetSetting("last_queue_choice:" + m.pendingTask.Project); err == nil && last != "" { + m.queueValue = last + } m.queueConfirm = huh.NewForm( huh.NewGroup( - huh.NewConfirm(). + huh.NewSelect[string](). Key("queue"). Title("Queue for execution?"). - Description("Start processing immediately"). - Affirmative("Yes"). - Negative("No"). + Options( + huh.NewOption("No — save to backlog", "no"), + huh.NewOption("Yes — execute now", "yes"), + huh.NewOption("Yes — execute in dangerous mode", "dangerous"), + ). Value(&m.queueValue), ), ).WithTheme(huh.ThemeDracula()). @@ -2766,9 +2806,16 @@ func (m *AppModel) updateNewTaskConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.queueConfirm.State == huh.StateCompleted { if m.pendingTask != nil { - if m.queueValue { + // Remember the choice for this project + m.db.SetSetting("last_queue_choice:"+m.pendingTask.Project, m.queueValue) + + switch m.queueValue { + case "yes": m.pendingTask.Status = db.StatusQueued - } else { + case "dangerous": + m.pendingTask.Status = db.StatusQueued + m.pendingTask.DangerousMode = true + default: m.pendingTask.Status = db.StatusBacklog } task := m.pendingTask @@ -3530,6 +3577,7 @@ func (m *AppModel) updateRetry(msg tea.Msg) (tea.Model, tea.Cmd) { if m.retryView.submitted { feedback := m.retryView.GetFeedback() attachments := m.retryView.GetAttachments() + dangerous := m.retryView.IsDangerous() taskID := m.retryView.task.ID // Clear kanban notification immediately for instant UI feedback delete(m.tasksNeedingInput, taskID) @@ -3548,7 +3596,7 @@ func (m *AppModel) updateRetry(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailView.Cleanup() m.detailView = nil } - return m, m.retryTaskWithAttachments(taskID, feedback, attachments) + return m, m.retryTaskWithAttachments(taskID, feedback, attachments, dangerous) } return m, cmd @@ -3888,6 +3936,24 @@ func (m *AppModel) queueTask(id int64) tea.Cmd { } } +func (m *AppModel) queueTaskDangerous(id int64) tea.Cmd { + database := m.db + exec := m.executor + return func() tea.Msg { + // Set dangerous mode before queueing + if err := database.UpdateTaskDangerousMode(id, true); err != nil { + return taskQueuedMsg{err: err} + } + err := database.UpdateTaskStatus(id, db.StatusQueued) + if err == nil { + if task, _ := database.GetTask(id); task != nil { + exec.NotifyTaskChange("status_changed", task) + } + } + return taskQueuedMsg{err: err} + } +} + func (m *AppModel) closeTask(id int64) tea.Cmd { database := m.db exec := m.executor @@ -4388,10 +4454,15 @@ func (m *AppModel) toggleTaskPinned(id int64) tea.Cmd { } } -func (m *AppModel) retryTaskWithAttachments(id int64, feedback string, attachmentPaths []string) tea.Cmd { +func (m *AppModel) retryTaskWithAttachments(id int64, feedback string, attachmentPaths []string, dangerous bool) tea.Cmd { database := m.db exec := m.executor return func() tea.Msg { + // Set dangerous mode if requested + if dangerous { + database.UpdateTaskDangerousMode(id, true) + } + // Get task to find worktree path task, _ := database.GetTask(id) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 34ed2efb..e2cedfed 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -163,6 +163,7 @@ func TestApplyKeybindingsConfig_AllBindings(t *testing.T) { ChangeStatus: &config.KeybindingConfig{Keys: []string{"s"}, Help: "status"}, CommandPalette: &config.KeybindingConfig{Keys: []string{"p"}, Help: "palette"}, ToggleDangerous: &config.KeybindingConfig{Keys: []string{"!"}, Help: "danger"}, + QueueDangerous: &config.KeybindingConfig{Keys: []string{"ctrl+x"}, Help: "exec danger"}, TogglePin: &config.KeybindingConfig{Keys: []string{"t"}, Help: "pin"}, Filter: &config.KeybindingConfig{Keys: []string{"/"}, Help: "search"}, OpenWorktree: &config.KeybindingConfig{Keys: []string{"w"}, Help: "worktree"}, @@ -1168,6 +1169,104 @@ func TestDefaultKeyMap_ApproveAndDenyKeys(t *testing.T) { } } +func TestDefaultKeyMap_QueueDangerousKey(t *testing.T) { + keys := DefaultKeyMap() + if keys.QueueDangerous.Help().Key != "X" { + t.Errorf("QueueDangerous key should be 'X', got '%s'", keys.QueueDangerous.Help().Key) + } + if keys.QueueDangerous.Help().Desc != "execute dangerous" { + t.Errorf("QueueDangerous help desc should be 'execute dangerous', got '%s'", keys.QueueDangerous.Help().Desc) + } +} + +func TestApplyKeybindingsConfig_QueueDangerous(t *testing.T) { + original := DefaultKeyMap() + cfg := &config.KeybindingsConfig{ + QueueDangerous: &config.KeybindingConfig{Keys: []string{"ctrl+x"}, Help: "exec danger"}, + } + result := ApplyKeybindingsConfig(original, cfg) + if result.QueueDangerous.Help().Key != "ctrl+x" { + t.Errorf("Expected QueueDangerous key 'ctrl+x', got '%s'", result.QueueDangerous.Help().Key) + } +} + +func TestQueueDangerous_KanbanView(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer database.Close() + + task := &db.Task{Title: "Test task", Status: db.StatusBacklog, Project: "personal"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + kanban := NewKanbanBoard(80, 24) + kanban.SetTasks([]*db.Task{task}) + + m := &AppModel{ + db: database, + keys: DefaultKeyMap(), + currentView: ViewDashboard, + tasks: []*db.Task{task}, + kanban: kanban, + tasksNeedingInput: make(map[int64]bool), + questionPrompts: make(map[int64]bool), + executorPrompts: make(map[int64]string), + } + + // Press X (QueueDangerous) on the selected task + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}} + m.Update(keyMsg) + + // Verify task status was updated to queued in the local model + if task.Status != db.StatusQueued { + t.Errorf("Expected task status to be queued, got %s", task.Status) + } + + // Verify dangerous mode was set in the local model + if !task.DangerousMode { + t.Error("Expected task DangerousMode to be true") + } +} + +func TestQueueDangerous_SkipsProcessingTask(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer database.Close() + + task := &db.Task{Title: "Test task", Status: db.StatusProcessing, Project: "personal"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + kanban := NewKanbanBoard(80, 24) + kanban.SetTasks([]*db.Task{task}) + + m := &AppModel{ + db: database, + keys: DefaultKeyMap(), + currentView: ViewDashboard, + tasks: []*db.Task{task}, + kanban: kanban, + tasksNeedingInput: make(map[int64]bool), + questionPrompts: make(map[int64]bool), + executorPrompts: make(map[int64]string), + } + + // Press X (QueueDangerous) on a processing task + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}} + m.Update(keyMsg) + + // Task should remain processing (not re-queued) + if task.Status != db.StatusProcessing { + t.Errorf("Expected task status to remain processing, got %s", task.Status) + } +} + // containsText checks if rendered text contains a substring (ignoring ANSI codes). func containsText(rendered, substr string) bool { cleaned := stripAnsiCodes(rendered) diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 3d547491..d8203470 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -2741,6 +2741,7 @@ func (m *DetailModel) renderHelp() string { claudeRunning := m.claudeMemoryMB > 0 if !claudeRunning { keys = append(keys, helpKey{"x", "execute", false}) + keys = append(keys, helpKey{"X", "execute dangerous", false}) } hasPanes := m.claudePaneID != "" || m.workdirPaneID != "" diff --git a/internal/ui/retry.go b/internal/ui/retry.go index 8fb6a413..6af5f8b1 100644 --- a/internal/ui/retry.go +++ b/internal/ui/retry.go @@ -20,6 +20,7 @@ type RetryModel struct { height int submitted bool cancelled bool + dangerous bool // submit retry in dangerous mode // Form inputs textarea textarea.Model @@ -83,6 +84,10 @@ func (m *RetryModel) Update(msg tea.Msg) (*RetryModel, tea.Cmd) { case "ctrl+s": m.submitted = true return m, nil + case "ctrl+d": + m.submitted = true + m.dangerous = true + return m, nil } } @@ -138,7 +143,7 @@ func (m *RetryModel) View() string { } // Help - helpText := "ctrl+s submit • esc cancel" + helpText := "ctrl+s submit • ctrl+d submit dangerous • esc cancel" content.WriteString(Dim.Render(helpText)) box := lipgloss.NewStyle(). @@ -168,6 +173,11 @@ func (m *RetryModel) GetAttachments() []string { return m.attachments } +// IsDangerous returns whether the retry should execute in dangerous mode. +func (m *RetryModel) IsDangerous() bool { + return m.dangerous +} + // SetSize updates the size. func (m *RetryModel) SetSize(width, height int) { m.width = width diff --git a/internal/ui/retry_test.go b/internal/ui/retry_test.go new file mode 100644 index 00000000..f578d91c --- /dev/null +++ b/internal/ui/retry_test.go @@ -0,0 +1,82 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/bborn/workflow/internal/db" +) + +func TestRetryModel_DangerousMode(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer database.Close() + + task := &db.Task{Title: "Test task", Status: db.StatusBlocked, Project: "personal"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + m := NewRetryModel(task, database, 80, 24) + + // Initially not dangerous + if m.IsDangerous() { + t.Error("Expected IsDangerous to be false initially") + } + + // Submit with ctrl+d should set dangerous mode + m.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + if !m.submitted { + t.Error("Expected submitted to be true after ctrl+d") + } + if !m.IsDangerous() { + t.Error("Expected IsDangerous to be true after ctrl+d submission") + } +} + +func TestRetryModel_NormalSubmitNotDangerous(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer database.Close() + + task := &db.Task{Title: "Test task", Status: db.StatusBlocked, Project: "personal"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + m := NewRetryModel(task, database, 80, 24) + + // Submit with ctrl+s should NOT set dangerous mode + m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + if !m.submitted { + t.Error("Expected submitted to be true after ctrl+s") + } + if m.IsDangerous() { + t.Error("Expected IsDangerous to be false after ctrl+s submission") + } +} + +func TestRetryModel_ViewShowsDangerousHint(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer database.Close() + + task := &db.Task{Title: "Test task", Status: db.StatusBlocked, Project: "personal"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + m := NewRetryModel(task, database, 80, 24) + view := m.View() + + if !containsText(view, "ctrl+d") { + t.Error("Retry view should show ctrl+d shortcut for dangerous mode submission") + } +}