detached d653cc1

tui: slash commands work while a turn is in flight

previously typing any slash command during a turn was rejected with
"cancel the current turn (esc) before running a slash command".
annoying and unnecessary for read-only commands, and the
destructive ones can cancel the turn for you.

read-only commands run immediately, parallel to the streaming
turn: /help, /jump, /sessions, /lock, /unlock, /exit.

destructive commands trigger cancelAndWaitForIdle first (cancels
the turn ctx, polls i.busy at 10ms intervals, gives up after 2s
as a safety cap so a wedged http stream cannot freeze the ui
forever): /clear, /compact, /login, /logout, /model. once the
turn goroutine has wound down they run on the now-quiet agent.

slashCancelsTurn(head) in slash_suggest.go is the single source
of truth for which commands need the wait. readme updated to
match the new behaviour.
commit
d653cc179db415c52074ed1c61c61afa618fb38c
author
patriceckhart · · 2026-04-19 09:21 UTC
parent
6470487
signature
unsigned
refs
tag: v0.0.8
3 files · +59 -12
M README.md +1 -1
@@ -223,7 +223,7 @@ frames containing images are full-repainted (no differential diff) to prevent st
223223
224224 you can keep typing while the agent is working. pressing enter during a turn queues the message instead of interrupting: it shows up above the status bar as `▸ sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. queue as many as you want; they run in order. esc / ctrl+c cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.
225225
226-slash commands still require an idle state — typing `/something` during a turn prints `cancel the current turn (esc) before running a slash command`.
226+slash commands also work while the agent is busy. read-only ones (`/help`, `/jump`, `/sessions`, `/lock`, `/unlock`, `/exit`) take effect immediately. destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`) cancel the active turn first and then run.
227227
228228 ## keys (interactive mode)
229229
@@ -223,7 +223,7 @@ frames containing images are full-repainted (no differential diff) to prevent st
223 223
224 you can keep typing while the agent is working. pressing enter during a turn queues the message instead of interrupting: it shows up above the status bar as `▸ sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. queue as many as you want; they run in order. esc / ctrl+c cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.224 you can keep typing while the agent is working. pressing enter during a turn queues the message instead of interrupting: it shows up above the status bar as `▸ sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. queue as many as you want; they run in order. esc / ctrl+c cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.
225 225
226-slash commands still require an idle state — typing `/something` during a turn prints `cancel the current turn (esc) before running a slash command`.226+slash commands also work while the agent is busy. read-only ones (`/help`, `/jump`, `/sessions`, `/lock`, `/unlock`, `/exit`) take effect immediately. destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`) cancel the active turn first and then run.
227 227
228 ## keys (interactive mode)228 ## keys (interactive mode)
229 229
M internal/agent/modes/interactive.go +45 -11
@@ -739,17 +739,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
739739 i.mu.Unlock()
740740 return false
741741 }
742- // Slash commands need a quiet state (they may swap models,
743- // compact the transcript, open dialogs, etc). Refuse while
744- // a turn is in flight — esc / ctrl+c cancels first.
745- i.mu.Lock()
746- busy := i.busy
747- i.mu.Unlock()
748- if busy {
749- i.mu.Lock()
750- i.statusErr = "cancel the current turn (esc) before running a slash command"
751- i.mu.Unlock()
752- return false
742+ // Slash commands run regardless of busy state. Commands that
743+ // would mutate the transcript or replace the agent (/clear,
744+ // /compact, /logout, /login, /model) cancel the active turn
745+ // first and wait for the goroutine to wind down so they don't
746+ // race with a streaming response. Safe commands (/help,
747+ // /jump, /sessions, /lock, /unlock, /exit) run immediately
748+ // without disturbing the active turn.
749+ head := text
750+ if idx := strings.IndexAny(text, " \t"); idx >= 0 {
751+ head = text[:idx]
752+ }
753+ if slashCancelsTurn(head) {
754+ i.cancelAndWaitForIdle()
753755 }
754756 return i.runSlash(ctx, text)
755757 }
@@ -943,6 +945,38 @@ func (i *Interactive) startOAuthFlow(provider string) {
943945 // applyModelSelection switches the active model (and provider, if the
944946 // new model belongs to a different one). It rebuilds the underlying
945947 // client when needed so the provider wire-protocol matches.
948+// cancelAndWaitForIdle cancels the active turn (if any) and blocks
949+// briefly until the turn goroutine has updated i.busy = false. Used
950+// before destructive slash commands so transcript-mutating work
951+// (/clear, /compact, /logout, /login completion, cross-provider
952+// /model swap) doesn't race with the still-running stream.
953+//
954+// The wait is bounded; if the turn doesn't release within the timeout
955+// we proceed anyway. Worst case is a brief overlap that the agent's
956+// own mutex protects against.
957+func (i *Interactive) cancelAndWaitForIdle() {
958+ i.mu.Lock()
959+ busy := i.busy
960+ cancel := i.cancelTurn
961+ i.mu.Unlock()
962+ if !busy {
963+ return
964+ }
965+ if cancel != nil {
966+ cancel()
967+ }
968+ deadline := time.Now().Add(2 * time.Second)
969+ for time.Now().Before(deadline) {
970+ i.mu.Lock()
971+ done := !i.busy
972+ i.mu.Unlock()
973+ if done {
974+ return
975+ }
976+ time.Sleep(10 * time.Millisecond)
977+ }
978+}
979+
946980 // openJumpDialog builds a /jump picker from the current transcript.
947981 // If the user typed "/jump foo" with a filter and it matches exactly
948982 // one turn, jump there directly without showing the dialog.
@@ -739,17 +739,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
739 i.mu.Unlock()739 i.mu.Unlock()
740 return false740 return false
741 }741 }
742- // Slash commands need a quiet state (they may swap models,742+ // Slash commands run regardless of busy state. Commands that
743- // compact the transcript, open dialogs, etc). Refuse while743+ // would mutate the transcript or replace the agent (/clear,
744- // a turn is in flight — esc / ctrl+c cancels first.744+ // /compact, /logout, /login, /model) cancel the active turn
745- i.mu.Lock()745+ // first and wait for the goroutine to wind down so they don't
746- busy := i.busy746+ // race with a streaming response. Safe commands (/help,
747- i.mu.Unlock()747+ // /jump, /sessions, /lock, /unlock, /exit) run immediately
748- if busy {748+ // without disturbing the active turn.
749- i.mu.Lock()749+ head := text
750- i.statusErr = "cancel the current turn (esc) before running a slash command"750+ if idx := strings.IndexAny(text, " \t"); idx >= 0 {
751- i.mu.Unlock()751+ head = text[:idx]
752- return false752+ }
753+ if slashCancelsTurn(head) {
754+ i.cancelAndWaitForIdle()
753 }755 }
754 return i.runSlash(ctx, text)756 return i.runSlash(ctx, text)
755 }757 }
@@ -943,6 +945,38 @@ func (i *Interactive) startOAuthFlow(provider string) {
943 // applyModelSelection switches the active model (and provider, if the945 // applyModelSelection switches the active model (and provider, if the
944 // new model belongs to a different one). It rebuilds the underlying946 // new model belongs to a different one). It rebuilds the underlying
945 // client when needed so the provider wire-protocol matches.947 // client when needed so the provider wire-protocol matches.
948+// cancelAndWaitForIdle cancels the active turn (if any) and blocks
949+// briefly until the turn goroutine has updated i.busy = false. Used
950+// before destructive slash commands so transcript-mutating work
951+// (/clear, /compact, /logout, /login completion, cross-provider
952+// /model swap) doesn't race with the still-running stream.
953+//
954+// The wait is bounded; if the turn doesn't release within the timeout
955+// we proceed anyway. Worst case is a brief overlap that the agent's
956+// own mutex protects against.
957+func (i *Interactive) cancelAndWaitForIdle() {
958+ i.mu.Lock()
959+ busy := i.busy
960+ cancel := i.cancelTurn
961+ i.mu.Unlock()
962+ if !busy {
963+ return
964+ }
965+ if cancel != nil {
966+ cancel()
967+ }
968+ deadline := time.Now().Add(2 * time.Second)
969+ for time.Now().Before(deadline) {
970+ i.mu.Lock()
971+ done := !i.busy
972+ i.mu.Unlock()
973+ if done {
974+ return
975+ }
976+ time.Sleep(10 * time.Millisecond)
977+ }
978+}
979+
946 // openJumpDialog builds a /jump picker from the current transcript.980 // openJumpDialog builds a /jump picker from the current transcript.
947 // If the user typed "/jump foo" with a filter and it matches exactly981 // If the user typed "/jump foo" with a filter and it matches exactly
948 // one turn, jump there directly without showing the dialog.982 // one turn, jump there directly without showing the dialog.
M internal/agent/modes/slash_suggest.go +13 -0
@@ -12,6 +12,19 @@ type slashCommand struct {
1212 Desc string
1313 }
1414
15+// slashCancelsTurn reports whether the named slash command, when run
16+// while a turn is in flight, requires the active turn to be cancelled
17+// first. The destructive commands (those that mutate the transcript
18+// or rebuild the agent) need a quiet state; the rest run alongside
19+// the streaming response without trouble.
20+func slashCancelsTurn(head string) bool {
21+ switch head {
22+ case "/clear", "/compact", "/logout", "/login", "/model":
23+ return true
24+ }
25+ return false
26+}
27+
1528 // slashCatalog lists every slash command the interactive mode handles.
1629 // Keep in sync with runSlash().
1730 var slashCatalog = []slashCommand{
@@ -12,6 +12,19 @@ type slashCommand struct {
12 Desc string12 Desc string
13 }13 }
14 14
15+// slashCancelsTurn reports whether the named slash command, when run
16+// while a turn is in flight, requires the active turn to be cancelled
17+// first. The destructive commands (those that mutate the transcript
18+// or rebuild the agent) need a quiet state; the rest run alongside
19+// the streaming response without trouble.
20+func slashCancelsTurn(head string) bool {
21+ switch head {
22+ case "/clear", "/compact", "/logout", "/login", "/model":
23+ return true
24+ }
25+ return false
26+}
27+
15 // slashCatalog lists every slash command the interactive mode handles.28 // slashCatalog lists every slash command the interactive mode handles.
16 // Keep in sync with runSlash().29 // Keep in sync with runSlash().
17 var slashCatalog = []slashCommand{30 var slashCatalog = []slashCommand{

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