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.
This commit is contained in:
patriceckhart 2026-04-21 18:32:22 +02:00
parent 6019404644
commit 112341ca3d

View file

@ -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