Skip to content

Commit 24e8b60

Browse files
bbornclaude
andcommitted
Fix kanban quick input for MCP taskyou_needs_input
latestChoicePrompt now detects question logs (LineType=="question") and returns isQuestion flag so callers can distinguish from permission prompts. Question prompts survive "Agent resumed working" and tool logs — only explicit user replies clear them. handlePostToolUseHook re-fetches task status after logging to respect MCP-set blocked state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0bf1f61 commit 24e8b60

File tree

3 files changed

+181
-27
lines changed

3 files changed

+181
-27
lines changed

cmd/task/main.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3664,11 +3664,32 @@ func handlePostToolUseHook(database *db.DB, taskID int64, input *ClaudeHookInput
36643664
database.AppendTaskLog(taskID, "tool", logMsg)
36653665
}
36663666

3667-
// After a tool completes, Claude is still working (will process tool results)
3668-
// Ensure task remains in "processing" state
3667+
// Re-fetch task status — the MCP server may have changed it during
3668+
// tool execution (e.g. taskyou_needs_input sets blocked).
3669+
task, err = database.GetTask(taskID)
3670+
if err != nil || task == nil {
3671+
return err
3672+
}
3673+
3674+
// After a tool completes, Claude is still working (will process tool results).
3675+
// Ensure task remains in "processing" state — unless an MCP tool
3676+
// intentionally set it to blocked for user input.
36693677
if task.Status == db.StatusBlocked {
3670-
database.UpdateTaskStatus(taskID, db.StatusProcessing)
3671-
database.AppendTaskLog(taskID, "system", "Agent resumed working")
3678+
// Check if the blocked state is from a question prompt (MCP needs_input).
3679+
// If so, respect it. Otherwise, resume to processing.
3680+
hasQuestion := false
3681+
if logs, err := database.GetTaskLogs(taskID, 3); err == nil {
3682+
for _, l := range logs {
3683+
if l.LineType == "question" {
3684+
hasQuestion = true
3685+
break
3686+
}
3687+
}
3688+
}
3689+
if !hasQuestion {
3690+
database.UpdateTaskStatus(taskID, db.StatusProcessing)
3691+
database.AppendTaskLog(taskID, "system", "Agent resumed working")
3692+
}
36723693
}
36733694

36743695
return nil

internal/ui/app.go

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,8 @@ type AppModel struct {
390390
prevStatuses map[int64]string
391391
// Track tasks with active input notifications (for UI highlighting)
392392
tasksNeedingInput map[int64]bool
393+
// Track which tasks have question prompts (vs permission prompts)
394+
questionPrompts map[int64]bool
393395
// Cached executor prompt messages for blocked tasks (from DB hook logs)
394396
executorPrompts map[int64]string
395397
// Track tasks the user closed manually (suppress notification for these)
@@ -610,6 +612,7 @@ func NewAppModel(database *db.DB, exec *executor.Executor, workingDir string, ve
610612
loading: true,
611613
prevStatuses: make(map[int64]string),
612614
tasksNeedingInput: make(map[int64]bool),
615+
questionPrompts: make(map[int64]bool),
613616
lastViewedAt: make(map[int64]time.Time),
614617
executorPrompts: make(map[int64]string),
615618
userClosedTaskIDs: make(map[int64]bool),
@@ -827,9 +830,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
827830
// Handles external approval (e.g. from tmux) where PreToolUse
828831
// logs "Agent resumed working" and transitions to processing.
829832
if m.tasksNeedingInput[t.ID] {
830-
if m.latestChoicePrompt(t.ID) == "" {
833+
if prompt, isQ := m.latestChoicePrompt(t.ID); prompt == "" {
831834
delete(m.tasksNeedingInput, t.ID)
835+
delete(m.questionPrompts, t.ID)
832836
delete(m.executorPrompts, t.ID)
837+
} else {
838+
m.questionPrompts[t.ID] = isQ
833839
}
834840
}
835841
}
@@ -851,20 +857,25 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
851857
for _, t := range m.tasks {
852858
if t.Status == db.StatusDone || t.Status == db.StatusBacklog {
853859
delete(m.tasksNeedingInput, t.ID)
860+
delete(m.questionPrompts, t.ID)
854861
delete(m.executorPrompts, t.ID)
855862
continue
856863
}
857864
if m.tasksNeedingInput[t.ID] {
858865
// Re-validate: if task is no longer blocked, the user provided input
859866
// (e.g., from the detail view tmux pane). Also re-check permission prompts.
860-
if t.Status != db.StatusBlocked && m.latestChoicePrompt(t.ID) == "" {
867+
if prompt, isQ := m.latestChoicePrompt(t.ID); t.Status != db.StatusBlocked && prompt == "" {
861868
delete(m.tasksNeedingInput, t.ID)
869+
delete(m.questionPrompts, t.ID)
862870
delete(m.executorPrompts, t.ID)
871+
} else {
872+
m.questionPrompts[t.ID] = isQ
863873
}
864874
continue
865875
}
866-
if prompt := m.latestChoicePrompt(t.ID); prompt != "" {
876+
if prompt, isQ := m.latestChoicePrompt(t.ID); prompt != "" {
867877
m.tasksNeedingInput[t.ID] = true
878+
m.questionPrompts[t.ID] = isQ
868879
// Capture the tmux pane content for richer display of the prompt.
869880
// This shows the actual executor output (including multiple choice options)
870881
// rather than just the hook log summary.
@@ -1179,6 +1190,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
11791190
// still blocked (e.g. another prompt queued), the latestChoicePrompt
11801191
// catch-up loop will re-detect it on the next poll cycle.
11811192
delete(m.tasksNeedingInput, msg.taskID)
1193+
delete(m.questionPrompts, msg.taskID)
11821194
delete(m.executorPrompts, msg.taskID)
11831195
}
11841196
cmds = append(cmds, m.loadTasks())
@@ -1219,12 +1231,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12191231
// newly-blocked tasks so the user can approve/deny immediately
12201232
// without waiting for the next loadTasks poll.
12211233
if m.tasksNeedingInput[event.TaskID] {
1222-
if m.latestChoicePrompt(event.TaskID) == "" {
1234+
if prompt, isQ := m.latestChoicePrompt(event.TaskID); prompt == "" {
12231235
delete(m.tasksNeedingInput, event.TaskID)
1236+
delete(m.questionPrompts, event.TaskID)
12241237
delete(m.executorPrompts, event.TaskID)
1238+
} else {
1239+
m.questionPrompts[event.TaskID] = isQ
12251240
}
1226-
} else if prompt := m.latestChoicePrompt(event.TaskID); prompt != "" {
1241+
} else if prompt, isQ := m.latestChoicePrompt(event.TaskID); prompt != "" {
12271242
m.tasksNeedingInput[event.TaskID] = true
1243+
m.questionPrompts[event.TaskID] = isQ
12281244
paneContent := executor.CapturePaneContent(event.TaskID, 15)
12291245
if paneContent != "" {
12301246
m.executorPrompts[event.TaskID] = paneContent
@@ -1739,7 +1755,12 @@ func (m *AppModel) renderExecutorPromptPreview(task *db.Task) string {
17391755
Width(m.width).
17401756
Padding(0, 1)
17411757

1742-
hints := hintStyle.Render("y approve N deny tab input enter detail")
1758+
var hints string
1759+
if m.questionPrompts[task.ID] {
1760+
hints = hintStyle.Render("tab reply enter detail")
1761+
} else {
1762+
hints = hintStyle.Render("y approve N deny tab input enter detail")
1763+
}
17431764

17441765
prompt := m.executorPrompts[task.ID]
17451766
promptLines := extractPromptLines(prompt, m.width-10)
@@ -3486,6 +3507,7 @@ func (m *AppModel) updateRetry(msg tea.Msg) (tea.Model, tea.Cmd) {
34863507
taskID := m.retryView.task.ID
34873508
// Clear kanban notification immediately for instant UI feedback
34883509
delete(m.tasksNeedingInput, taskID)
3510+
delete(m.questionPrompts, taskID)
34893511
delete(m.executorPrompts, taskID)
34903512
m.kanban.SetTasksNeedingInput(m.tasksNeedingInput)
34913513
// Clear the notification banner if it's for this task
@@ -4156,29 +4178,43 @@ func (m *AppModel) sendTextToExecutor(taskID int64, text string) tea.Cmd {
41564178
// latestChoicePrompt checks whether a task has a pending permission/choice prompt
41574179
// by reading recent DB logs written by the notification hook. Returns the prompt
41584180
// message if still pending, or "" if resolved (e.g. "Agent resumed working",
4159-
// user approved/denied). This is status-agnostic — works for any active task.
4160-
// Only matches "Waiting for permission" entries (actual choice prompts), NOT
4181+
// user approved/denied). The second return value isQuestion is true when the
4182+
// prompt is a question (from MCP taskyou_needs_input) rather than a permission
4183+
// prompt. Question prompts have different resolution semantics: they are NOT
4184+
// cleared by "Agent resumed working" or tool logs, only by explicit user reply.
4185+
// Only matches "Waiting for permission" and "question" entries, NOT
41614186
// "Waiting for user input" (generic idle/end_turn scenarios).
4162-
func (m *AppModel) latestChoicePrompt(taskID int64) string {
4187+
func (m *AppModel) latestChoicePrompt(taskID int64) (string, bool) {
41634188
logs, err := m.db.GetTaskLogs(taskID, 10)
41644189
if err != nil {
4165-
return ""
4190+
return "", false
41664191
}
41674192
// Logs are in DESC order (most recent first).
41684193
// A pending prompt is only valid if no subsequent log indicates resolution.
4194+
// For permission prompts: "Agent resumed working", tool logs, and user
4195+
// approve/deny all resolve the prompt.
4196+
// For question prompts: only an explicit user reply resolves it.
4197+
permissionResolved := false
41694198
for _, l := range logs {
41704199
switch {
4200+
case l.LineType == "question":
4201+
// Question prompts are only resolved by explicit user reply,
4202+
// not by "Agent resumed working" or tool logs.
4203+
return l.Content, true
41714204
case l.LineType == "system" && strings.HasPrefix(l.Content, "Waiting for permission"):
4172-
return l.Content
4205+
if permissionResolved {
4206+
return "", false
4207+
}
4208+
return l.Content, false
41734209
case l.LineType == "system" && (l.Content == "Agent resumed working" || l.Content == "Claude resumed working"):
4174-
return "" // prompt was resolved
4210+
permissionResolved = true
41754211
case l.LineType == "user" && (strings.HasPrefix(l.Content, "Approved") || strings.HasPrefix(l.Content, "Denied") || strings.HasPrefix(l.Content, "Replied")):
4176-
return "" // user already responded
4212+
return "", false // user already responded (resolves both types)
41774213
case l.LineType == "tool":
4178-
return "" // a tool ran after the permission prompt, so it was resolved
4214+
permissionResolved = true
41794215
}
41804216
}
4181-
return ""
4217+
return "", false
41824218
}
41834219

41844220
// detectPermissionPrompt does a live DB check for a pending permission prompt
@@ -4187,11 +4223,12 @@ func (m *AppModel) latestChoicePrompt(taskID int64) string {
41874223
// poll-based detection hasn't caught up yet (e.g. a real-time event showed the
41884224
// task as blocked before loadTasks detected the permission prompt).
41894225
func (m *AppModel) detectPermissionPrompt(taskID int64) bool {
4190-
prompt := m.latestChoicePrompt(taskID)
4226+
prompt, isQ := m.latestChoicePrompt(taskID)
41914227
if prompt == "" {
41924228
return false
41934229
}
41944230
m.tasksNeedingInput[taskID] = true
4231+
m.questionPrompts[taskID] = isQ
41954232
paneContent := executor.CapturePaneContent(taskID, 15)
41964233
if paneContent != "" {
41974234
m.executorPrompts[taskID] = paneContent

0 commit comments

Comments
 (0)