From 1cd08ae1550dda675e00129e4f7bc891e89fcb31 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Thu, 20 Aug 2026 01:16:04 +0800 Subject: [PATCH 1/2] feat(cli): extend non-blocking slash commands to more builtins #8130 opted /about, /help, and /settings in to run immediately while a response streams. Apply the same criteria to eleven more builtins so local UI controls no longer wait for the active turn: - UI-preference commands whose saved changes apply through the existing settings hooks: /theme, /editor, /vim, /voice, and /terminal-setup. - Read-only status commands: /tools, /lsp, /tasks, /hooks, /docs, and /bug. Commands that submit model turns, mutate conversation state, or read state the active turn is writing remain serialized, as documented in the non-blocking slash commands design doc. Each opt-in is pinned by a unit test. --- docs/design/nonblocking-slash-commands.md | 13 +++++ .../cli/src/ui/commands/bugCommand.test.ts | 4 ++ packages/cli/src/ui/commands/bugCommand.ts | 1 + .../cli/src/ui/commands/docsCommand.test.ts | 4 ++ packages/cli/src/ui/commands/docsCommand.ts | 1 + .../cli/src/ui/commands/editorCommand.test.ts | 1 + packages/cli/src/ui/commands/editorCommand.ts | 1 + .../cli/src/ui/commands/hooksCommand.test.ts | 4 ++ packages/cli/src/ui/commands/hooksCommand.ts | 1 + .../cli/src/ui/commands/lspCommand.test.ts | 4 ++ packages/cli/src/ui/commands/lspCommand.ts | 1 + .../cli/src/ui/commands/tasksCommand.test.ts | 4 ++ packages/cli/src/ui/commands/tasksCommand.ts | 1 + .../ui/commands/terminalSetupCommand.test.ts | 1 + .../src/ui/commands/terminalSetupCommand.ts | 1 + .../cli/src/ui/commands/themeCommand.test.ts | 1 + packages/cli/src/ui/commands/themeCommand.ts | 1 + .../cli/src/ui/commands/toolsCommand.test.ts | 4 ++ packages/cli/src/ui/commands/toolsCommand.ts | 1 + .../cli/src/ui/commands/vimCommand.test.ts | 49 +++++++++++++++++++ packages/cli/src/ui/commands/vimCommand.ts | 1 + .../cli/src/ui/commands/voice-command.test.ts | 1 + packages/cli/src/ui/commands/voice-command.ts | 1 + 23 files changed, 101 insertions(+) create mode 100644 packages/cli/src/ui/commands/vimCommand.test.ts diff --git a/docs/design/nonblocking-slash-commands.md b/docs/design/nonblocking-slash-commands.md index d84fb54d240..868f543aa7d 100644 --- a/docs/design/nonblocking-slash-commands.md +++ b/docs/design/nonblocking-slash-commands.md @@ -32,6 +32,19 @@ stdout while Ink is rendering. existing settings hooks without replacing the active conversation turn. - `/help`: opens the static help dialog. +## Extended Command Set + +The same criteria were later applied to the remaining builtins: + +- UI-preference commands whose saved changes apply through the existing + settings hooks without touching the active turn: `/theme`, `/editor`, + `/vim`, `/voice`, and `/terminal-setup` (writes only external IDE + keybinding files). +- Read-only status commands that neither read state the active turn is + writing nor mutate anything: `/tools`, `/lsp`, `/tasks`, `/hooks` + (read-only browse dialog), `/docs`, and `/bug` (the latter two only + append an Ink item and open a browser). + The following categories remain serialized: - Commands that submit or transform a model turn, such as skills, `/summary`, diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index 6eb474c0347..ce464649249 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -54,6 +54,10 @@ describe('bugCommand', () => { vi.clearAllMocks(); }); + it('opts in to running during streaming', () => { + expect(bugCommand.canRunDuringStreaming).toBe(true); + }); + it('should generate the default GitHub issue URL', async () => { const mockContext = createMockCommandContext({ services: { diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 2b4a1362a37..8caacebf6f2 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -23,6 +23,7 @@ export const bugCommand: SlashCommand = { kind: CommandKind.BUILT_IN, argumentHint: '', supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + canRunDuringStreaming: true, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); const systemInfo = await getExtendedSystemInfo(context); diff --git a/packages/cli/src/ui/commands/docsCommand.test.ts b/packages/cli/src/ui/commands/docsCommand.test.ts index c4e5b83c811..93f837863a5 100644 --- a/packages/cli/src/ui/commands/docsCommand.test.ts +++ b/packages/cli/src/ui/commands/docsCommand.test.ts @@ -35,6 +35,10 @@ describe('docsCommand', () => { vi.unstubAllEnvs(); }); + it('opts in to running during streaming', () => { + expect(docsCommand.canRunDuringStreaming).toBe(true); + }); + it("should add an info message and call 'open' in a non-sandbox environment", async () => { if (!docsCommand.action) { throw new Error('docsCommand must have an action.'); diff --git a/packages/cli/src/ui/commands/docsCommand.ts b/packages/cli/src/ui/commands/docsCommand.ts index 754e3a20159..99216135054 100644 --- a/packages/cli/src/ui/commands/docsCommand.ts +++ b/packages/cli/src/ui/commands/docsCommand.ts @@ -21,6 +21,7 @@ export const docsCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + canRunDuringStreaming: true, action: async (context: CommandContext) => { const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en'; const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`; diff --git a/packages/cli/src/ui/commands/editorCommand.test.ts b/packages/cli/src/ui/commands/editorCommand.test.ts index 9b5e84d3d1c..fd10bdca196 100644 --- a/packages/cli/src/ui/commands/editorCommand.test.ts +++ b/packages/cli/src/ui/commands/editorCommand.test.ts @@ -26,5 +26,6 @@ describe('editorCommand', () => { it('should have the correct name and description', () => { expect(editorCommand.name).toBe('editor'); expect(editorCommand.description).toBe('set external editor preference'); + expect(editorCommand.canRunDuringStreaming).toBe(true); }); }); diff --git a/packages/cli/src/ui/commands/editorCommand.ts b/packages/cli/src/ui/commands/editorCommand.ts index 9ecf5ba99af..6d55123ef70 100644 --- a/packages/cli/src/ui/commands/editorCommand.ts +++ b/packages/cli/src/ui/commands/editorCommand.ts @@ -18,6 +18,7 @@ export const editorCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, + canRunDuringStreaming: true, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'editor', diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index b2b4dbd5efb..4aba04d74df 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -32,6 +32,10 @@ describe('hooksCommand', () => { }); }); + it('opts in to running during streaming', () => { + expect(hooksCommand.canRunDuringStreaming).toBe(true); + }); + describe('basic functionality', () => { it('should open hooks management dialog in interactive mode', async () => { const result = await hooksCommand.action!(mockContext, ''); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 2cdbff8e161..47622e89f52 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -197,6 +197,7 @@ export const hooksCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + canRunDuringStreaming: true, action: async ( context: CommandContext, args: string, diff --git a/packages/cli/src/ui/commands/lspCommand.test.ts b/packages/cli/src/ui/commands/lspCommand.test.ts index 772876b449a..1af4c64b099 100644 --- a/packages/cli/src/ui/commands/lspCommand.test.ts +++ b/packages/cli/src/ui/commands/lspCommand.test.ts @@ -30,6 +30,10 @@ describe('lspCommand', () => { ]); }); + it('opts in to running during streaming', () => { + expect(lspCommand.canRunDuringStreaming).toBe(true); + }); + it('returns an error when config is unavailable in non-interactive mode', async () => { if (!lspCommand.action) { throw new Error('lspCommand must have an action'); diff --git a/packages/cli/src/ui/commands/lspCommand.ts b/packages/cli/src/ui/commands/lspCommand.ts index fe00dba69b0..5678b769c3b 100644 --- a/packages/cli/src/ui/commands/lspCommand.ts +++ b/packages/cli/src/ui/commands/lspCommand.ts @@ -42,6 +42,7 @@ export const lspCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + canRunDuringStreaming: true, action: async ( context: CommandContext, _args?: string, diff --git a/packages/cli/src/ui/commands/tasksCommand.test.ts b/packages/cli/src/ui/commands/tasksCommand.test.ts index fecddfeb28e..eda6c54a633 100644 --- a/packages/cli/src/ui/commands/tasksCommand.test.ts +++ b/packages/cli/src/ui/commands/tasksCommand.test.ts @@ -105,6 +105,10 @@ describe('tasksCommand', () => { } as unknown as Parameters[0]); }); + it('opts in to running during streaming', () => { + expect(tasksCommand.canRunDuringStreaming).toBe(true); + }); + it('reports an empty registry', async () => { const result = await tasksCommand.action!(context, ''); expect(result).toEqual({ diff --git a/packages/cli/src/ui/commands/tasksCommand.ts b/packages/cli/src/ui/commands/tasksCommand.ts index 3096be24fef..73818c33bff 100644 --- a/packages/cli/src/ui/commands/tasksCommand.ts +++ b/packages/cli/src/ui/commands/tasksCommand.ts @@ -166,6 +166,7 @@ export const tasksCommand: SlashCommand = { // text dump as the only way to inspect background task state. See the // interactive-mode hint at the top of the output for the soft redirect. supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + canRunDuringStreaming: true, action: async (context) => { const { config } = context.services; if (!config) { diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.test.ts b/packages/cli/src/ui/commands/terminalSetupCommand.test.ts index d0d3d6c6c5d..8a22017bc76 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.test.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.test.ts @@ -20,6 +20,7 @@ describe('terminalSetupCommand', () => { expect(terminalSetupCommand.name).toBe('terminal-setup'); expect(terminalSetupCommand.description).toContain('multiline input'); expect(terminalSetupCommand.kind).toBe('built-in'); + expect(terminalSetupCommand.canRunDuringStreaming).toBe(true); }); it('should return success message when terminal setup succeeds', async () => { diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts index 2f25f349510..efa70964d0e 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -24,6 +24,7 @@ export const terminalSetupCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, + canRunDuringStreaming: true, action: async (): Promise => { try { diff --git a/packages/cli/src/ui/commands/themeCommand.test.ts b/packages/cli/src/ui/commands/themeCommand.test.ts index 1ad3dc6b6f0..667cfd3b429 100644 --- a/packages/cli/src/ui/commands/themeCommand.test.ts +++ b/packages/cli/src/ui/commands/themeCommand.test.ts @@ -57,5 +57,6 @@ describe('themeCommand', () => { it('should have the correct name and description', () => { expect(themeCommand.name).toBe('theme'); expect(themeCommand.description).toBe('change the theme'); + expect(themeCommand.canRunDuringStreaming).toBe(true); }); }); diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index 520a26ab61a..638d35bdad0 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -20,6 +20,7 @@ export const themeCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, + canRunDuringStreaming: true, action: (_context, _args): OpenDialogActionReturn | MessageActionReturn => { // Reject before opening the dialog: with NO_COLOR the theme picker // cannot run, and returning a message lets the processor record the diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index 9e1eae83645..356ff281fee 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -27,6 +27,10 @@ const mockTools = [ ] as Tool[]; describe('toolsCommand', () => { + it('opts in to running during streaming', () => { + expect(toolsCommand.canRunDuringStreaming).toBe(true); + }); + it('should display an error if the tool registry is unavailable', async () => { const mockContext = createMockCommandContext({ services: { diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 5c6625e6d35..90f6f2886ee 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -18,6 +18,7 @@ export const toolsCommand: SlashCommand = { return t('List available Qwen Code tools. Usage: /tools [desc]'); }, kind: CommandKind.BUILT_IN, + canRunDuringStreaming: true, action: async (context: CommandContext, args?: string): Promise => { const subCommand = args?.trim(); diff --git a/packages/cli/src/ui/commands/vimCommand.test.ts b/packages/cli/src/ui/commands/vimCommand.test.ts new file mode 100644 index 00000000000..db294ef566c --- /dev/null +++ b/packages/cli/src/ui/commands/vimCommand.test.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { vimCommand } from './vimCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('vimCommand', () => { + it('should have the correct metadata', () => { + expect(vimCommand.name).toBe('vim'); + expect(vimCommand.description).toBe('toggle vim mode on/off'); + expect(vimCommand.canRunDuringStreaming).toBe(true); + }); + + it('should report entering vim mode when toggled on', async () => { + const mockContext = createMockCommandContext({ + ui: { + toggleVimEnabled: vi.fn().mockResolvedValue(true), + }, + }); + + const result = await vimCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Entered Vim mode. Run /vim again to exit.', + }); + }); + + it('should report exiting vim mode when toggled off', async () => { + const mockContext = createMockCommandContext({ + ui: { + toggleVimEnabled: vi.fn().mockResolvedValue(false), + }, + }); + + const result = await vimCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Exited Vim mode.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts index 0999357ae99..c977da13ed6 100644 --- a/packages/cli/src/ui/commands/vimCommand.ts +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -15,6 +15,7 @@ export const vimCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, + canRunDuringStreaming: true, action: async (context, _args) => { const newVimState = await context.ui.toggleVimEnabled(); diff --git a/packages/cli/src/ui/commands/voice-command.test.ts b/packages/cli/src/ui/commands/voice-command.test.ts index c38753a0c01..069d7994bd7 100644 --- a/packages/cli/src/ui/commands/voice-command.test.ts +++ b/packages/cli/src/ui/commands/voice-command.test.ts @@ -28,6 +28,7 @@ describe('voice-command', () => { it('has the expected metadata', () => { expect(voiceCommand.name).toBe('voice'); expect(voiceCommand.argumentHint).toBe('[hold|tap|off|status]'); + expect(voiceCommand.canRunDuringStreaming).toBe(true); }); it('prompts for a voice model before enabling', async () => { diff --git a/packages/cli/src/ui/commands/voice-command.ts b/packages/cli/src/ui/commands/voice-command.ts index e0aede52bf9..b4f5a6e249e 100644 --- a/packages/cli/src/ui/commands/voice-command.ts +++ b/packages/cli/src/ui/commands/voice-command.ts @@ -23,6 +23,7 @@ export const voiceCommand: SlashCommand = { argumentHint: '[hold|tap|off|status]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, + canRunDuringStreaming: true, action: (context, args) => { const settings = context.services.settings; const command = args.trim().toLowerCase(); From c5b9e014c91d71dfcdce830c1fd0cff369d0a3c4 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:26:54 +0800 Subject: [PATCH 2/2] docs(cli): scope extended-command claim to the eleven opted-in builtins --- docs/design/nonblocking-slash-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/nonblocking-slash-commands.md b/docs/design/nonblocking-slash-commands.md index 868f543aa7d..2fa66f7d132 100644 --- a/docs/design/nonblocking-slash-commands.md +++ b/docs/design/nonblocking-slash-commands.md @@ -34,7 +34,7 @@ stdout while Ink is rendering. ## Extended Command Set -The same criteria were later applied to the remaining builtins: +The same criteria were later applied to eleven more builtins: - UI-preference commands whose saved changes apply through the existing settings hooks without touching the active turn: `/theme`, `/editor`,