From 72d58717cdd1b4f73caf63869964809b8282e4bb Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Mon, 2 Mar 2026 15:36:32 -0600 Subject: [PATCH] Fix kanban column layout: scoped quick input to blocked tasks only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reverted PR #478 broke the kanban layout by showing a pane preview for ALL in-progress tasks (not just blocked ones needing input). This caused the preview to constantly consume space and break height calc. Key changes: - Rename latestPermissionPrompt → latestChoicePrompt, only match "Waiting for permission" (not "Waiting for user input" generic idle/end_turn) to fix yellow highlight false positives - Replace replyActive/replyTaskID/replyPaneContent with quickInputFocused - Add Tab keybinding to focus quick input (only for tasks needing input) - Esc/Shift+Tab to unfocus, Enter to send, empty Enter to unfocus - Keep pane preview scoped to blocked tasks with permission prompts (not all in-progress tasks), preserving kanban column height - Remove 'r' key reply mode entry from Retry handler - Update all tests for new behavior Co-Authored-By: Claude Opus 4.6 --- internal/config/keybindings.go | 1 + internal/ui/app.go | 205 ++++++++++++++------------------- internal/ui/app_test.go | 146 ++++++++++++----------- 3 files changed, 157 insertions(+), 195 deletions(-) diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 91516621..b30aaabc 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -54,6 +54,7 @@ type KeybindingsConfig struct { DenyPrompt *KeybindingConfig `yaml:"deny_prompt,omitempty"` Spotlight *KeybindingConfig `yaml:"spotlight,omitempty"` SpotlightSync *KeybindingConfig `yaml:"spotlight_sync,omitempty"` + QuickInput *KeybindingConfig `yaml:"quick_input,omitempty"` } // DefaultKeybindingsConfigPath returns the default path for the keybindings config file. diff --git a/internal/ui/app.go b/internal/ui/app.go index 2a0b6fb9..a0c65431 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -95,6 +95,8 @@ type KeyMap struct { // Spotlight mode Spotlight key.Binding SpotlightSync key.Binding + // Quick input focus + QuickInput key.Binding } // ShortHelp returns key bindings to show in the mini help. @@ -271,6 +273,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("F"), key.WithHelp("F", "spotlight sync"), ), + QuickInput: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "input"), + ), } } @@ -336,6 +342,7 @@ func ApplyKeybindingsConfig(km KeyMap, cfg *config.KeybindingsConfig) KeyMap { km.DenyPrompt = applyBinding(km.DenyPrompt, cfg.DenyPrompt) km.Spotlight = applyBinding(km.Spotlight, cfg.Spotlight) km.SpotlightSync = applyBinding(km.SpotlightSync, cfg.SpotlightSync) + km.QuickInput = applyBinding(km.QuickInput, cfg.QuickInput) return km } @@ -469,11 +476,9 @@ type AppModel struct { // AI command service for natural language command interpretation aiCommandService *ai.CommandService - // Reply input for executor prompts (multiple choice, free-form text) - replyInput textinput.Model - replyActive bool // Whether reply mode is active (typing a response) - replyTaskID int64 // Task ID the reply is for - replyPaneContent []string // Captured tmux pane lines shown above the reply input + // Quick input for sending text to executor (always visible when task needs input) + replyInput textinput.Model + quickInputFocused bool // Whether quick input field has keyboard focus // Filter state filterInput textinput.Model @@ -718,9 +723,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateDetail(msg) } - // Handle reply input mode (needs all message types for text input) - if m.currentView == ViewDashboard && m.replyActive { - return m.updateReplyMode(msg) + // Handle quick input mode (needs all message types for text input) + if m.currentView == ViewDashboard && m.quickInputFocused { + return m.updateQuickInput(msg) } // Handle filter input mode (needs all message types for text input) @@ -821,7 +826,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handles external approval (e.g. from tmux) where PreToolUse // logs "Agent resumed working" and transitions to processing. if m.tasksNeedingInput[t.ID] { - if m.latestPermissionPrompt(t.ID) == "" { + if m.latestChoicePrompt(t.ID) == "" { delete(m.tasksNeedingInput, t.ID) delete(m.executorPrompts, t.ID) } @@ -851,13 +856,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.tasksNeedingInput[t.ID] { // Re-validate: if task is no longer blocked, the user provided input // (e.g., from the detail view tmux pane). Also re-check permission prompts. - if t.Status != db.StatusBlocked && m.latestPermissionPrompt(t.ID) == "" { + if t.Status != db.StatusBlocked && m.latestChoicePrompt(t.ID) == "" { delete(m.tasksNeedingInput, t.ID) delete(m.executorPrompts, t.ID) } continue } - if prompt := m.latestPermissionPrompt(t.ID); prompt != "" { + if prompt := m.latestChoicePrompt(t.ID); prompt != "" { m.tasksNeedingInput[t.ID] = true // Capture the tmux pane content for richer display of the prompt. // This shows the actual executor output (including multiple choice options) @@ -1170,7 +1175,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.notification = fmt.Sprintf("%s %s executor prompt for task #%d", IconDone(), action, msg.taskID) m.notifyUntil = time.Now().Add(3 * time.Second) // Clear prompt state immediately for visual feedback. If the task is - // still blocked (e.g. another prompt queued), the latestPermissionPrompt + // still blocked (e.g. another prompt queued), the latestChoicePrompt // catch-up loop will re-detect it on the next poll cycle. delete(m.tasksNeedingInput, msg.taskID) delete(m.executorPrompts, msg.taskID) @@ -1213,11 +1218,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // newly-blocked tasks so the user can approve/deny immediately // without waiting for the next loadTasks poll. if m.tasksNeedingInput[event.TaskID] { - if m.latestPermissionPrompt(event.TaskID) == "" { + if m.latestChoicePrompt(event.TaskID) == "" { delete(m.tasksNeedingInput, event.TaskID) delete(m.executorPrompts, event.TaskID) } - } else if prompt := m.latestPermissionPrompt(event.TaskID); prompt != "" { + } else if prompt := m.latestChoicePrompt(event.TaskID); prompt != "" { m.tasksNeedingInput[event.TaskID] = true paneContent := executor.CapturePaneContent(event.TaskID, 15) if paneContent != "" { @@ -1723,102 +1728,57 @@ func (m *AppModel) renderHelp() string { // renderExecutorPromptPreview renders a compact preview of the executor's current prompt // for a blocked task that needs input. Shows the permission message from the hook log -// with approve/deny/reply hints. +// with approve/deny/tab-input hints. When quick input is focused, shows the text input. func (m *AppModel) renderExecutorPromptPreview(task *db.Task) string { - // If reply mode is active for this task, show the reply input - if m.replyActive && m.replyTaskID == task.ID { - return m.renderReplyInput(task) - } - - prompt := m.executorPrompts[task.ID] - - // Extract the last meaningful lines from the captured pane content - promptLines := extractPromptLines(prompt, m.width-10) - - // Dim style for the action hints hintStyle := lipgloss.NewStyle().Foreground(ColorMuted) - hints := hintStyle.Render("y approve N deny r reply enter detail") - - // Warning style for the task reference warnStyle := lipgloss.NewStyle().Foreground(ColorWarning) + detailStyle := lipgloss.NewStyle().Foreground(ColorMuted) barStyle := lipgloss.NewStyle(). Width(m.width). Padding(0, 1) - if len(promptLines) == 0 { - // No captured content yet - show a minimal single-line hint - line := warnStyle.Render(fmt.Sprintf("#%d waiting for input", task.ID)) + " " + hints - return barStyle.Render(line) - } + hints := hintStyle.Render("y approve N deny tab input enter detail") - // Show last few lines of the prompt content so user can see multiple choice options - maxLines := 5 - if len(promptLines) > maxLines { - promptLines = promptLines[len(promptLines)-maxLines:] - } + prompt := m.executorPrompts[task.ID] + promptLines := extractPromptLines(prompt, m.width-10) var lines []string - // Header line: task ID + action hints - headerLine := warnStyle.Render(fmt.Sprintf("#%d ", task.ID)) + hints - lines = append(lines, barStyle.Render(headerLine)) - - // Show prompt content lines - detailStyle := lipgloss.NewStyle().Foreground(ColorMuted) - detailMaxWidth := m.width - 6 // padding + indent - if detailMaxWidth < 20 { - detailMaxWidth = 20 - } - for _, pl := range promptLines { - if len(pl) > detailMaxWidth { - pl = pl[:detailMaxWidth-1] + "…" + if len(promptLines) == 0 { + // No captured content yet - show a minimal single-line hint + line := warnStyle.Render(fmt.Sprintf("#%d waiting for input", task.ID)) + " " + hints + lines = append(lines, barStyle.Render(line)) + } else { + // Show last few lines of the prompt content so user can see multiple choice options + maxLines := 5 + if len(promptLines) > maxLines { + promptLines = promptLines[len(promptLines)-maxLines:] } - lines = append(lines, barStyle.Render(" "+detailStyle.Render(pl))) - } - - return strings.Join(lines, "\n") -} - -// renderReplyInput renders the reply text input for a blocked task, -// including captured tmux pane content so the user can see what they're responding to. -func (m *AppModel) renderReplyInput(task *db.Task) string { - warnStyle := lipgloss.NewStyle().Foreground(ColorWarning) - hintStyle := lipgloss.NewStyle().Foreground(ColorMuted) - detailStyle := lipgloss.NewStyle().Foreground(ColorMuted) - - barStyle := lipgloss.NewStyle(). - Width(m.width). - Padding(0, 1) - var lines []string + // Header line: task ID + action hints + headerLine := warnStyle.Render(fmt.Sprintf("#%d ", task.ID)) + hints + lines = append(lines, barStyle.Render(headerLine)) - // Show captured pane content (last N meaningful lines) so user can see the options - if len(m.replyPaneContent) > 0 { - // Show up to 8 lines of context from the tmux pane - paneLines := m.replyPaneContent - maxContextLines := 8 - if len(paneLines) > maxContextLines { - paneLines = paneLines[len(paneLines)-maxContextLines:] - } - header := warnStyle.Render(fmt.Sprintf("#%d executor prompt:", task.ID)) - lines = append(lines, barStyle.Render(header)) - for _, pl := range paneLines { - maxWidth := m.width - 6 - if maxWidth < 20 { - maxWidth = 20 - } - if len(pl) > maxWidth { - pl = pl[:maxWidth-1] + "…" + // Show prompt content lines + detailMaxWidth := m.width - 6 + if detailMaxWidth < 20 { + detailMaxWidth = 20 + } + for _, pl := range promptLines { + if len(pl) > detailMaxWidth { + pl = pl[:detailMaxWidth-1] + "…" } lines = append(lines, barStyle.Render(" "+detailStyle.Render(pl))) } } - // Reply input line - label := warnStyle.Render("reply: ") - hints := hintStyle.Render(" enter send esc cancel") - lines = append(lines, barStyle.Render(label+m.replyInput.View()+hints)) + // Show quick input bar when focused + if m.quickInputFocused { + label := warnStyle.Render("input: ") + inputHints := hintStyle.Render(" enter send esc cancel") + lines = append(lines, barStyle.Render(label+m.replyInput.View()+inputHints)) + } return strings.Join(lines, "\n") } @@ -2012,17 +1972,6 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Retry): if task := m.kanban.SelectedTask(); task != nil { - // If task needs input, enter reply mode instead of retry - if m.tasksNeedingInput[task.ID] || m.detectPermissionPrompt(task.ID) { - m.replyActive = true - m.replyTaskID = task.ID - m.replyInput.SetValue("") - m.replyInput.Focus() - // Capture the tmux pane content so the user can see the prompt/options - paneContent := executor.CapturePaneContent(task.ID, 20) - m.replyPaneContent = extractPromptLines(paneContent, m.width-6) - return m, textinput.Blink - } // Allow retry for blocked, done, or backlog tasks if task.Status == db.StatusBlocked || task.Status == db.StatusDone || task.Status == db.StatusBacklog { @@ -2073,6 +2022,17 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.syncSpotlight(task) } + case key.Matches(msg, m.keys.QuickInput): + // Focus the quick input field if selected task needs input + if task := m.kanban.SelectedTask(); task != nil { + if m.tasksNeedingInput[task.ID] || m.detectPermissionPrompt(task.ID) { + m.quickInputFocused = true + m.replyInput.SetValue("") + m.replyInput.Focus() + return m, textinput.Blink + } + } + case key.Matches(msg, m.keys.Settings): m.settingsView = NewSettingsModel(m.db, m.width, m.height) m.previousView = m.currentView @@ -2125,8 +2085,8 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// updateReplyMode handles input when reply mode is active (typing a response to an executor prompt). -func (m *AppModel) updateReplyMode(msg tea.Msg) (tea.Model, tea.Cmd) { +// updateQuickInput handles input when the quick input field is focused. +func (m *AppModel) updateQuickInput(msg tea.Msg) (tea.Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if !ok { // Pass non-key messages (like blink) to the text input @@ -2136,30 +2096,31 @@ func (m *AppModel) updateReplyMode(msg tea.Msg) (tea.Model, tea.Cmd) { } switch keyMsg.String() { - case "esc": - // Cancel reply mode - m.replyActive = false - m.replyTaskID = 0 - m.replyPaneContent = nil + case "esc", "shift+tab": + // Return focus to kanban + m.quickInputFocused = false m.replyInput.SetValue("") m.replyInput.Blur() return m, nil case "enter": - // Send the reply text to the executor + // Send the text to the selected task's executor text := strings.TrimSpace(m.replyInput.Value()) if text == "" { - // Empty reply - cancel - m.replyActive = false - m.replyTaskID = 0 - m.replyPaneContent = nil + // Empty input - just unfocus + m.quickInputFocused = false + m.replyInput.Blur() + return m, nil + } + task := m.kanban.SelectedTask() + if task == nil { + m.quickInputFocused = false + m.replyInput.SetValue("") m.replyInput.Blur() return m, nil } - taskID := m.replyTaskID - m.replyActive = false - m.replyTaskID = 0 - m.replyPaneContent = nil + taskID := task.ID + m.quickInputFocused = false m.replyInput.SetValue("") m.replyInput.Blur() return m, m.sendTextToExecutor(taskID, text) @@ -4190,11 +4151,13 @@ func (m *AppModel) sendTextToExecutor(taskID int64, text string) tea.Cmd { } } -// latestPermissionPrompt checks whether a task has a pending permission prompt +// latestChoicePrompt checks whether a task has a pending permission/choice prompt // by reading recent DB logs written by the notification hook. Returns the prompt // message if still pending, or "" if resolved (e.g. "Agent resumed working", // user approved/denied). This is status-agnostic — works for any active task. -func (m *AppModel) latestPermissionPrompt(taskID int64) string { +// Only matches "Waiting for permission" entries (actual choice prompts), NOT +// "Waiting for user input" (generic idle/end_turn scenarios). +func (m *AppModel) latestChoicePrompt(taskID int64) string { logs, err := m.db.GetTaskLogs(taskID, 10) if err != nil { return "" @@ -4203,7 +4166,7 @@ func (m *AppModel) latestPermissionPrompt(taskID int64) string { // A pending prompt is only valid if no subsequent log indicates resolution. for _, l := range logs { switch { - case l.LineType == "system" && (strings.HasPrefix(l.Content, "Waiting for permission") || l.Content == "Waiting for user input"): + case l.LineType == "system" && strings.HasPrefix(l.Content, "Waiting for permission"): return l.Content case l.LineType == "system" && (l.Content == "Agent resumed working" || l.Content == "Claude resumed working"): return "" // prompt was resolved @@ -4222,7 +4185,7 @@ func (m *AppModel) latestPermissionPrompt(taskID int64) string { // poll-based detection hasn't caught up yet (e.g. a real-time event showed the // task as blocked before loadTasks detected the permission prompt). func (m *AppModel) detectPermissionPrompt(taskID int64) bool { - prompt := m.latestPermissionPrompt(taskID) + prompt := m.latestChoicePrompt(taskID) if prompt == "" { return false } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index bb055504..1ee9fea4 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -1121,8 +1121,9 @@ func TestExtractPromptLinesContent(t *testing.T) { func TestRenderExecutorPromptPreview_NoPrompt(t *testing.T) { m := &AppModel{ - width: 100, - executorPrompts: make(map[int64]string), + width: 100, + executorPrompts: make(map[int64]string), + tasksNeedingInput: make(map[int64]bool), } task := &db.Task{ID: 42, Title: "Test task"} result := m.renderExecutorPromptPreview(task) @@ -1133,6 +1134,9 @@ func TestRenderExecutorPromptPreview_NoPrompt(t *testing.T) { if !containsText(result, "#42") { t.Error("expected result to contain task ID") } + if !containsText(result, "tab input") { + t.Error("expected result to contain 'tab input' hint") + } } func TestRenderExecutorPromptPreview_WithPrompt(t *testing.T) { @@ -1197,7 +1201,7 @@ func TestLatestPermissionPrompt_PermissionMessage(t *testing.T) { database.AppendTaskLog(task.ID, "system", "Waiting for permission: Edit(src/models/offer.rb)") // Should return the full permission message - result := m.latestPermissionPrompt(task.ID) + result := m.latestChoicePrompt(task.ID) if result != "Waiting for permission: Edit(src/models/offer.rb)" { t.Errorf("expected 'Waiting for permission: Edit(src/models/offer.rb)', got '%s'", result) } @@ -1220,7 +1224,7 @@ func TestLatestPermissionPrompt_GenericWaiting(t *testing.T) { // Log a generic waiting message (no specific message from hook) database.AppendTaskLog(task.ID, "system", "Waiting for permission") - result := m.latestPermissionPrompt(task.ID) + result := m.latestChoicePrompt(task.ID) if result != "Waiting for permission" { t.Errorf("expected 'Waiting for permission', got '%s'", result) } @@ -1245,7 +1249,7 @@ func TestLatestPermissionPrompt_ResumedClears(t *testing.T) { database.AppendTaskLog(task.ID, "system", "Agent resumed working") // Should return empty since agent resumed - result := m.latestPermissionPrompt(task.ID) + result := m.latestChoicePrompt(task.ID) if result != "" { t.Errorf("expected empty string after resume, got '%s'", result) } @@ -1261,13 +1265,13 @@ func TestLatestPermissionPrompt_NoLogs(t *testing.T) { m := &AppModel{db: database} // No task or logs - should return empty - result := m.latestPermissionPrompt(999) + result := m.latestChoicePrompt(999) if result != "" { t.Errorf("expected empty string for nonexistent task, got '%s'", result) } } -func TestLatestPermissionPrompt_UserInputMessage_Ignored(t *testing.T) { +func TestLatestChoicePrompt_UserInputMessage_NotMatched(t *testing.T) { database, err := db.Open(":memory:") if err != nil { t.Fatalf("Failed to create test database: %v", err) @@ -1281,14 +1285,14 @@ func TestLatestPermissionPrompt_UserInputMessage_Ignored(t *testing.T) { t.Fatalf("Failed to create task: %v", err) } - // "Waiting for user input" means the executor is waiting for input (e.g. - // multiple choice, free text) — should trigger the status line / prompt preview - // so the user can reply from the kanban view. + // "Waiting for user input" is a generic idle/end_turn scenario — should NOT + // trigger the yellow highlight or prompt preview. Only "Waiting for permission" + // entries are actual choice prompts. database.AppendTaskLog(task.ID, "system", "Waiting for user input") - result := m.latestPermissionPrompt(task.ID) - if result != "Waiting for user input" { - t.Errorf("expected 'Waiting for user input', got '%s'", result) + result := m.latestChoicePrompt(task.ID) + if result != "" { + t.Errorf("expected empty string for 'Waiting for user input', got '%s'", result) } } @@ -1351,7 +1355,7 @@ func TestJumpToNotificationKey_FocusExecutor(t *testing.T) { // TestExecutorRespondedClearsPromptState verifies that approving/denying an // executor prompt immediately clears both tasksNeedingInput and executorPrompts // for visual feedback. If the task still has a pending prompt, the -// latestPermissionPrompt catch-up loop will re-detect it on the next poll. +// latestChoicePrompt catch-up loop will re-detect it on the next poll. func TestExecutorRespondedClearsPromptState(t *testing.T) { m := &AppModel{ width: 100, @@ -1705,9 +1709,9 @@ func TestFilterChipDeletion(t *testing.T) { } } -// TestReplyMode_RKeyEntersReplyMode verifies pressing 'r' on a blocked task -// with a pending prompt enters reply mode. -func TestReplyMode_RKeyEntersReplyMode(t *testing.T) { +// TestQuickInput_TabEntersQuickInput verifies pressing Tab on a blocked task +// with a pending prompt focuses the quick input field. +func TestQuickInput_TabEntersQuickInput(t *testing.T) { database, err := db.Open(":memory:") if err != nil { t.Fatalf("Failed to create test database: %v", err) @@ -1741,20 +1745,17 @@ func TestReplyMode_RKeyEntersReplyMode(t *testing.T) { m.kanban.SetTasks(m.tasks) m.kanban.SelectTask(task.ID) - // Press 'r' to enter reply mode - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + // Press Tab to focus quick input + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) model := result.(*AppModel) - if !model.replyActive { - t.Error("replyActive should be true after pressing 'r' on a blocked task") - } - if model.replyTaskID != task.ID { - t.Errorf("replyTaskID should be %d, got %d", task.ID, model.replyTaskID) + if !model.quickInputFocused { + t.Error("quickInputFocused should be true after pressing Tab on a blocked task") } } -// TestReplyMode_EscCancels verifies pressing Esc in reply mode cancels it. -func TestReplyMode_EscCancels(t *testing.T) { +// TestQuickInput_EscUnfocuses verifies pressing Esc in quick input mode unfocuses it. +func TestQuickInput_EscUnfocuses(t *testing.T) { replyInput := textinput.New() replyInput.CharLimit = 200 replyInput.Focus() @@ -1764,8 +1765,7 @@ func TestReplyMode_EscCancels(t *testing.T) { height: 50, currentView: ViewDashboard, keys: DefaultKeyMap(), - replyActive: true, - replyTaskID: 42, + quickInputFocused: true, replyInput: replyInput, tasksNeedingInput: make(map[int64]bool), executorPrompts: make(map[int64]string), @@ -1774,43 +1774,44 @@ func TestReplyMode_EscCancels(t *testing.T) { result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape}) model := result.(*AppModel) - if model.replyActive { - t.Error("replyActive should be false after pressing Esc") - } - if model.replyTaskID != 0 { - t.Error("replyTaskID should be 0 after pressing Esc") + if model.quickInputFocused { + t.Error("quickInputFocused should be false after pressing Esc") } } -// TestReplyMode_EmptyEnterCancels verifies pressing Enter with empty input cancels reply mode. -func TestReplyMode_EmptyEnterCancels(t *testing.T) { +// TestQuickInput_EmptyEnterUnfocuses verifies pressing Enter with empty input unfocuses. +func TestQuickInput_EmptyEnterUnfocuses(t *testing.T) { replyInput := textinput.New() replyInput.CharLimit = 200 replyInput.Focus() + kanban := NewKanbanBoard(100, 50) + task := &db.Task{ID: 42, Title: "Test task", Status: db.StatusBlocked} + kanban.SetTasks([]*db.Task{task}) + m := &AppModel{ width: 100, height: 50, currentView: ViewDashboard, keys: DefaultKeyMap(), - replyActive: true, - replyTaskID: 42, + quickInputFocused: true, replyInput: replyInput, tasksNeedingInput: make(map[int64]bool), executorPrompts: make(map[int64]string), + kanban: kanban, } result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) model := result.(*AppModel) - if model.replyActive { - t.Error("replyActive should be false after pressing Enter with empty input") + if model.quickInputFocused { + t.Error("quickInputFocused should be false after pressing Enter with empty input") } } -// TestReplyMode_RKeyIgnoredWithoutBlockedTask verifies 'r' does nothing +// TestQuickInput_TabIgnoredWithoutBlockedTask verifies Tab does nothing // when the selected task doesn't need input. -func TestReplyMode_RKeyIgnoredWithoutBlockedTask(t *testing.T) { +func TestQuickInput_TabIgnoredWithoutBlockedTask(t *testing.T) { database, err := db.Open(":memory:") if err != nil { t.Fatalf("Failed to create test database: %v", err) @@ -1840,11 +1841,11 @@ func TestReplyMode_RKeyIgnoredWithoutBlockedTask(t *testing.T) { } m.kanban.SetTasks(m.tasks) - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) model := result.(*AppModel) - if model.replyActive { - t.Error("replyActive should remain false when task doesn't need input") + if model.quickInputFocused { + t.Error("quickInputFocused should remain false when task doesn't need input") } } @@ -1867,70 +1868,67 @@ func TestLatestPermissionPrompt_RepliedClears(t *testing.T) { database.AppendTaskLog(task.ID, "user", "Replied from kanban: 2") m := &AppModel{db: database} - result := m.latestPermissionPrompt(task.ID) + result := m.latestChoicePrompt(task.ID) if result != "" { t.Errorf("expected empty string after reply, got '%s'", result) } } -// TestRenderExecutorPromptPreview_ShowsReplyHint verifies the prompt preview -// includes the reply hint alongside approve/deny. -func TestRenderExecutorPromptPreview_ShowsReplyHint(t *testing.T) { +// TestRenderExecutorPromptPreview_ShowsTabInputHint verifies the prompt preview +// includes the tab input hint alongside approve/deny. +func TestRenderExecutorPromptPreview_ShowsTabInputHint(t *testing.T) { replyInput := textinput.New() replyInput.CharLimit = 200 m := &AppModel{ - width: 100, - height: 50, - executorPrompts: map[int64]string{1: "Choose option 1, 2, or 3"}, - replyInput: replyInput, + width: 100, + height: 50, + executorPrompts: map[int64]string{1: "Choose option 1, 2, or 3"}, + tasksNeedingInput: map[int64]bool{1: true}, + replyInput: replyInput, } task := &db.Task{ID: 1, Title: "Test task"} rendered := m.renderExecutorPromptPreview(task) - if !strings.Contains(rendered, "r reply") { - t.Error("prompt preview should include 'r reply' hint") - } if !strings.Contains(rendered, "y approve") { - t.Error("prompt preview should still include 'y approve' hint") + t.Error("prompt preview should include 'y approve' hint") } if !strings.Contains(rendered, "N deny") { - t.Error("prompt preview should still include 'N deny' hint") + t.Error("prompt preview should include 'N deny' hint") + } + if !strings.Contains(rendered, "tab input") { + t.Error("prompt preview should include 'tab input' hint") } } -// TestRenderExecutorPromptPreview_ReplyInputActive verifies the prompt preview -// shows the reply text input when reply mode is active for the task. -func TestRenderExecutorPromptPreview_ReplyInputActive(t *testing.T) { +// TestRenderExecutorPromptPreview_QuickInputFocused verifies the prompt preview +// shows the text input field when quick input is focused. +func TestRenderExecutorPromptPreview_QuickInputFocused(t *testing.T) { replyInput := textinput.New() replyInput.CharLimit = 200 replyInput.Focus() m := &AppModel{ - width: 100, - height: 50, - executorPrompts: map[int64]string{1: "Choose option 1, 2, or 3"}, - replyActive: true, - replyTaskID: 1, - replyInput: replyInput, - replyPaneContent: []string{"Select an option:", "1) First option", "2) Second option"}, + width: 100, + height: 50, + executorPrompts: map[int64]string{1: "Choose option 1, 2, or 3"}, + tasksNeedingInput: map[int64]bool{1: true}, + quickInputFocused: true, + replyInput: replyInput, } task := &db.Task{ID: 1, Title: "Test task"} rendered := m.renderExecutorPromptPreview(task) - if !strings.Contains(rendered, "reply:") { - t.Error("prompt preview should show reply input label when reply mode is active") + if !strings.Contains(rendered, "input:") { + t.Error("prompt preview should show input label when quick input is focused") } if !strings.Contains(rendered, "esc cancel") { - t.Error("prompt preview should show cancel hint when reply mode is active") + t.Error("prompt preview should show cancel hint when quick input is focused") } if !strings.Contains(rendered, "#1") { - t.Error("prompt preview should show task ID when reply mode is active") - } - if !strings.Contains(rendered, "First option") { - t.Error("prompt preview should show captured pane content with options") + t.Error("prompt preview should show task ID") } }