diff --git a/docs/design/nonblocking-slash-commands.md b/docs/design/nonblocking-slash-commands.md new file mode 100644 index 0000000000..d84fb54d24 --- /dev/null +++ b/docs/design/nonblocking-slash-commands.md @@ -0,0 +1,55 @@ +# Non-blocking Slash Commands During Streaming + +## Problem + +The interactive input router currently queues every slash command except +`/btw` while a model response is streaming. This makes local UI controls wait +for the active conversation turn even when their result does not depend on that +turn. + +## Design + +`SlashCommand` gains an opt-in `canRunDuringStreaming` capability. The default +remains false. While the main model is responding, the input router resolves the +submitted command through the existing slash-command tree. An opted-in command +is sent directly to the slash-command processor; all other slash commands keep +using the existing serialized message queue. + +The direct path does not go through `submitQuery`. That function owns the model +turn lifecycle and deliberately rejects concurrent top-level turns. Keeping +local commands outside it avoids sharing abort controllers, submission flags, +or model-stream counters with the active response. + +The slash-command processor and command results already update Ink through +React state. The initial commands therefore do not write directly to terminal +stdout while Ink is rendering. + +## Initial Command Set + +- `/status`, `/about`, and `/status paths`: read local runtime information and + append an Ink history item. +- `/settings`: opens the settings dialog; saved changes apply through the + existing settings hooks without replacing the active conversation turn. +- `/help`: opens the static help dialog. + +The following categories remain serialized: + +- Commands that submit or transform a model turn, such as skills, `/summary`, + `/compress`, `/model `, and `/goal`. +- Commands that replace, clear, rewind, resume, branch, or otherwise mutate + conversation state. +- Commands that schedule tools or perform long-running external work. +- Commands that read state being mutated by the active turn, such as + `/context`, `/stats`, `/copy`, `/diff`, and `/recap`. + +`/btw` keeps its specialized concurrent model-request path. `/quit` keeps its +existing immediate cancellation path. Ctrl+Q continues to force any submission +to wait for idle, including an otherwise opted-in command. + +## Verification + +Unit coverage verifies that opted-in commands bypass both `submitQuery` and the +message queue during a response, while unmarked slash commands remain queued. +Command tests pin the initial capability declarations. Interactive E2E checks +should start a visibly streaming response, open each opted-in command, close any +dialog, and confirm that the original response continues and completes. diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 71843d9dfb..61f1eebdec 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -811,6 +811,56 @@ describe('AppContainer State Management', () => { }); describe('Context Providers', () => { + const renderRespondingInput = ( + slashCommands: Array<{ + name: string; + description: string; + kind: 'built-in'; + canRunDuringStreaming?: boolean; + }>, + ) => { + const handleSlashCommand = vi.fn(); + const submitQuery = vi.fn(); + const addMessage = vi.fn(); + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand, + slashCommands, + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'responding', + submitQuery, + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + }); + mockedUseMessageQueue.mockReturnValue({ + messageQueue: [], + addMessage, + clearQueue: vi.fn(), + getQueuedMessagesText: vi.fn().mockReturnValue(''), + popAllMessages: vi.fn().mockReturnValue(null), + drainQueue: vi.fn().mockReturnValue([]), + popNextTurn: vi.fn().mockReturnValue(null), + }); + render( + , + ); + return { handleSlashCommand, submitQuery, addMessage }; + }; + it('provides AppContext with correct values', () => { const { unmount } = render( { expect(mockQueueMessage).not.toHaveBeenCalled(); }); + it('runs opted-in slash commands outside the active turn while responding', () => { + const { handleSlashCommand, submitQuery, addMessage } = + renderRespondingInput([ + { + name: 'settings', + description: 'Open settings', + kind: 'built-in', + canRunDuringStreaming: true, + }, + ]); + + capturedUIActions.handleFinalSubmit('/settings', { + submittedPrompt: '/settings', + }); + + expect(handleSlashCommand).toHaveBeenCalledWith('/settings'); + expect(submitQuery).not.toHaveBeenCalled(); + expect(addMessage).not.toHaveBeenCalled(); + }); + + it('keeps opted-in slash commands queued when Ctrl+Q defers them', () => { + const { handleSlashCommand, submitQuery, addMessage } = + renderRespondingInput([ + { + name: 'settings', + description: 'Open settings', + kind: 'built-in', + canRunDuringStreaming: true, + }, + ]); + + capturedUIActions.handleFinalSubmit('/settings', { + deferUntilIdle: true, + submittedPrompt: '/settings', + }); + + expect(addMessage).toHaveBeenCalledWith('/settings', true, '/settings'); + expect(handleSlashCommand).not.toHaveBeenCalled(); + expect(submitQuery).not.toHaveBeenCalled(); + }); + + it('keeps turn-dependent slash commands queued while responding', () => { + const { handleSlashCommand, submitQuery, addMessage } = + renderRespondingInput([ + { + name: 'model', + description: 'Change model', + kind: 'built-in', + }, + ]); + + capturedUIActions.handleFinalSubmit('/model', { + submittedPrompt: '/model', + }); + + expect(addMessage).toHaveBeenCalledWith('/model', false, '/model'); + expect(handleSlashCommand).not.toHaveBeenCalled(); + expect(submitQuery).not.toHaveBeenCalled(); + }); + it('submits slash commands immediately instead of queueing while idle', () => { const mockSubmitQuery = vi.fn(); const mockQueueMessage = vi.fn(); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0eaf5195b4..a4a47b8a5b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -173,6 +173,7 @@ import { detectWorkflowKeyword, buildWorkflowSteeringNotice, } from './utils/workflow-keyword.js'; +import { parseSlashCommand } from '../utils/commands.js'; import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { ExtensionRefreshState } from '../config/extension-refresh-state.js'; @@ -2343,6 +2344,15 @@ export const AppContainer = (props: AppContainerProps) => { addMessage(submittedValue, true, submittedPrompt); return; } + if ( + streamingState === StreamingState.Responding && + isSlashCommand(userPromptText) && + parseSlashCommand(userPromptText, slashCommands).commandToExecute + ?.canRunDuringStreaming + ) { + void handleSlashCommand(userPromptText); + return; + } if ( streamingState === StreamingState.Responding && isBtwCommand(submittedValue) @@ -2489,6 +2499,7 @@ export const AppContainer = (props: AppContainerProps) => { isProcessing, submitUserQuery, handleSlashCommand, + slashCommands, config, geminiClient, historyManager, diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 2a5bf58870..fd2d581429 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -75,6 +75,8 @@ describe('aboutCommand', () => { expect(aboutCommand.name).toBe('status'); expect(aboutCommand.altNames).toEqual(['about']); expect(aboutCommand.description).toBe('show version info'); + expect(aboutCommand.canRunDuringStreaming).toBe(true); + expect(aboutCommand.subCommands?.[0]?.canRunDuringStreaming).toBe(true); }); it('should call addItem with all version info', async () => { diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 1e0e98a8d6..118d4d29b6 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -22,6 +22,7 @@ export const aboutCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + canRunDuringStreaming: true, action: async (context) => { const systemInfo = await getExtendedSystemInfo(context); @@ -63,6 +64,7 @@ export const aboutCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + canRunDuringStreaming: true, action: async (context) => { const info = await collectSessionPathInfo(context); const content = formatSessionPathInfo(info); diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts index 964682b720..8b40bf03cb 100644 --- a/packages/cli/src/ui/commands/helpCommand.test.ts +++ b/packages/cli/src/ui/commands/helpCommand.test.ts @@ -55,5 +55,6 @@ describe('helpCommand', () => { expect(helpCommand.kind).toBe(CommandKind.BUILT_IN); expect(helpCommand.argumentHint).toBeUndefined(); expect(helpCommand.description).toBe('for help on Qwen Code'); + expect(helpCommand.canRunDuringStreaming).toBe(true); }); }); diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index 3e224964a9..b7784dda06 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -13,6 +13,7 @@ export const helpCommand: SlashCommand = { altNames: ['?'], kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, + canRunDuringStreaming: true, get description() { return t('for help on Qwen Code'); }, diff --git a/packages/cli/src/ui/commands/settingsCommand.test.ts b/packages/cli/src/ui/commands/settingsCommand.test.ts index 6aa3111ffb..3ebf277f67 100644 --- a/packages/cli/src/ui/commands/settingsCommand.test.ts +++ b/packages/cli/src/ui/commands/settingsCommand.test.ts @@ -32,5 +32,6 @@ describe('settingsCommand', () => { expect(settingsCommand.description).toBe( 'View and edit Qwen Code settings', ); + expect(settingsCommand.canRunDuringStreaming).toBe(true); }); }); diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts index 37f7002e7a..8543034a77 100644 --- a/packages/cli/src/ui/commands/settingsCommand.ts +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, + canRunDuringStreaming: true, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'settings', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index a41306770f..684eb5c412 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -366,6 +366,13 @@ export interface SlashCommand { */ supportedModes?: ExecutionMode[]; + /** + * Whether the interactive UI may execute this command immediately while a + * model response is streaming. Commands opt in only when they do not submit + * a model turn or mutate conversation state owned by the active turn. + */ + canRunDuringStreaming?: boolean; + // ── Phase 1: visibility ──────────────────────────────────────────────── /** * Whether users can invoke this command via a slash command.