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.
This commit is contained in:
patriceckhart 2026-04-19 11:21:37 +02:00
parent 64704875d2
commit d653cc179d
3 changed files with 59 additions and 12 deletions

View file

@ -223,7 +223,7 @@ frames containing images are full-repainted (no differential diff) to prevent st
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.
slash commands still require an idle state — typing `/something` during a turn prints `cancel the current turn (esc) before running a slash command`.
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.
## keys (interactive mode)

View file

@ -739,17 +739,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
i.mu.Unlock()
return false
}
// Slash commands need a quiet state (they may swap models,
// compact the transcript, open dialogs, etc). Refuse while
// a turn is in flight — esc / ctrl+c cancels first.
i.mu.Lock()
busy := i.busy
i.mu.Unlock()
if busy {
i.mu.Lock()
i.statusErr = "cancel the current turn (esc) before running a slash command"
i.mu.Unlock()
return false
// Slash commands run regardless of busy state. Commands that
// would mutate the transcript or replace the agent (/clear,
// /compact, /logout, /login, /model) cancel the active turn
// first and wait for the goroutine to wind down so they don't
// race with a streaming response. Safe commands (/help,
// /jump, /sessions, /lock, /unlock, /exit) run immediately
// without disturbing the active turn.
head := text
if idx := strings.IndexAny(text, " \t"); idx >= 0 {
head = text[:idx]
}
if slashCancelsTurn(head) {
i.cancelAndWaitForIdle()
}
return i.runSlash(ctx, text)
}
@ -943,6 +945,38 @@ func (i *Interactive) startOAuthFlow(provider string) {
// applyModelSelection switches the active model (and provider, if the
// new model belongs to a different one). It rebuilds the underlying
// client when needed so the provider wire-protocol matches.
// cancelAndWaitForIdle cancels the active turn (if any) and blocks
// briefly until the turn goroutine has updated i.busy = false. Used
// before destructive slash commands so transcript-mutating work
// (/clear, /compact, /logout, /login completion, cross-provider
// /model swap) doesn't race with the still-running stream.
//
// The wait is bounded; if the turn doesn't release within the timeout
// we proceed anyway. Worst case is a brief overlap that the agent's
// own mutex protects against.
func (i *Interactive) cancelAndWaitForIdle() {
i.mu.Lock()
busy := i.busy
cancel := i.cancelTurn
i.mu.Unlock()
if !busy {
return
}
if cancel != nil {
cancel()
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
i.mu.Lock()
done := !i.busy
i.mu.Unlock()
if done {
return
}
time.Sleep(10 * time.Millisecond)
}
}
// openJumpDialog builds a /jump picker from the current transcript.
// If the user typed "/jump foo" with a filter and it matches exactly
// one turn, jump there directly without showing the dialog.

View file

@ -12,6 +12,19 @@ type slashCommand struct {
Desc string
}
// slashCancelsTurn reports whether the named slash command, when run
// while a turn is in flight, requires the active turn to be cancelled
// first. The destructive commands (those that mutate the transcript
// or rebuild the agent) need a quiet state; the rest run alongside
// the streaming response without trouble.
func slashCancelsTurn(head string) bool {
switch head {
case "/clear", "/compact", "/logout", "/login", "/model":
return true
}
return false
}
// slashCatalog lists every slash command the interactive mode handles.
// Keep in sync with runSlash().
var slashCatalog = []slashCommand{