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.- commit
- 112341ca3d2c40fa20d7c1dbfa5ba56f40ff15f6
- author
- patriceckhart · · 2026-04-21 16:32 UTC
- parent
- 6019404
- signature
- unsigned
- refs
- tag: v0.0.83
1 file · +31 -3
M internal/agent/modes/interactive.go +31 -3
| @@ -715,7 +715,13 @@ func (i *Interactive) redraw() { | |||
| 715 | 715 | } | |
| 716 | 716 | var suggest []string | |
| 717 | 717 | currentInput := i.ed.Value() | |
| 718 | - | if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy { | |
| 718 | + | // Slash popup renders even while the agent is busy so the user | |
| 719 | + | // can queue a destructive command (/clear, /compact, /logout, | |
| 720 | + | // /model) or a read-only one (/help, /jump, /sessions, etc.) | |
| 721 | + | // without waiting for the current turn to finish. The dispatcher | |
| 722 | + | // in runSlash already handles the busy case per-command: safe | |
| 723 | + | // ones run immediately, destructive ones cancel the turn first. | |
| 724 | + | if len(dialog) == 0 && i.suggest.Active(currentInput) { | |
| 719 | 725 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | |
| 720 | 726 | } | |
| 721 | 727 | ||
| @@ -1105,8 +1111,30 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1105 | 1111 | i.armCtrlCExit() | |
| 1106 | 1112 | return false | |
| 1107 | 1113 | case tui.KeyEsc: | |
| 1108 | - | // Esc interrupts a running turn. When idle, fall through so the | |
| 1109 | - | // editor can clear itself. | |
| 1114 | + | // Esc interrupts a running turn — but only when nothing | |
| 1115 | + | // else on screen wants to consume the key first. The slash | |
| 1116 | + | // popup has its own Esc behaviour (close + clear editor), | |
| 1117 | + | // and transient overlays like the /help block and extension | |
| 1118 | + | // notes should dismiss on Esc before we even consider the | |
| 1119 | + | // turn. Without these guards, a casual Esc press after | |
| 1120 | + | // running /help on a busy turn rips the turn away. | |
| 1121 | + | if i.suggest.Active(i.ed.Value()) { | |
| 1122 | + | break | |
| 1123 | + | } | |
| 1124 | + | i.mu.Lock() | |
| 1125 | + | hadHelp := len(i.helpBlock) > 0 | |
| 1126 | + | hadNotes := len(i.extNotes) > 0 | |
| 1127 | + | if hadHelp { | |
| 1128 | + | i.helpBlock = nil | |
| 1129 | + | } | |
| 1130 | + | if hadNotes { | |
| 1131 | + | i.extNotes = nil | |
| 1132 | + | } | |
| 1133 | + | i.mu.Unlock() | |
| 1134 | + | if hadHelp || hadNotes { | |
| 1135 | + | i.invalidate() | |
| 1136 | + | return false | |
| 1137 | + | } | |
| 1110 | 1138 | if i.busy && i.cancelTurn != nil { | |
| 1111 | 1139 | i.cancelTurn() | |
| 1112 | 1140 | // If a confirm dialog is pending, refuse it so the agent | |
| @@ -715,7 +715,13 @@ func (i *Interactive) redraw() { | |||||
| 715 | } | 715 | } | ||
| 716 | var suggest []string | 716 | var suggest []string | ||
| 717 | currentInput := i.ed.Value() | 717 | currentInput := i.ed.Value() | ||
| 718 | - | if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy { | 718 | + | // Slash popup renders even while the agent is busy so the user |
| 719 | + | // can queue a destructive command (/clear, /compact, /logout, | |||
| 720 | + | // /model) or a read-only one (/help, /jump, /sessions, etc.) | |||
| 721 | + | // without waiting for the current turn to finish. The dispatcher | |||
| 722 | + | // in runSlash already handles the busy case per-command: safe | |||
| 723 | + | // ones run immediately, destructive ones cancel the turn first. | |||
| 724 | + | if len(dialog) == 0 && i.suggest.Active(currentInput) { | |||
| 719 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | 725 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | ||
| 720 | } | 726 | } | ||
| 721 | 727 | ||||
| @@ -1105,8 +1111,30 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1105 | i.armCtrlCExit() | 1111 | i.armCtrlCExit() | ||
| 1106 | return false | 1112 | return false | ||
| 1107 | case tui.KeyEsc: | 1113 | case tui.KeyEsc: | ||
| 1108 | - | // Esc interrupts a running turn. When idle, fall through so the | 1114 | + | // Esc interrupts a running turn — but only when nothing |
| 1109 | - | // editor can clear itself. | 1115 | + | // else on screen wants to consume the key first. The slash |
| 1116 | + | // popup has its own Esc behaviour (close + clear editor), | |||
| 1117 | + | // and transient overlays like the /help block and extension | |||
| 1118 | + | // notes should dismiss on Esc before we even consider the | |||
| 1119 | + | // turn. Without these guards, a casual Esc press after | |||
| 1120 | + | // running /help on a busy turn rips the turn away. | |||
| 1121 | + | if i.suggest.Active(i.ed.Value()) { | |||
| 1122 | + | break | |||
| 1123 | + | } | |||
| 1124 | + | i.mu.Lock() | |||
| 1125 | + | hadHelp := len(i.helpBlock) > 0 | |||
| 1126 | + | hadNotes := len(i.extNotes) > 0 | |||
| 1127 | + | if hadHelp { | |||
| 1128 | + | i.helpBlock = nil | |||
| 1129 | + | } | |||
| 1130 | + | if hadNotes { | |||
| 1131 | + | i.extNotes = nil | |||
| 1132 | + | } | |||
| 1133 | + | i.mu.Unlock() | |||
| 1134 | + | if hadHelp || hadNotes { | |||
| 1135 | + | i.invalidate() | |||
| 1136 | + | return false | |||
| 1137 | + | } | |||
| 1110 | if i.busy && i.cancelTurn != nil { | 1138 | if i.busy && i.cancelTurn != nil { | ||
| 1111 | i.cancelTurn() | 1139 | i.cancelTurn() | ||
| 1112 | // If a confirm dialog is pending, refuse it so the agent | 1140 | // If a confirm dialog is pending, refuse it so the agent | ||