Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions internal/app/service/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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})
Expand Down
57 changes: 57 additions & 0 deletions internal/app/service/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions internal/app/service/protocol_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func (s *Service) DispatchProtocol(in protocol.Intent) {
WorkflowSaveAs: in.WorkflowSaveAs,
WorkflowScriptPath: in.WorkflowScriptPath,
AutoReview: in.AutoReview,
Quiet: in.Quiet,
})
}

Expand Down
1 change: 1 addition & 0 deletions internal/app/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type Intent struct {
Thinking string
ApprovalMode string
AutoReview bool
Quiet bool
ViewMode string
SkillName string
SkillEnabled bool
Expand Down
5 changes: 5 additions & 0 deletions internal/runtime/protocol/intents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 13 additions & 10 deletions internal/tui/model_keys_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
47 changes: 40 additions & 7 deletions internal/tui/model_permission_cycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading