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
13 changes: 13 additions & 0 deletions docs/design/nonblocking-slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 eleven more 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`,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/bugCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/bugCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const bugCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
argumentHint: '<description>',
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
canRunDuringStreaming: true,
action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim();
const systemInfo = await getExtendedSystemInfo(context);
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/docsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/docsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/editorCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/editorCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const editorCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
canRunDuringStreaming: true,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'editor',
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/hooksCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '');
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/hooksCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/lspCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/lspCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/tasksCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ describe('tasksCommand', () => {
} as unknown as Parameters<typeof createMockCommandContext>[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({
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/tasksCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/terminalSetupCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/terminalSetupCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const terminalSetupCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
canRunDuringStreaming: true,

action: async (): Promise<MessageActionReturn> => {
try {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/themeCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/themeCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/toolsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/toolsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
const subCommand = args?.trim();

Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/ui/commands/vimCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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.',
});
});
});
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/vimCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/voice-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/voice-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading