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 | |||
| 223 | 223 | ||
| 224 | 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 | 228 | ## keys (interactive mode) | |
| 229 | 229 | ||
| @@ -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) { | |||
| 739 | 739 | i.mu.Unlock() | |
| 740 | 740 | return false | |
| 741 | 741 | } | |
| 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() | |
| 753 | 755 | } | |
| 754 | 756 | return i.runSlash(ctx, text) | |
| 755 | 757 | } | |
| @@ -943,6 +945,38 @@ func (i *Interactive) startOAuthFlow(provider string) { | |||
| 943 | 945 | // applyModelSelection switches the active model (and provider, if the | |
| 944 | 946 | // new model belongs to a different one). It rebuilds the underlying | |
| 945 | 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 | 980 | // openJumpDialog builds a /jump picker from the current transcript. | |
| 947 | 981 | // If the user typed "/jump foo" with a filter and it matches exactly | |
| 948 | 982 | // 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 false | 740 | 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 while | 743 | + | // 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.busy | 746 | + | // 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 false | 752 | + | } |
| 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 the | 945 | // applyModelSelection switches the active model (and provider, if the | ||
| 944 | // new model belongs to a different one). It rebuilds the underlying | 946 | // 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 exactly | 981 | // 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 { | |||
| 12 | 12 | 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 | 28 | // slashCatalog lists every slash command the interactive mode handles. | |
| 16 | 29 | // Keep in sync with runSlash(). | |
| 17 | 30 | var slashCatalog = []slashCommand{ | |
| @@ -12,6 +12,19 @@ type slashCommand struct { | |||||
| 12 | Desc string | 12 | 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{ | ||