From 7398c739006386c8e51283852967acb83294161c Mon Sep 17 00:00:00 2001 From: shayne-snap Date: Fri, 7 Aug 2026 07:14:42 +0800 Subject: [PATCH 1/2] fix(tui): allow Alt+P permission cycle while busy Option+P was silently ignored while a turn was running, so the permission cycle only worked when idle. The old busy gate existed because the permission intents emit EventTurnDone, which the TUI treats as turn completion and would commit the live transcript and clear busy while the backend kept streaming. Add a Quiet flag to permission intents: when set, the service applies the state change and emits EventInfo (AutoAcceptKnown) but skips EventTurnDone, mirroring the approval modal's quiet IntentEnableAutoAccept path. Alt+P now cycles ask -> auto-review -> auto-accept mid-turn with no TUI/backend desync; idle behavior is unchanged. --- internal/app/service/dispatch.go | 8 ++- internal/app/service/events_test.go | 57 +++++++++++++++++++++ internal/app/service/protocol_dispatch.go | 1 + internal/app/service/service.go | 1 + internal/runtime/protocol/intents.go | 5 ++ internal/tui/model_keys_chat.go | 23 +++++---- internal/tui/model_permission_cycle_test.go | 47 ++++++++++++++--- 7 files changed, 123 insertions(+), 19 deletions(-) diff --git a/internal/app/service/dispatch.go b/internal/app/service/dispatch.go index e1842fb9..17a01dc5 100644 --- a/internal/app/service/dispatch.go +++ b/internal/app/service/dispatch.go @@ -85,7 +85,9 @@ func (s *Service) Dispatch(in Intent) { msg = "Ask for approval" } s.emit(Event{Kind: EventInfo, Text: msg, AutoAccept: enabled, AutoAcceptKnown: true}) - s.emit(Event{Kind: EventTurnDone, LastResponse: msg}) + if !in.Quiet { + s.emit(Event{Kind: EventTurnDone, LastResponse: msg}) + } case IntentSetAutoReview: s.app.SetAutoReviewEnabled(in.AutoReview) msg := "Auto-review enabled" @@ -94,7 +96,9 @@ func (s *Service) Dispatch(in Intent) { } enabled := s.app.AutoAcceptPermissions() s.emit(Event{Kind: EventInfo, Text: msg, AutoAccept: enabled, AutoAcceptKnown: true, AutoReview: in.AutoReview}) - s.emit(Event{Kind: EventTurnDone, LastResponse: msg}) + if !in.Quiet { + s.emit(Event{Kind: EventTurnDone, LastResponse: msg}) + } case IntentEnableAutoAccept: s.app.SetAutoAcceptPermissions(true) s.emit(Event{Kind: EventInfo, Text: autoAcceptMessage(true), AutoAccept: true, AutoAcceptKnown: true}) diff --git a/internal/app/service/events_test.go b/internal/app/service/events_test.go index 0e68b9d6..aaf664f4 100644 --- a/internal/app/service/events_test.go +++ b/internal/app/service/events_test.go @@ -1799,6 +1799,63 @@ func TestEnableAutoAcceptIntentDoesNotEndActiveTurn(t *testing.T) { } } +func TestQuietPermissionIntentsDoNotEndActiveTurn(t *testing.T) { + work := t.TempDir() + t.Chdir(work) + cfg := app.DefaultConfig() + cfg.DataDir = t.TempDir() + svc, err := New(t.Context(), cfg, app.StartOptions{NewSession: true}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer svc.Close() + waitForServiceEvent(t, svc, EventSessionHydrated) + + // Quiet auto-accept: EventInfo only, no EventTurnDone. + svc.Dispatch(Intent{Kind: IntentSetApprovalMode, ApprovalMode: "auto_accept", Quiet: true}) + info := waitForServiceEvent(t, svc, EventInfo) + if info.Text != "Auto-accept edits enabled" || !info.AutoAccept || !info.AutoAcceptKnown { + t.Fatalf("unexpected quiet approval mode info: %+v", info) + } + select { + case ev := <-svc.Events(): + t.Fatalf("quiet approval mode intent should not emit another event, got %+v", ev) + case <-time.After(100 * time.Millisecond): + } + + // Quiet auto-review: EventInfo only, no EventTurnDone. + svc.Dispatch(Intent{Kind: IntentSetAutoReview, AutoReview: true, Quiet: true}) + info = waitForServiceEvent(t, svc, EventInfo) + if info.Text != "Auto-review enabled" || !info.AutoReview || !info.AutoAcceptKnown { + t.Fatalf("unexpected quiet auto review info: %+v", info) + } + select { + case ev := <-svc.Events(): + t.Fatalf("quiet auto review intent should not emit another event, got %+v", ev) + case <-time.After(100 * time.Millisecond): + } + + // Quiet ask: EventInfo only, no EventTurnDone. + svc.Dispatch(Intent{Kind: IntentSetApprovalMode, ApprovalMode: "ask", Quiet: true}) + info = waitForServiceEvent(t, svc, EventInfo) + if info.Text != "Ask for approval" || info.AutoAccept || !info.AutoAcceptKnown { + t.Fatalf("unexpected quiet ask info: %+v", info) + } + select { + case ev := <-svc.Events(): + t.Fatalf("quiet ask intent should not emit another event, got %+v", ev) + case <-time.After(100 * time.Millisecond): + } + + // Non-quiet intents still end the turn, so the idle Alt+P path is unchanged. + svc.Dispatch(Intent{Kind: IntentSetApprovalMode, ApprovalMode: "auto_accept"}) + info = waitForServiceEvent(t, svc, EventInfo) + if info.Text != "Auto-accept edits enabled" { + t.Fatalf("unexpected non-quiet approval mode info: %q", info.Text) + } + waitForServiceEvent(t, svc, EventTurnDone) +} + func TestReviewCommandOpensMenu(t *testing.T) { t.Setenv("DEEPSEEK_API_KEY", "sk-test") cfg := app.DefaultConfig() diff --git a/internal/app/service/protocol_dispatch.go b/internal/app/service/protocol_dispatch.go index 3084f864..25cd322b 100644 --- a/internal/app/service/protocol_dispatch.go +++ b/internal/app/service/protocol_dispatch.go @@ -41,6 +41,7 @@ func (s *Service) DispatchProtocol(in protocol.Intent) { WorkflowSaveAs: in.WorkflowSaveAs, WorkflowScriptPath: in.WorkflowScriptPath, AutoReview: in.AutoReview, + Quiet: in.Quiet, }) } diff --git a/internal/app/service/service.go b/internal/app/service/service.go index 2e113f41..f8698ea8 100644 --- a/internal/app/service/service.go +++ b/internal/app/service/service.go @@ -68,6 +68,7 @@ type Intent struct { Thinking string ApprovalMode string AutoReview bool + Quiet bool ViewMode string SkillName string SkillEnabled bool diff --git a/internal/runtime/protocol/intents.go b/internal/runtime/protocol/intents.go index 4e3894e2..ef294448 100644 --- a/internal/runtime/protocol/intents.go +++ b/internal/runtime/protocol/intents.go @@ -73,6 +73,11 @@ type Intent struct { WorkflowScript string `json:"workflow_script,omitempty"` WorkflowSaveAs string `json:"workflow_save_as,omitempty"` WorkflowScriptPath string `json:"workflow_script_path,omitempty"` + // Quiet suppresses the EventTurnDone the service emits after handling an + // intent. The TUI sets it for intents dispatched mid-turn (e.g. the + // Alt+P permission cycle while the agent is busy) so the running turn is + // not ended by a spurious turn-done event. + Quiet bool `json:"quiet,omitempty"` } type ConfigSettingUpdate struct { diff --git a/internal/tui/model_keys_chat.go b/internal/tui/model_keys_chat.go index 13512f3e..fa53da21 100644 --- a/internal/tui/model_keys_chat.go +++ b/internal/tui/model_keys_chat.go @@ -31,12 +31,11 @@ func (m *model) handleChatModeKey(msg tea.KeyMsg) (tea.Cmd, bool) { m.refreshViewportContent() return m.flushNativeScrollbackCmd(), true } - // Blocked while busy: the permission intents emit EventTurnDone, which - // the TUI treats as turn completion — dispatching mid-turn would commit - // the live transcript and clear busy while the backend keeps streaming. - // Mid-turn auto-accept is still available from the approval modal - // (approve + enable auto-accept), which uses a quiet intent path. - if !m.busy && !m.hasSlashSuggestions() && !m.hasFilePanel() && !m.hasSkillSuggestions() { + // Allowed while busy: the permission intents emit EventTurnDone, which + // the TUI treats as turn completion, so cyclePermissions dispatches the + // quiet variants mid-turn (no EventTurnDone) — same path as the approval + // modal's approve + enable auto-accept. + if !m.hasSlashSuggestions() && !m.hasFilePanel() && !m.hasSkillSuggestions() { m.cyclePermissions() return nil, true } @@ -129,19 +128,23 @@ func (m *model) handleChatModeKey(msg tea.KeyMsg) (tea.Cmd, bool) { // cyclePermissions advances the permission mode with the Alt+P shortcut: // ask -> auto-review -> auto-accept -> ask. Local state is updated // optimistically so the footer indicator responds instantly; the service -// confirms the same state via EventInfo (AutoAcceptKnown). +// confirms the same state via EventInfo (AutoAcceptKnown). While a turn is +// busy the intents are dispatched with Quiet set so the service does not emit +// EventTurnDone, which the TUI would otherwise treat as turn completion and +// commit the live transcript while the backend keeps streaming. func (m *model) cyclePermissions() { + quiet := m.busy switch nextPermissionsMode(m.autoAccept, m.autoReviewEnabled) { case "auto-review": - m.dispatchIntent(protocol.Intent{Kind: protocol.IntentSetAutoReview, AutoReview: true}) + m.dispatchIntent(protocol.Intent{Kind: protocol.IntentSetAutoReview, AutoReview: true, Quiet: quiet}) m.autoReviewEnabled = true m.autoAccept = false case "auto-accept": - m.dispatchIntent(protocol.Intent{Kind: protocol.IntentSetApprovalMode, ApprovalMode: "auto_accept"}) + m.dispatchIntent(protocol.Intent{Kind: protocol.IntentSetApprovalMode, ApprovalMode: "auto_accept", Quiet: quiet}) m.autoAccept = true m.autoReviewEnabled = false default: // ask - m.dispatchIntent(protocol.Intent{Kind: protocol.IntentSetApprovalMode, ApprovalMode: "ask"}) + m.dispatchIntent(protocol.Intent{Kind: protocol.IntentSetApprovalMode, ApprovalMode: "ask", Quiet: quiet}) m.autoAccept = false m.autoReviewEnabled = false } diff --git a/internal/tui/model_permission_cycle_test.go b/internal/tui/model_permission_cycle_test.go index 9db91c34..72100025 100644 --- a/internal/tui/model_permission_cycle_test.go +++ b/internal/tui/model_permission_cycle_test.go @@ -19,8 +19,8 @@ func TestAltPCyclesPermissionModes(t *testing.T) { // ask -> auto-review m, _ = updateTestModel(t, m, altPKey()) - if len(*intents) != 1 || (*intents)[0].Kind != protocol.IntentSetAutoReview || (*intents)[0].AutoReview != true { - t.Fatalf("expected auto_review intent from ask, got %+v", *intents) + if len(*intents) != 1 || (*intents)[0].Kind != protocol.IntentSetAutoReview || (*intents)[0].AutoReview != true || (*intents)[0].Quiet { + t.Fatalf("expected non-quiet auto_review intent from ask, got %+v", *intents) } if !m.autoReviewEnabled || m.autoAccept { t.Fatalf("expected optimistic auto-review state, got autoReview=%v autoAccept=%v", m.autoReviewEnabled, m.autoAccept) @@ -54,23 +54,56 @@ func TestAltPCyclesPermissionModes(t *testing.T) { } } -func TestAltPBlockedWhileBusy(t *testing.T) { +func TestAltPCyclesWhileBusy(t *testing.T) { m, intents := newModelWithDispatchSpy() m.busy = true m.busySince = time.Now().Add(-5 * time.Minute) m.status = "running" + // ask -> auto-review (quiet: no EventTurnDone mid-turn) m, _ = updateTestModel(t, m, altPKey()) - - if len(*intents) != 0 { - t.Fatalf("busy turn should block permission cycle, got %+v", *intents) + if len(*intents) != 1 { + t.Fatalf("expected one auto_review intent, got %+v", *intents) + } + got := (*intents)[0] + if got.Kind != protocol.IntentSetAutoReview || !got.AutoReview || !got.Quiet { + t.Fatalf("expected quiet auto_review intent, got %+v", got) + } + if !m.autoReviewEnabled || m.autoAccept { + t.Fatalf("expected optimistic auto-review state, got autoReview=%v autoAccept=%v", m.autoReviewEnabled, m.autoAccept) } if !m.busy { - t.Fatal("busy turn should remain busy after blocked Alt+P") + t.Fatal("busy turn should remain busy after mid-turn Alt+P") } if m.status != "running" { t.Fatalf("busy turn status should be preserved, got %q", m.status) } + + // auto-review -> auto-accept (quiet) + m, _ = updateTestModel(t, m, altPKey()) + if len(*intents) != 2 { + t.Fatalf("expected two intents after second Alt+P, got %+v", *intents) + } + got = (*intents)[1] + if got.Kind != protocol.IntentSetApprovalMode || got.ApprovalMode != "auto_accept" || !got.Quiet { + t.Fatalf("expected quiet auto_accept intent, got %+v", got) + } + if !m.busy || !m.autoAccept || m.autoReviewEnabled { + t.Fatalf("unexpected state after second Alt+P: busy=%v autoReview=%v autoAccept=%v", m.busy, m.autoReviewEnabled, m.autoAccept) + } + + // auto-accept -> ask (quiet) + m, _ = updateTestModel(t, m, altPKey()) + if len(*intents) != 3 { + t.Fatalf("expected three intents after third Alt+P, got %+v", *intents) + } + got = (*intents)[2] + if got.Kind != protocol.IntentSetApprovalMode || got.ApprovalMode != "ask" || !got.Quiet { + t.Fatalf("expected quiet ask intent, got %+v", got) + } + if !m.busy || m.autoAccept || m.autoReviewEnabled { + t.Fatalf("unexpected state after third Alt+P: busy=%v autoReview=%v autoAccept=%v", m.busy, m.autoReviewEnabled, m.autoAccept) + } } func TestAltPBlockedWhileSlashSuggestionsOpen(t *testing.T) { From 0d091b44d066f10c9603288b13630411df61c754 Mon Sep 17 00:00:00 2001 From: shayne-snap Date: Fri, 7 Aug 2026 07:21:04 +0800 Subject: [PATCH 2/2] chore: trigger CI