diff --git a/README.md b/README.md index aeff922..c0d8b3b 100644 --- a/README.md +++ b/README.md @@ -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: ` 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) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 71b0c09..707a8da 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -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. diff --git a/internal/agent/modes/slash_suggest.go b/internal/agent/modes/slash_suggest.go index 83da217..79114b7 100644 --- a/internal/agent/modes/slash_suggest.go +++ b/internal/agent/modes/slash_suggest.go @@ -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{