Skip to content

Commit 29e9b35

Browse files
authored
feat(cli): run safe slash commands during streaming (#8130)
1 parent c82b6a5 commit 29e9b35

10 files changed

Lines changed: 191 additions & 0 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Non-blocking Slash Commands During Streaming
2+
3+
## Problem
4+
5+
The interactive input router currently queues every slash command except
6+
`/btw` while a model response is streaming. This makes local UI controls wait
7+
for the active conversation turn even when their result does not depend on that
8+
turn.
9+
10+
## Design
11+
12+
`SlashCommand` gains an opt-in `canRunDuringStreaming` capability. The default
13+
remains false. While the main model is responding, the input router resolves the
14+
submitted command through the existing slash-command tree. An opted-in command
15+
is sent directly to the slash-command processor; all other slash commands keep
16+
using the existing serialized message queue.
17+
18+
The direct path does not go through `submitQuery`. That function owns the model
19+
turn lifecycle and deliberately rejects concurrent top-level turns. Keeping
20+
local commands outside it avoids sharing abort controllers, submission flags,
21+
or model-stream counters with the active response.
22+
23+
The slash-command processor and command results already update Ink through
24+
React state. The initial commands therefore do not write directly to terminal
25+
stdout while Ink is rendering.
26+
27+
## Initial Command Set
28+
29+
- `/status`, `/about`, and `/status paths`: read local runtime information and
30+
append an Ink history item.
31+
- `/settings`: opens the settings dialog; saved changes apply through the
32+
existing settings hooks without replacing the active conversation turn.
33+
- `/help`: opens the static help dialog.
34+
35+
The following categories remain serialized:
36+
37+
- Commands that submit or transform a model turn, such as skills, `/summary`,
38+
`/compress`, `/model <model> <prompt>`, and `/goal`.
39+
- Commands that replace, clear, rewind, resume, branch, or otherwise mutate
40+
conversation state.
41+
- Commands that schedule tools or perform long-running external work.
42+
- Commands that read state being mutated by the active turn, such as
43+
`/context`, `/stats`, `/copy`, `/diff`, and `/recap`.
44+
45+
`/btw` keeps its specialized concurrent model-request path. `/quit` keeps its
46+
existing immediate cancellation path. Ctrl+Q continues to force any submission
47+
to wait for idle, including an otherwise opted-in command.
48+
49+
## Verification
50+
51+
Unit coverage verifies that opted-in commands bypass both `submitQuery` and the
52+
message queue during a response, while unmarked slash commands remain queued.
53+
Command tests pin the initial capability declarations. Interactive E2E checks
54+
should start a visibly streaming response, open each opted-in command, close any
55+
dialog, and confirm that the original response continues and completes.

packages/cli/src/ui/AppContainer.test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,56 @@ describe('AppContainer State Management', () => {
811811
});
812812

813813
describe('Context Providers', () => {
814+
const renderRespondingInput = (
815+
slashCommands: Array<{
816+
name: string;
817+
description: string;
818+
kind: 'built-in';
819+
canRunDuringStreaming?: boolean;
820+
}>,
821+
) => {
822+
const handleSlashCommand = vi.fn();
823+
const submitQuery = vi.fn();
824+
const addMessage = vi.fn();
825+
mockedUseSlashCommandProcessor.mockReturnValue({
826+
handleSlashCommand,
827+
slashCommands,
828+
pendingHistoryItems: [],
829+
commandContext: {},
830+
shellConfirmationRequest: null,
831+
confirmationRequest: null,
832+
});
833+
mockedUseGeminiStream.mockReturnValue({
834+
streamingState: 'responding',
835+
submitQuery,
836+
initError: null,
837+
pendingHistoryItems: [],
838+
thought: null,
839+
cancelOngoingRequest: vi.fn(),
840+
retryLastPrompt: vi.fn(),
841+
streamingResponseLengthRef: { current: 0 },
842+
isReceivingContent: false,
843+
});
844+
mockedUseMessageQueue.mockReturnValue({
845+
messageQueue: [],
846+
addMessage,
847+
clearQueue: vi.fn(),
848+
getQueuedMessagesText: vi.fn().mockReturnValue(''),
849+
popAllMessages: vi.fn().mockReturnValue(null),
850+
drainQueue: vi.fn().mockReturnValue([]),
851+
popNextTurn: vi.fn().mockReturnValue(null),
852+
});
853+
render(
854+
<AppContainer
855+
config={mockConfig}
856+
settings={mockSettings}
857+
version="1.0.0"
858+
initializationResult={mockInitResult}
859+
/>,
860+
);
861+
return { handleSlashCommand, submitQuery, addMessage };
862+
};
863+
814864
it('provides AppContext with correct values', () => {
815865
const { unmount } = render(
816866
<AppContainer
@@ -1393,6 +1443,66 @@ describe('AppContainer State Management', () => {
13931443
expect(mockQueueMessage).not.toHaveBeenCalled();
13941444
});
13951445

1446+
it('runs opted-in slash commands outside the active turn while responding', () => {
1447+
const { handleSlashCommand, submitQuery, addMessage } =
1448+
renderRespondingInput([
1449+
{
1450+
name: 'settings',
1451+
description: 'Open settings',
1452+
kind: 'built-in',
1453+
canRunDuringStreaming: true,
1454+
},
1455+
]);
1456+
1457+
capturedUIActions.handleFinalSubmit('/settings', {
1458+
submittedPrompt: '/settings',
1459+
});
1460+
1461+
expect(handleSlashCommand).toHaveBeenCalledWith('/settings');
1462+
expect(submitQuery).not.toHaveBeenCalled();
1463+
expect(addMessage).not.toHaveBeenCalled();
1464+
});
1465+
1466+
it('keeps opted-in slash commands queued when Ctrl+Q defers them', () => {
1467+
const { handleSlashCommand, submitQuery, addMessage } =
1468+
renderRespondingInput([
1469+
{
1470+
name: 'settings',
1471+
description: 'Open settings',
1472+
kind: 'built-in',
1473+
canRunDuringStreaming: true,
1474+
},
1475+
]);
1476+
1477+
capturedUIActions.handleFinalSubmit('/settings', {
1478+
deferUntilIdle: true,
1479+
submittedPrompt: '/settings',
1480+
});
1481+
1482+
expect(addMessage).toHaveBeenCalledWith('/settings', true, '/settings');
1483+
expect(handleSlashCommand).not.toHaveBeenCalled();
1484+
expect(submitQuery).not.toHaveBeenCalled();
1485+
});
1486+
1487+
it('keeps turn-dependent slash commands queued while responding', () => {
1488+
const { handleSlashCommand, submitQuery, addMessage } =
1489+
renderRespondingInput([
1490+
{
1491+
name: 'model',
1492+
description: 'Change model',
1493+
kind: 'built-in',
1494+
},
1495+
]);
1496+
1497+
capturedUIActions.handleFinalSubmit('/model', {
1498+
submittedPrompt: '/model',
1499+
});
1500+
1501+
expect(addMessage).toHaveBeenCalledWith('/model', false, '/model');
1502+
expect(handleSlashCommand).not.toHaveBeenCalled();
1503+
expect(submitQuery).not.toHaveBeenCalled();
1504+
});
1505+
13961506
it('submits slash commands immediately instead of queueing while idle', () => {
13971507
const mockSubmitQuery = vi.fn();
13981508
const mockQueueMessage = vi.fn();

packages/cli/src/ui/AppContainer.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ import {
173173
detectWorkflowKeyword,
174174
buildWorkflowSteeringNotice,
175175
} from './utils/workflow-keyword.js';
176+
import { parseSlashCommand } from '../utils/commands.js';
176177
import { type LoadedSettings, SettingScope } from '../config/settings.js';
177178
import { type InitializationResult } from '../core/initializer.js';
178179
import { ExtensionRefreshState } from '../config/extension-refresh-state.js';
@@ -2343,6 +2344,15 @@ export const AppContainer = (props: AppContainerProps) => {
23432344
addMessage(submittedValue, true, submittedPrompt);
23442345
return;
23452346
}
2347+
if (
2348+
streamingState === StreamingState.Responding &&
2349+
isSlashCommand(userPromptText) &&
2350+
parseSlashCommand(userPromptText, slashCommands).commandToExecute
2351+
?.canRunDuringStreaming
2352+
) {
2353+
void handleSlashCommand(userPromptText);
2354+
return;
2355+
}
23462356
if (
23472357
streamingState === StreamingState.Responding &&
23482358
isBtwCommand(submittedValue)
@@ -2489,6 +2499,7 @@ export const AppContainer = (props: AppContainerProps) => {
24892499
isProcessing,
24902500
submitUserQuery,
24912501
handleSlashCommand,
2502+
slashCommands,
24922503
config,
24932504
geminiClient,
24942505
historyManager,

packages/cli/src/ui/commands/aboutCommand.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ describe('aboutCommand', () => {
7575
expect(aboutCommand.name).toBe('status');
7676
expect(aboutCommand.altNames).toEqual(['about']);
7777
expect(aboutCommand.description).toBe('show version info');
78+
expect(aboutCommand.canRunDuringStreaming).toBe(true);
79+
expect(aboutCommand.subCommands?.[0]?.canRunDuringStreaming).toBe(true);
7880
});
7981

8082
it('should call addItem with all version info', async () => {

packages/cli/src/ui/commands/aboutCommand.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const aboutCommand: SlashCommand = {
2222
},
2323
kind: CommandKind.BUILT_IN,
2424
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
25+
canRunDuringStreaming: true,
2526
action: async (context) => {
2627
const systemInfo = await getExtendedSystemInfo(context);
2728

@@ -63,6 +64,7 @@ export const aboutCommand: SlashCommand = {
6364
},
6465
kind: CommandKind.BUILT_IN,
6566
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
67+
canRunDuringStreaming: true,
6668
action: async (context) => {
6769
const info = await collectSessionPathInfo(context);
6870
const content = formatSessionPathInfo(info);

packages/cli/src/ui/commands/helpCommand.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,6 @@ describe('helpCommand', () => {
5555
expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
5656
expect(helpCommand.argumentHint).toBeUndefined();
5757
expect(helpCommand.description).toBe('for help on Qwen Code');
58+
expect(helpCommand.canRunDuringStreaming).toBe(true);
5859
});
5960
});

packages/cli/src/ui/commands/helpCommand.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const helpCommand: SlashCommand = {
1313
altNames: ['?'],
1414
kind: CommandKind.BUILT_IN,
1515
supportedModes: ['interactive'] as const,
16+
canRunDuringStreaming: true,
1617
get description() {
1718
return t('for help on Qwen Code');
1819
},

packages/cli/src/ui/commands/settingsCommand.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ describe('settingsCommand', () => {
3232
expect(settingsCommand.description).toBe(
3333
'View and edit Qwen Code settings',
3434
);
35+
expect(settingsCommand.canRunDuringStreaming).toBe(true);
3536
});
3637
});

packages/cli/src/ui/commands/settingsCommand.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = {
1515
},
1616
kind: CommandKind.BUILT_IN,
1717
supportedModes: ['interactive'] as const,
18+
canRunDuringStreaming: true,
1819
action: (_context, _args): OpenDialogActionReturn => ({
1920
type: 'dialog',
2021
dialog: 'settings',

packages/cli/src/ui/commands/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,13 @@ export interface SlashCommand {
366366
*/
367367
supportedModes?: ExecutionMode[];
368368

369+
/**
370+
* Whether the interactive UI may execute this command immediately while a
371+
* model response is streaming. Commands opt in only when they do not submit
372+
* a model turn or mutate conversation state owned by the active turn.
373+
*/
374+
canRunDuringStreaming?: boolean;
375+
369376
// ── Phase 1: visibility ────────────────────────────────────────────────
370377
/**
371378
* Whether users can invoke this command via a slash command.

0 commit comments

Comments
 (0)