From 112341ca3d2c40fa20d7c1dbfa5ba56f40ff15f6 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Tue, 21 Apr 2026 18:32:22 +0200 Subject: [PATCH] fix(tui): slash popup + transient overlays swallow esc before busy-cancel Two rules for how esc resolves while a turn is running got implemented this round: 1) Let the user open the slash popup during a busy turn. The suggest render path used to short-circuit on i.busy, so typing / while the agent was working did nothing. The dispatcher in runSlash already handles the busy-state routing per command (safe ones run immediately, destructive ones cancel first), so dropping the guard was safe. Now / opens the popup whether or not a turn is in flight. 2) Esc dismisses overlays before it cancels the turn. The global key switch used to fire the busy-cancel unconditionally on esc. That meant three common patterns silently ripped the active turn away: - Open the slash popup, press esc to dismiss it -> turn cancelled. - Run /help to see the key bindings while a turn was running, press esc when done -> turn cancelled. - An extension pushed a notify/display line, user pressed esc to clear it -> turn cancelled. The esc case now checks, in order: - slash popup active -> break out of the switch, let the popup's own esc handler (later in handleKey) close it - helpBlock or extNotes non-empty -> clear them, invalidate, return (turn keeps running) - busy + cancelable -> cancel the turn (old behaviour) - idle -> fall through to the editor which clears itself Result: esc feels like a dismiss key that escalates. It nukes the turn only when nothing else on screen wants it. No change to dialog handlers \u2014 those already intercept esc in their own return-false branches before the global switch ever runs. --- internal/agent/modes/interactive.go | 34 ++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index ab016a6..2d696a2 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -715,7 +715,13 @@ func (i *Interactive) redraw() { } var suggest []string currentInput := i.ed.Value() - if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy { + // Slash popup renders even while the agent is busy so the user + // can queue a destructive command (/clear, /compact, /logout, + // /model) or a read-only one (/help, /jump, /sessions, etc.) + // without waiting for the current turn to finish. The dispatcher + // in runSlash already handles the busy case per-command: safe + // ones run immediately, destructive ones cancel the turn first. + if len(dialog) == 0 && i.suggest.Active(currentInput) { suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) } @@ -1105,8 +1111,30 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { i.armCtrlCExit() return false case tui.KeyEsc: - // Esc interrupts a running turn. When idle, fall through so the - // editor can clear itself. + // Esc interrupts a running turn — but only when nothing + // else on screen wants to consume the key first. The slash + // popup has its own Esc behaviour (close + clear editor), + // and transient overlays like the /help block and extension + // notes should dismiss on Esc before we even consider the + // turn. Without these guards, a casual Esc press after + // running /help on a busy turn rips the turn away. + if i.suggest.Active(i.ed.Value()) { + break + } + i.mu.Lock() + hadHelp := len(i.helpBlock) > 0 + hadNotes := len(i.extNotes) > 0 + if hadHelp { + i.helpBlock = nil + } + if hadNotes { + i.extNotes = nil + } + i.mu.Unlock() + if hadHelp || hadNotes { + i.invalidate() + return false + } if i.busy && i.cancelTurn != nil { i.cancelTurn() // If a confirm dialog is pending, refuse it so the agent