From 077c185d1cde03396e77f76dd5a8898429e5067d Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Wed, 18 Mar 2026 10:14:08 -0500 Subject: [PATCH 1/2] Add dangerous mode execution shortcut across all task entry points Enable executing tasks in dangerous mode directly without needing to start a task first and then toggle. Changes: - TUI: Add 'X' (Shift+X) keybinding to execute in dangerous mode from kanban and detail views (configurable via queue_dangerous in keybindings) - Task creation: Replace yes/no queue confirmation with 3-option select: No / Yes / Yes (dangerous mode) - Retry: Add Ctrl+D shortcut to submit retry in dangerous mode - CLI: Add --dangerous flag to execute, create, and move commands - MCP: Add dangerous_mode parameter to taskyou_create_task - DB: Include dangerous_mode in CreateTask INSERT for proper persistence Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/task/cli_test.go | 111 +++++++++++++++++++++++++++++++++ cmd/task/main.go | 61 ++++++++++++++---- internal/config/keybindings.go | 5 ++ internal/db/tasks.go | 6 +- internal/mcp/server.go | 16 +++-- internal/ui/app.go | 86 +++++++++++++++++++++---- internal/ui/app_test.go | 99 +++++++++++++++++++++++++++++ internal/ui/detail.go | 1 + internal/ui/retry.go | 12 +++- internal/ui/retry_test.go | 82 ++++++++++++++++++++++++ 10 files changed, 446 insertions(+), 33 deletions(-) create mode 100644 internal/ui/retry_test.go 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..c975a261 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,17 @@ 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 + m.queueValue = "no" 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 +2802,13 @@ func (m *AppModel) updateNewTaskConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.queueConfirm.State == huh.StateCompleted { if m.pendingTask != nil { - if 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 +3570,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 +3589,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 +3929,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 +4447,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") + } +} From 072e257350ca64f8ef9839fd61cc933d82e37f36 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sun, 22 Mar 2026 07:19:01 -0500 Subject: [PATCH 2/2] Remember last queue choice per project in task creation dialog The task creation confirmation dialog now defaults to the user's last choice for the same project, persisted via DB settings. This avoids having to re-select "Yes" or "Yes (dangerous mode)" every time when working on the same project. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/ui/app.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/ui/app.go b/internal/ui/app.go index c975a261..caefb86c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2750,7 +2750,11 @@ 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() + // 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.NewSelect[string](). @@ -2802,6 +2806,9 @@ func (m *AppModel) updateNewTaskConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.queueConfirm.State == huh.StateCompleted { if m.pendingTask != nil { + // 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