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) {