Skip to content

Commit 1ec3871

Browse files
authored
Merge pull request #483 from bborn/task/1905-fix-kanban-column-layout-height-issue
Fix kanban column layout height issue
2 parents c9697f9 + 4d8c78a commit 1ec3871

File tree

3 files changed

+163
-247
lines changed

3 files changed

+163
-247
lines changed

internal/config/keybindings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type KeybindingsConfig struct {
5454
DenyPrompt *KeybindingConfig `yaml:"deny_prompt,omitempty"`
5555
Spotlight *KeybindingConfig `yaml:"spotlight,omitempty"`
5656
SpotlightSync *KeybindingConfig `yaml:"spotlight_sync,omitempty"`
57+
QuickInput *KeybindingConfig `yaml:"quick_input,omitempty"`
5758
}
5859

5960
// DefaultKeybindingsConfigPath returns the default path for the keybindings config file.

internal/ui/app.go

Lines changed: 84 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
17271732
func (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).
42254187
func (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

Comments
 (0)