detached 112341c

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() {
715715 }
716716 var suggest []string
717717 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) {
719725 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
720726 }
721727
@@ -1105,8 +1111,30 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
11051111 i.armCtrlCExit()
11061112 return false
11071113 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+ }
11101138 if i.busy && i.cancelTurn != nil {
11111139 i.cancelTurn()
11121140 // If a confirm dialog is pending, refuse it so the agent
@@ -715,7 +715,13 @@ func (i *Interactive) redraw() {
715 }715 }
716 var suggest []string716 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 false1112 return false
1107 case tui.KeyEsc:1113 case tui.KeyEsc:
1108- // Esc interrupts a running turn. When idle, fall through so the1114+ // 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 agent1140 // If a confirm dialog is pending, refuse it so the agent

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close