fix: basic slash command support (#1020)

This commit is contained in:
Mingholy
2025-11-18 13:46:42 +08:00
committed by GitHub
parent 6bb829f876
commit efca0bc795
4 changed files with 247 additions and 9 deletions

View File

@@ -23,6 +23,7 @@ import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
import { vi } from 'vitest';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind } from './ui/commands/types.js';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
@@ -727,6 +728,7 @@ describe('runNonInteractive', () => {
const mockCommand = {
name: 'testcommand',
description: 'a test command',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: [{ text: 'Prompt from command' }],
@@ -766,6 +768,7 @@ describe('runNonInteractive', () => {
const mockCommand = {
name: 'confirm',
description: 'a command that needs confirmation',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'confirm_shell_commands',
commands: ['rm -rf /'],
@@ -821,6 +824,7 @@ describe('runNonInteractive', () => {
const mockCommand = {
name: 'noaction',
description: 'unhandled type',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'unhandled',
}),
@@ -847,6 +851,7 @@ describe('runNonInteractive', () => {
const mockCommand = {
name: 'testargs',
description: 'a test command',
kind: CommandKind.FILE,
action: mockAction,
};
mockGetCommands.mockReturnValue([mockCommand]);

View File

@@ -13,15 +13,56 @@ import {
type Config,
} from '@qwen-code/qwen-code-core';
import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import type { CommandContext } from './ui/commands/types.js';
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { LoadedSettings } from './config/settings.js';
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
/**
* Filters commands based on the allowed built-in command names.
*
* - Always includes FILE commands
* - Only includes BUILT_IN commands if their name is in the allowed set
* - Excludes other command types (e.g., MCP_PROMPT) in non-interactive mode
*
* @param commands All loaded commands
* @param allowedBuiltinCommandNames Set of allowed built-in command names (empty = none allowed)
* @returns Filtered commands
*/
function filterCommandsForNonInteractive(
commands: readonly SlashCommand[],
allowedBuiltinCommandNames: Set<string>,
): SlashCommand[] {
return commands.filter((cmd) => {
if (cmd.kind === CommandKind.FILE) {
return true;
}
// Built-in commands: only include if in the allowed list
if (cmd.kind === CommandKind.BUILT_IN) {
return allowedBuiltinCommandNames.has(cmd.name);
}
// Exclude other types (e.g., MCP_PROMPT) in non-interactive mode
return false;
});
}
/**
* Processes a slash command in a non-interactive environment.
*
* @param rawQuery The raw query string (should start with '/')
* @param abortController Controller to cancel the operation
* @param config The configuration object
* @param settings The loaded settings
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. If not provided or empty, only file commands are available.
* @returns A Promise that resolves to `PartListUnion` if a valid command is
* found and results in a prompt, or `undefined` otherwise.
* @throws {FatalInputError} if the command result is not supported in
@@ -32,21 +73,35 @@ export const handleSlashCommand = async (
abortController: AbortController,
config: Config,
settings: LoadedSettings,
allowedBuiltinCommandNames?: string[],
): Promise<PartListUnion | undefined> => {
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/')) {
return;
}
// Only custom commands are supported for now.
const loaders = [new FileCommandLoader(config)];
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
: [new FileCommandLoader(config)];
const commandService = await CommandService.create(
loaders,
abortController.signal,
);
const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
commands,
allowedBuiltinSet,
);
const { commandToExecute, args } = parseSlashCommand(rawQuery, commands);
const { commandToExecute, args } = parseSlashCommand(
rawQuery,
filteredCommands,
);
if (commandToExecute) {
if (commandToExecute.action) {
@@ -107,3 +162,44 @@ export const handleSlashCommand = async (
return;
};
/**
* Retrieves all available slash commands for the current configuration.
*
* @param config The configuration object
* @param settings The loaded settings
* @param abortSignal Signal to cancel the loading process
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. If not provided or empty, only file commands are available.
* @returns A Promise that resolves to an array of SlashCommand objects
*/
export const getAvailableCommands = async (
config: Config,
settings: LoadedSettings,
abortSignal: AbortSignal,
allowedBuiltinCommandNames?: string[],
): Promise<SlashCommand[]> => {
try {
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
: [new FileCommandLoader(config)];
const commandService = await CommandService.create(loaders, abortSignal);
const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
commands,
allowedBuiltinSet,
);
// Filter out hidden commands
return filteredCommands.filter((cmd) => !cmd.hidden);
} catch (error) {
// Handle errors gracefully - log and return empty array
console.error('Error loading available commands:', error);
return [];
}
};

View File

@@ -128,6 +128,14 @@ export type AgentRequest = z.infer<typeof agentRequestSchema>;
export type AgentNotification = z.infer<typeof agentNotificationSchema>;
export type AvailableCommandInput = z.infer<typeof availableCommandInputSchema>;
export type AvailableCommand = z.infer<typeof availableCommandSchema>;
export type AvailableCommandsUpdate = z.infer<
typeof availableCommandsUpdateSchema
>;
export const writeTextFileRequestSchema = z.object({
content: z.string(),
path: z.string(),
@@ -386,6 +394,21 @@ export const promptRequestSchema = z.object({
sessionId: z.string(),
});
export const availableCommandInputSchema = z.object({
hint: z.string(),
});
export const availableCommandSchema = z.object({
description: z.string(),
input: availableCommandInputSchema.nullable().optional(),
name: z.string(),
});
export const availableCommandsUpdateSchema = z.object({
availableCommands: z.array(availableCommandSchema),
sessionUpdate: z.literal('available_commands_update'),
});
export const sessionUpdateSchema = z.union([
z.object({
content: contentBlockSchema,
@@ -423,6 +446,7 @@ export const sessionUpdateSchema = z.union([
entries: z.array(planEntrySchema),
sessionUpdate: z.literal('plan'),
}),
availableCommandsUpdateSchema,
]);
export const agentResponseSchema = z.union([

View File

@@ -31,6 +31,7 @@ import {
MCPServerConfig,
ToolConfirmationOutcome,
logToolCall,
logUserPrompt,
getErrorStatus,
isWithinRoot,
isNodeError,
@@ -38,6 +39,7 @@ import {
TaskTool,
Kind,
TodoWriteTool,
UserPromptEvent,
} from '@qwen-code/qwen-code-core';
import * as acp from './acp.js';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -53,6 +55,26 @@ import { ExtensionStorage, type Extension } from '../config/extension.js';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
import {
handleSlashCommand,
getAvailableCommands,
} from '../nonInteractiveCliCommands.js';
import type { AvailableCommand, AvailableCommandsUpdate } from './schema.js';
import { isSlashCommand } from '../ui/utils/commandUtils.js';
/**
* Built-in commands that are allowed in ACP integration mode.
* Only these commands will be available when using handleSlashCommand
* or getAvailableCommands in ACP integration.
*
* Currently, only "init" is supported because `handleSlashCommand` in
* nonInteractiveCliCommands.ts only supports handling results where
* result.type is "submit_prompt". Other result types are either coupled
* to the UI or cannot send notifications to the client via ACP.
*
* If you have a good idea to add support for more commands, PRs are welcome!
*/
const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
/**
* Resolves the model to use based on the current configuration.
@@ -151,7 +173,7 @@ class GeminiAgent {
cwd,
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const sessionId = randomUUID();
const sessionId = this.config.getSessionId() || randomUUID();
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
let isAuthenticated = false;
@@ -182,9 +204,20 @@ class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(sessionId, chat, config, this.client);
const session = new Session(
sessionId,
chat,
config,
this.client,
this.settings,
);
this.sessions.set(sessionId, session);
// Send available commands update as the first session update
setTimeout(async () => {
await session.sendAvailableCommandsUpdate();
}, 0);
return {
sessionId,
};
@@ -242,12 +275,14 @@ class GeminiAgent {
class Session {
private pendingPrompt: AbortController | null = null;
private turn: number = 0;
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly client: acp.Client,
private readonly settings: LoadedSettings,
) {}
async cancelPendingPrompt(): Promise<void> {
@@ -264,10 +299,57 @@ class Session {
const pendingSend = new AbortController();
this.pendingPrompt = pendingSend;
const promptId = Math.random().toString(16).slice(2);
const chat = this.chat;
// Increment turn counter for each user prompt
this.turn += 1;
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
const chat = this.chat;
const promptId = this.config.getSessionId() + '########' + this.turn;
// Extract text from all text blocks to construct the full prompt text for logging
const promptText = params.prompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' ');
// Log user prompt
logUserPrompt(
this.config,
new UserPromptEvent(
promptText.length,
promptId,
this.config.getContentGeneratorConfig()?.authType,
promptText,
),
);
// Check if the input contains a slash command
// Extract text from the first text block if present
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
const inputText = firstTextBlock?.text || '';
let parts: Part[];
if (isSlashCommand(inputText)) {
// Handle slash command - allow specific built-in commands for ACP integration
const slashCommandResult = await handleSlashCommand(
inputText,
pendingSend,
this.config,
this.settings,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
);
if (slashCommandResult) {
// Use the result from the slash command
parts = slashCommandResult as Part[];
} else {
// Slash command didn't return a prompt, continue with normal processing
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
} else {
// Normal processing for non-slash commands
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
let nextMessage: Content | null = { role: 'user', parts };
@@ -361,6 +443,37 @@ class Session {
await this.client.sessionUpdate(params);
}
async sendAvailableCommandsUpdate(): Promise<void> {
const abortController = new AbortController();
try {
const slashCommands = await getAvailableCommands(
this.config,
this.settings,
abortController.signal,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
);
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
const availableCommands: AvailableCommand[] = slashCommands.map(
(cmd) => ({
name: cmd.name,
description: cmd.description,
input: null,
}),
);
const update: AvailableCommandsUpdate = {
sessionUpdate: 'available_commands_update',
availableCommands,
};
await this.sendUpdate(update);
} catch (error) {
// Log error but don't fail session creation
console.error('Error sending available commands update:', error);
}
}
private async runTool(
abortSignal: AbortSignal,
promptId: string,