@@ -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