Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/design/nonblocking-slash-commands.md
Original file line number Diff line number Diff line change
@@ -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 <model> <prompt>`, 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.
110 changes: 110 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
return { handleSlashCommand, submitQuery, addMessage };
};

it('provides AppContext with correct values', () => {
const { unmount } = render(
<AppContainer
Expand Down Expand Up @@ -1393,6 +1443,66 @@ describe('AppContainer State Management', () => {
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();
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -2489,6 +2499,7 @@ export const AppContainer = (props: AppContainerProps) => {
isProcessing,
submitUserQuery,
handleSlashCommand,
slashCommands,
config,
geminiClient,
historyManager,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/commands/aboutCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/commands/aboutCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/helpCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/helpCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/settingsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ describe('settingsCommand', () => {
expect(settingsCommand.description).toBe(
'View and edit Qwen Code settings',
);
expect(settingsCommand.canRunDuringStreaming).toBe(true);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/settingsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading