@@ -95,6 +95,8 @@ type KeyMap struct {
9595 // Spotlight mode
9696 Spotlight key.Binding
9797 SpotlightSync key.Binding
98+ // Quick input focus
99+ QuickInput key.Binding
98100}
99101
100102// ShortHelp returns key bindings to show in the mini help.
@@ -271,6 +273,10 @@ func DefaultKeyMap() KeyMap {
271273 key.WithKeys("F"),
272274 key.WithHelp("F", "spotlight sync"),
273275 ),
276+ QuickInput: key.NewBinding(
277+ key.WithKeys("tab"),
278+ key.WithHelp("tab", "input"),
279+ ),
274280 }
275281}
276282
@@ -336,6 +342,7 @@ func ApplyKeybindingsConfig(km KeyMap, cfg *config.KeybindingsConfig) KeyMap {
336342 km.DenyPrompt = applyBinding(km.DenyPrompt, cfg.DenyPrompt)
337343 km.Spotlight = applyBinding(km.Spotlight, cfg.Spotlight)
338344 km.SpotlightSync = applyBinding(km.SpotlightSync, cfg.SpotlightSync)
345+ km.QuickInput = applyBinding(km.QuickInput, cfg.QuickInput)
339346
340347 return km
341348}
@@ -469,11 +476,9 @@ type AppModel struct {
469476 // AI command service for natural language command interpretation
470477 aiCommandService *ai.CommandService
471478
472- // Reply input for executor prompts (multiple choice, free-form text)
473- replyInput textinput.Model
474- replyActive bool // Whether reply mode is active (typing a response)
475- replyTaskID int64 // Task ID the reply is for
476- replyPaneContent []string // Captured tmux pane lines shown above the reply input
479+ // Quick input for sending text to executor (always visible when task needs input)
480+ replyInput textinput.Model
481+ quickInputFocused bool // Whether quick input field has keyboard focus
477482
478483 // Filter state
479484 filterInput textinput.Model
@@ -718,9 +723,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
718723 return m.updateDetail(msg)
719724 }
720725
721- // Handle reply input mode (needs all message types for text input)
722- if m.currentView == ViewDashboard && m.replyActive {
723- return m.updateReplyMode (msg)
726+ // Handle quick input mode (needs all message types for text input)
727+ if m.currentView == ViewDashboard && m.quickInputFocused {
728+ return m.updateQuickInput (msg)
724729 }
725730
726731 // 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) {
821826 // Handles external approval (e.g. from tmux) where PreToolUse
822827 // logs "Agent resumed working" and transitions to processing.
823828 if m.tasksNeedingInput[t.ID] {
824- if m.latestPermissionPrompt (t.ID) == "" {
829+ if m.latestChoicePrompt (t.ID) == "" {
825830 delete(m.tasksNeedingInput, t.ID)
826831 delete(m.executorPrompts, t.ID)
827832 }
@@ -851,13 +856,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
851856 if m.tasksNeedingInput[t.ID] {
852857 // Re-validate: if task is no longer blocked, the user provided input
853858 // (e.g., from the detail view tmux pane). Also re-check permission prompts.
854- if t.Status != db.StatusBlocked && m.latestPermissionPrompt (t.ID) == "" {
859+ if t.Status != db.StatusBlocked && m.latestChoicePrompt (t.ID) == "" {
855860 delete(m.tasksNeedingInput, t.ID)
856861 delete(m.executorPrompts, t.ID)
857862 }
858863 continue
859864 }
860- if prompt := m.latestPermissionPrompt (t.ID); prompt != "" {
865+ if prompt := m.latestChoicePrompt (t.ID); prompt != "" {
861866 m.tasksNeedingInput[t.ID] = true
862867 // Capture the tmux pane content for richer display of the prompt.
863868 // 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) {
11701175 m.notification = fmt.Sprintf("%s %s executor prompt for task #%d", IconDone(), action, msg.taskID)
11711176 m.notifyUntil = time.Now().Add(3 * time.Second)
11721177 // Clear prompt state immediately for visual feedback. If the task is
1173- // still blocked (e.g. another prompt queued), the latestPermissionPrompt
1178+ // still blocked (e.g. another prompt queued), the latestChoicePrompt
11741179 // catch-up loop will re-detect it on the next poll cycle.
11751180 delete(m.tasksNeedingInput, msg.taskID)
11761181 delete(m.executorPrompts, msg.taskID)
@@ -1213,11 +1218,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12131218 // newly-blocked tasks so the user can approve/deny immediately
12141219 // without waiting for the next loadTasks poll.
12151220 if m.tasksNeedingInput[event.TaskID] {
1216- if m.latestPermissionPrompt (event.TaskID) == "" {
1221+ if m.latestChoicePrompt (event.TaskID) == "" {
12171222 delete(m.tasksNeedingInput, event.TaskID)
12181223 delete(m.executorPrompts, event.TaskID)
12191224 }
1220- } else if prompt := m.latestPermissionPrompt (event.TaskID); prompt != "" {
1225+ } else if prompt := m.latestChoicePrompt (event.TaskID); prompt != "" {
12211226 m.tasksNeedingInput[event.TaskID] = true
12221227 paneContent := executor.CapturePaneContent(event.TaskID, 15)
12231228 if paneContent != "" {
@@ -1723,102 +1728,57 @@ func (m *AppModel) renderHelp() string {
17231728
17241729// renderExecutorPromptPreview renders a compact preview of the executor's current prompt
17251730// for a blocked task that needs input. Shows the permission message from the hook log
1726- // with approve/deny/reply hints.
1731+ // with approve/deny/tab-input hints. When quick input is focused, shows the text input .
17271732func (m *AppModel) renderExecutorPromptPreview(task *db.Task) string {
1728- // If reply mode is active for this task, show the reply input
1729- if m.replyActive && m.replyTaskID == task.ID {
1730- return m.renderReplyInput(task)
1731- }
1732-
1733- prompt := m.executorPrompts[task.ID]
1734-
1735- // Extract the last meaningful lines from the captured pane content
1736- promptLines := extractPromptLines(prompt, m.width-10)
1737-
1738- // Dim style for the action hints
17391733 hintStyle := lipgloss.NewStyle().Foreground(ColorMuted)
1740- hints := hintStyle.Render("y approve N deny r reply enter detail")
1741-
1742- // Warning style for the task reference
17431734 warnStyle := lipgloss.NewStyle().Foreground(ColorWarning)
1735+ detailStyle := lipgloss.NewStyle().Foreground(ColorMuted)
17441736
17451737 barStyle := lipgloss.NewStyle().
17461738 Width(m.width).
17471739 Padding(0, 1)
17481740
1749- if len(promptLines) == 0 {
1750- // No captured content yet - show a minimal single-line hint
1751- line := warnStyle.Render(fmt.Sprintf("#%d waiting for input", task.ID)) + " " + hints
1752- return barStyle.Render(line)
1753- }
1741+ hints := hintStyle.Render("y approve N deny tab input enter detail")
17541742
1755- // Show last few lines of the prompt content so user can see multiple choice options
1756- maxLines := 5
1757- if len(promptLines) > maxLines {
1758- promptLines = promptLines[len(promptLines)-maxLines:]
1759- }
1743+ prompt := m.executorPrompts[task.ID]
1744+ promptLines := extractPromptLines(prompt, m.width-10)
17601745
17611746 var lines []string
17621747
1763- // Header line: task ID + action hints
1764- headerLine := warnStyle.Render(fmt.Sprintf("#%d ", task.ID)) + hints
1765- lines = append(lines, barStyle.Render(headerLine))
1766-
1767- // Show prompt content lines
1768- detailStyle := lipgloss.NewStyle().Foreground(ColorMuted)
1769- detailMaxWidth := m.width - 6 // padding + indent
1770- if detailMaxWidth < 20 {
1771- detailMaxWidth = 20
1772- }
1773- for _, pl := range promptLines {
1774- if len(pl) > detailMaxWidth {
1775- pl = pl[:detailMaxWidth-1] + "…"
1748+ if len(promptLines) == 0 {
1749+ // No captured content yet - show a minimal single-line hint
1750+ line := warnStyle.Render(fmt.Sprintf("#%d waiting for input", task.ID)) + " " + hints
1751+ lines = append(lines, barStyle.Render(line))
1752+ } else {
1753+ // Show last few lines of the prompt content so user can see multiple choice options
1754+ maxLines := 5
1755+ if len(promptLines) > maxLines {
1756+ promptLines = promptLines[len(promptLines)-maxLines:]
17761757 }
1777- lines = append(lines, barStyle.Render(" "+detailStyle.Render(pl)))
1778- }
17791758
1780- return strings.Join(lines, "\n")
1781- }
1759+ // Header line: task ID + action hints
1760+ headerLine := warnStyle.Render(fmt.Sprintf("#%d ", task.ID)) + hints
1761+ lines = append(lines, barStyle.Render(headerLine))
17821762
1783- // renderReplyInput renders the reply text input for a blocked task,
1784- // including captured tmux pane content so the user can see what they're responding to.
1785- func (m *AppModel) renderReplyInput(task *db.Task) string {
1786- warnStyle := lipgloss.NewStyle().Foreground(ColorWarning)
1787- hintStyle := lipgloss.NewStyle().Foreground(ColorMuted)
1788- detailStyle := lipgloss.NewStyle().Foreground(ColorMuted)
1789-
1790- barStyle := lipgloss.NewStyle().
1791- Width(m.width).
1792- Padding(0, 1)
1793-
1794- var lines []string
1795-
1796- // Show captured pane content (last N meaningful lines) so user can see the options
1797- if len(m.replyPaneContent) > 0 {
1798- // Show up to 8 lines of context from the tmux pane
1799- paneLines := m.replyPaneContent
1800- maxContextLines := 8
1801- if len(paneLines) > maxContextLines {
1802- paneLines = paneLines[len(paneLines)-maxContextLines:]
1803- }
1804- header := warnStyle.Render(fmt.Sprintf("#%d executor prompt:", task.ID))
1805- lines = append(lines, barStyle.Render(header))
1806- for _, pl := range paneLines {
1807- maxWidth := m.width - 6
1808- if maxWidth < 20 {
1809- maxWidth = 20
1810- }
1811- if len(pl) > maxWidth {
1812- pl = pl[:maxWidth-1] + "…"
1763+ // Show prompt content lines
1764+ detailMaxWidth := m.width - 6
1765+ if detailMaxWidth < 20 {
1766+ detailMaxWidth = 20
1767+ }
1768+ for _, pl := range promptLines {
1769+ if len(pl) > detailMaxWidth {
1770+ pl = pl[:detailMaxWidth-1] + "…"
18131771 }
18141772 lines = append(lines, barStyle.Render(" "+detailStyle.Render(pl)))
18151773 }
18161774 }
18171775
1818- // Reply input line
1819- label := warnStyle.Render("reply: ")
1820- hints := hintStyle.Render(" enter send esc cancel")
1821- lines = append(lines, barStyle.Render(label+m.replyInput.View()+hints))
1776+ // Show quick input bar when focused
1777+ if m.quickInputFocused {
1778+ label := warnStyle.Render("input: ")
1779+ inputHints := hintStyle.Render(" enter send esc cancel")
1780+ lines = append(lines, barStyle.Render(label+m.replyInput.View()+inputHints))
1781+ }
18221782
18231783 return strings.Join(lines, "\n")
18241784}
@@ -2012,18 +1972,6 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
20121972
20131973 case key.Matches(msg, m.keys.Retry):
20141974 if task := m.kanban.SelectedTask(); task != nil {
2015- // Only focus quick input if task is in progress or blocked
2016- if (task.Status == db.StatusProcessing || task.Status == db.StatusBlocked) &&
2017- (m.tasksNeedingInput[task.ID] || m.detectPermissionPrompt(task.ID)) {
2018- m.replyActive = true
2019- m.replyTaskID = task.ID
2020- m.replyInput.SetValue("")
2021- m.replyInput.Focus()
2022- // Capture the tmux pane content so the user can see the prompt/options
2023- paneContent := executor.CapturePaneContent(task.ID, 20)
2024- m.replyPaneContent = extractPromptLines(paneContent, m.width-6)
2025- return m, textinput.Blink
2026- }
20271975 // Allow retry for blocked, done, or backlog tasks
20281976 if task.Status == db.StatusBlocked || task.Status == db.StatusDone ||
20291977 task.Status == db.StatusBacklog {
@@ -2074,6 +2022,17 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
20742022 return m, m.syncSpotlight(task)
20752023 }
20762024
2025+ case key.Matches(msg, m.keys.QuickInput):
2026+ // Focus the quick input field if selected task needs input
2027+ if task := m.kanban.SelectedTask(); task != nil {
2028+ if m.tasksNeedingInput[task.ID] || m.detectPermissionPrompt(task.ID) {
2029+ m.quickInputFocused = true
2030+ m.replyInput.SetValue("")
2031+ m.replyInput.Focus()
2032+ return m, textinput.Blink
2033+ }
2034+ }
2035+
20772036 case key.Matches(msg, m.keys.Settings):
20782037 m.settingsView = NewSettingsModel(m.db, m.width, m.height)
20792038 m.previousView = m.currentView
@@ -2126,8 +2085,8 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
21262085 return m, nil
21272086}
21282087
2129- // updateReplyMode handles input when reply mode is active (typing a response to an executor prompt) .
2130- func (m *AppModel) updateReplyMode (msg tea.Msg) (tea.Model, tea.Cmd) {
2088+ // updateQuickInput handles input when the quick input field is focused .
2089+ func (m *AppModel) updateQuickInput (msg tea.Msg) (tea.Model, tea.Cmd) {
21312090 keyMsg, ok := msg.(tea.KeyMsg)
21322091 if !ok {
21332092 // Pass non-key messages (like blink) to the text input
@@ -2137,30 +2096,31 @@ func (m *AppModel) updateReplyMode(msg tea.Msg) (tea.Model, tea.Cmd) {
21372096 }
21382097
21392098 switch keyMsg.String() {
2140- case "esc":
2141- // Cancel reply mode
2142- m.replyActive = false
2143- m.replyTaskID = 0
2144- m.replyPaneContent = nil
2099+ case "esc", "shift+tab":
2100+ // Return focus to kanban
2101+ m.quickInputFocused = false
21452102 m.replyInput.SetValue("")
21462103 m.replyInput.Blur()
21472104 return m, nil
21482105
21492106 case "enter":
2150- // Send the reply text to the executor
2107+ // Send the text to the selected task's executor
21512108 text := strings.TrimSpace(m.replyInput.Value())
21522109 if text == "" {
2153- // Empty reply - cancel
2154- m.replyActive = false
2155- m.replyTaskID = 0
2156- m.replyPaneContent = nil
2110+ // Empty input - just unfocus
2111+ m.quickInputFocused = false
2112+ m.replyInput.Blur()
2113+ return m, nil
2114+ }
2115+ task := m.kanban.SelectedTask()
2116+ if task == nil {
2117+ m.quickInputFocused = false
2118+ m.replyInput.SetValue("")
21572119 m.replyInput.Blur()
21582120 return m, nil
21592121 }
2160- taskID := m.replyTaskID
2161- m.replyActive = false
2162- m.replyTaskID = 0
2163- m.replyPaneContent = nil
2122+ taskID := task.ID
2123+ m.quickInputFocused = false
21642124 m.replyInput.SetValue("")
21652125 m.replyInput.Blur()
21662126 return m, m.sendTextToExecutor(taskID, text)
@@ -4191,11 +4151,13 @@ func (m *AppModel) sendTextToExecutor(taskID int64, text string) tea.Cmd {
41914151 }
41924152}
41934153
4194- // latestPermissionPrompt checks whether a task has a pending permission prompt
4154+ // latestChoicePrompt checks whether a task has a pending permission/choice prompt
41954155// by reading recent DB logs written by the notification hook. Returns the prompt
41964156// message if still pending, or "" if resolved (e.g. "Agent resumed working",
41974157// user approved/denied). This is status-agnostic — works for any active task.
4198- func (m *AppModel) latestPermissionPrompt(taskID int64) string {
4158+ // Only matches "Waiting for permission" entries (actual choice prompts), NOT
4159+ // "Waiting for user input" (generic idle/end_turn scenarios).
4160+ func (m *AppModel) latestChoicePrompt(taskID int64) string {
41994161 logs, err := m.db.GetTaskLogs(taskID, 10)
42004162 if err != nil {
42014163 return ""
@@ -4204,7 +4166,7 @@ func (m *AppModel) latestPermissionPrompt(taskID int64) string {
42044166 // A pending prompt is only valid if no subsequent log indicates resolution.
42054167 for _, l := range logs {
42064168 switch {
4207- case l.LineType == "system" && ( strings.HasPrefix(l.Content, "Waiting for permission") || l.Content == "Waiting for user input "):
4169+ case l.LineType == "system" && strings.HasPrefix(l.Content, "Waiting for permission"):
42084170 return l.Content
42094171 case l.LineType == "system" && (l.Content == "Agent resumed working" || l.Content == "Claude resumed working"):
42104172 return "" // prompt was resolved
@@ -4223,7 +4185,7 @@ func (m *AppModel) latestPermissionPrompt(taskID int64) string {
42234185// poll-based detection hasn't caught up yet (e.g. a real-time event showed the
42244186// task as blocked before loadTasks detected the permission prompt).
42254187func (m *AppModel) detectPermissionPrompt(taskID int64) bool {
4226- prompt := m.latestPermissionPrompt (taskID)
4188+ prompt := m.latestChoicePrompt (taskID)
42274189 if prompt == "" {
42284190 return false
42294191 }
0 commit comments