diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 1aad835e..066b1848 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -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]); diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 166a1706..77b9d099 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -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, +): 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 => { 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 => { + 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 []; + } +}; diff --git a/packages/cli/src/zed-integration/schema.ts b/packages/cli/src/zed-integration/schema.ts index b35cc47d..e5f72b50 100644 --- a/packages/cli/src/zed-integration/schema.ts +++ b/packages/cli/src/zed-integration/schema.ts @@ -128,6 +128,14 @@ export type AgentRequest = z.infer; export type AgentNotification = z.infer; +export type AvailableCommandInput = z.infer; + +export type AvailableCommand = z.infer; + +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([ diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 4a01ed7e..d83395f2 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -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 { - 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 { @@ -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 { + 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,