diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 84ba5ff55..82be72361 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -98,6 +98,14 @@ export class AgentSideConnection implements Client { ); } + /** + * Sends a custom notification to the client. + * Used for extension-specific notifications that are not part of the core ACP protocol. + */ + async sendCustomNotification(method: string, params: T): Promise { + return await this.#connection.sendNotification(method, params); + } + /** * Request permission before running a tool * @@ -374,6 +382,7 @@ export interface Client { ): Promise; sessionUpdate(params: schema.SessionNotification): Promise; authenticateUpdate(params: schema.AuthenticateUpdate): Promise; + sendCustomNotification(method: string, params: T): Promise; writeTextFile( params: schema.WriteTextFileRequest, ): Promise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index f77d43297..d56d196db 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -15,7 +15,6 @@ import { qwenOAuth2Events, MCPServerConfig, SessionService, - buildApiHistoryFromConversation, type Config, type ConversationRecord, type DeviceAuthorizationData, @@ -349,12 +348,20 @@ class GeminiAgent { const sessionId = config.getSessionId(); const geminiClient = config.getGeminiClient(); - const history = conversation - ? buildApiHistoryFromConversation(conversation) - : undefined; - const chat = history - ? await geminiClient.startChat(history) - : await geminiClient.startChat(); + // Use GeminiClient to manage chat lifecycle properly + // This ensures geminiClient.chat is in sync with the session's chat + // + // Note: When loading a session, config.initialize() has already been called + // in newSessionConfig(), which in turn calls geminiClient.initialize(). + // The GeminiClient.initialize() method checks config.getResumedSessionData() + // and automatically loads the conversation history into the chat instance. + // So we only need to initialize if it hasn't been done yet. + if (!geminiClient.isInitialized()) { + await geminiClient.initialize(); + } + + // Now get the chat instance that's managed by GeminiClient + const chat = geminiClient.getChat(); const session = new Session( sessionId, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 1d90ed20b..46ffd6702 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -41,9 +41,11 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { z } from 'zod'; import { getErrorMessage } from '../../utils/errors.js'; +import { normalizePartList } from '../../utils/nonInteractiveHelpers.js'; import { handleSlashCommand, getAvailableCommands, + type NonInteractiveSlashCommandResult, } from '../../nonInteractiveCliCommands.js'; import type { AvailableCommand, @@ -67,7 +69,7 @@ import { SubAgentTracker } from './SubAgentTracker.js'; * Built-in commands that are allowed in ACP integration mode. * Only safe, read-only commands that don't require interactive UI. */ -export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init']; +export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init', 'summary', 'compress']; /** * Session represents an active conversation session with the AI model. @@ -167,7 +169,7 @@ export class Session implements SessionContext { const firstTextBlock = params.prompt.find((block) => block.type === 'text'); const inputText = firstTextBlock?.text || ''; - let parts: Part[]; + let parts: Part[] | null; if (isSlashCommand(inputText)) { // Handle slash command - allow specific built-in commands for ACP integration @@ -179,12 +181,15 @@ export class Session implements SessionContext { 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); + parts = await this.#processSlashCommandResult( + slashCommandResult, + params.prompt, + ); + + // If parts is null, the command was fully handled (e.g., /summary completed) + // Return early without sending to the model + if (parts === null) { + return { stopReason: 'end_turn' }; } } else { // Normal processing for non-slash commands @@ -647,6 +652,103 @@ export class Session implements SessionContext { } } + /** + * Processes the result of a slash command execution. + * + * Supported result types in ACP mode: + * - submit_prompt: Submits content to the model + * - stream_messages: Streams multiple messages to the client (ACP-specific) + * - unsupported: Command cannot be executed in ACP mode + * - no_command: No command was found, use original prompt + * + * Note: 'message' type is not supported in ACP mode - commands should use + * 'stream_messages' instead for consistent async handling. + * + * @param result The result from handleSlashCommand + * @param originalPrompt The original prompt blocks + * @returns Parts to use for the prompt, or null if command was handled without needing model interaction + */ + async #processSlashCommandResult( + result: NonInteractiveSlashCommandResult, + originalPrompt: acp.ContentBlock[], + ): Promise { + switch (result.type) { + case 'submit_prompt': + // Command wants to submit a prompt to the model + // Convert PartListUnion to Part[] + return normalizePartList(result.content); + + case 'message': { + // 'message' type is not ideal for ACP mode, but we handle it for compatibility + // by converting it to a stream_messages-like notification + await this.client.sendCustomNotification('_qwencode/slash_command', { + sessionId: this.sessionId, + command: originalPrompt + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(' '), + messageType: result.messageType, + message: result.content || '', + }); + + if (result.messageType === 'error') { + // Throw error to stop execution + throw new Error(result.content || 'Slash command failed.'); + } + // For info messages, return null to indicate command was handled + return null; + } + + case 'stream_messages': { + // Command returns multiple messages via async generator (ACP-preferred) + const command = originalPrompt + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(' '); + + // Stream all messages to the client + for await (const msg of result.messages) { + await this.client.sendCustomNotification('_qwencode/slash_command', { + sessionId: this.sessionId, + command, + messageType: msg.messageType, + message: msg.content, + }); + + // If we encounter an error message, throw after sending + if (msg.messageType === 'error') { + throw new Error(msg.content || 'Slash command failed.'); + } + } + + // All messages sent successfully, return null to indicate command was handled + return null; + } + + case 'unsupported': { + // Command returned an unsupported result type + const unsupportedError = `Slash command not supported in ACP integration: ${result.reason}`; + throw new Error(unsupportedError); + } + + case 'no_command': + // No command was found or executed, use original prompt + return originalPrompt.map((block) => { + if (block.type === 'text') { + return { text: block.text }; + } + throw new Error(`Unsupported block type: ${block.type}`); + }); + + default: { + // Exhaustiveness check + const _exhaustive: never = result; + const unknownError = `Unknown slash command result type: ${(_exhaustive as NonInteractiveSlashCommandResult).type}`; + throw new Error(unknownError); + } + } + } + async #resolvePrompt( message: acp.ContentBlock[], abortSignal: AbortSignal, diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 2682c8fcb..1ce425f42 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -590,6 +590,12 @@ export default { 'No conversation found to summarize.': 'No conversation found to summarize.', 'Failed to generate project context summary: {{error}}': 'Failed to generate project context summary: {{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + 'Saved project summary to {{filePathForDisplay}}.', + 'Saving project summary...': 'Saving project summary...', + 'Generating project summary...': 'Generating project summary...', + 'Failed to generate summary - no text content received from LLM response': + 'Failed to generate summary - no text content received from LLM response', // ============================================================================ // Commands - Model diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 009578be6..5978eef3a 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -604,6 +604,12 @@ export default { 'Не найдено диалогов для создания сводки.', 'Failed to generate project context summary: {{error}}': 'Не удалось сгенерировать сводку контекста проекта: {{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + 'Сводка проекта сохранена в {{filePathForDisplay}}', + 'Saving project summary...': 'Сохранение сводки проекта...', + 'Generating project summary...': 'Генерация сводки проекта...', + 'Failed to generate summary - no text content received from LLM response': + 'Не удалось сгенерировать сводку - не получен текстовый контент из ответа LLM', // ============================================================================ // Команды - Модель diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index b8f49a776..8798079c9 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -560,6 +560,12 @@ export default { 'No conversation found to summarize.': '未找到要总结的对话', 'Failed to generate project context summary: {{error}}': '生成项目上下文摘要失败:{{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + '项目摘要已保存到 {{filePathForDisplay}}', + 'Saving project summary...': '正在保存项目摘要...', + 'Generating project summary...': '正在生成项目摘要...', + 'Failed to generate summary - no text content received from LLM response': + '生成摘要失败 - 未从 LLM 响应中接收到文本内容', // ============================================================================ // Commands - Model diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 1719a0532..ae8a39992 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -42,6 +42,61 @@ import { computeUsageFromMetrics, } from './utils/nonInteractiveHelpers.js'; +const ALLOWED_BUILTIN_COMMANDS_FOR_NON_INTERACTIVE = [ + 'init', + 'summary', + 'compress', +]; + +/** + * Emits a final message for slash command results. + * Note: systemMessage should already be emitted before calling this function. + */ +async function emitNonInteractiveFinalMessage(params: { + message: string; + isError: boolean; + adapter?: JsonOutputAdapterInterface; + config: Config; + startTimeMs: number; +}): Promise { + const { message, isError, adapter, config } = params; + + if (!adapter) { + // Text output mode: write directly to stdout/stderr + const target = isError ? process.stderr : process.stdout; + target.write(`${message}\n`); + return; + } + + // JSON output mode: emit assistant message and result + // (systemMessage should already be emitted by caller) + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: message, + } as unknown as Parameters[0]); + adapter.finalizeAssistantMessage(); + + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + const outputFormat = config.getOutputFormat(); + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + + adapter.emitResult({ + isError, + durationMs: Date.now() - params.startTimeMs, + apiDurationMs: 0, + numTurns: 0, + errorMessage: isError ? message : undefined, + usage, + stats, + summary: message, + }); +} + /** * Provides optional overrides for `runNonInteractive` execution. * @@ -115,6 +170,16 @@ export async function runNonInteractive( process.on('SIGINT', shutdownHandler); process.on('SIGTERM', shutdownHandler); + // Emit systemMessage first (always the first message in JSON mode) + if (adapter) { + const systemMessage = await buildSystemMessage( + config, + sessionId, + permissionMode, + ); + adapter.emitMessage(systemMessage); + } + let initialPartList: PartListUnion | null = extractPartsFromUserMessage( options.userMessage, ); @@ -127,11 +192,41 @@ export async function runNonInteractive( abortController, config, settings, + ALLOWED_BUILTIN_COMMANDS_FOR_NON_INTERACTIVE, ); - if (slashCommandResult) { - // A slash command can replace the prompt entirely; fall back to @-command processing otherwise. - initialPartList = slashCommandResult as PartListUnion; - slashHandled = true; + switch (slashCommandResult.type) { + case 'submit_prompt': + // A slash command can replace the prompt entirely; fall back to @-command processing otherwise. + initialPartList = slashCommandResult.content; + slashHandled = true; + break; + case 'message': { + // systemMessage already emitted above + await emitNonInteractiveFinalMessage({ + message: slashCommandResult.content, + isError: slashCommandResult.messageType === 'error', + adapter, + config, + startTimeMs: startTime, + }); + return; + } + case 'stream_messages': + // ACP exclusive - should not reach here in non-interactive mode + throw new FatalInputError( + 'Stream messages mode is not supported in non-interactive CLI', + ); + break; + case 'unsupported': + throw new FatalInputError(slashCommandResult.reason); + case 'no_command': + break; + default: { + const _exhaustive: never = slashCommandResult; + throw new FatalInputError( + `Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`, + ); + } } } @@ -163,15 +258,6 @@ export async function runNonInteractive( const initialParts = normalizePartList(initialPartList); let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; - if (adapter) { - const systemMessage = await buildSystemMessage( - config, - sessionId, - permissionMode, - ); - adapter.emitMessage(systemMessage); - } - let isFirstTurn = true; while (true) { turnCount++; diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 77b9d0999..711307412 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -7,7 +7,6 @@ import type { PartListUnion } from '@google/genai'; import { parseSlashCommand } from './utils/commands.js'; import { - FatalInputError, Logger, uiTelemetryService, type Config, @@ -19,11 +18,151 @@ import { CommandKind, type CommandContext, type SlashCommand, + type SlashCommandActionReturn, } 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'; +/** + * Result of handling a slash command in non-interactive mode. + * + * Supported types: + * - 'submit_prompt': Submits content to the model (supports all modes) + * - 'message': Returns a single message (supports non-interactive JSON/text only) + * - 'stream_messages': Streams multiple messages (supports ACP only) + * - 'unsupported': Command cannot be executed in this mode + * - 'no_command': No command was found or executed + */ +export type NonInteractiveSlashCommandResult = + | { + type: 'submit_prompt'; + content: PartListUnion; + } + | { + type: 'message'; + messageType: 'info' | 'error'; + content: string; + } + | { + type: 'stream_messages'; + messages: AsyncGenerator< + { messageType: 'info' | 'error'; content: string }, + void, + unknown + >; + } + | { + type: 'unsupported'; + reason: string; + originalType: string; + } + | { + type: 'no_command'; + }; + +/** + * Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult. + * + * Only the following result types are supported in non-interactive mode: + * - submit_prompt: Submits content to the model (all modes) + * - message: Returns a single message (non-interactive JSON/text only) + * - stream_messages: Streams multiple messages (ACP only) + * + * All other result types are converted to 'unsupported'. + * + * @param result The result from executing a slash command action + * @returns A NonInteractiveSlashCommandResult describing the outcome + */ +function handleCommandResult( + result: SlashCommandActionReturn, +): NonInteractiveSlashCommandResult { + switch (result.type) { + case 'submit_prompt': + return { + type: 'submit_prompt', + content: result.content, + }; + + case 'message': + return { + type: 'message', + messageType: result.messageType, + content: result.content, + }; + + case 'stream_messages': + return { + type: 'stream_messages', + messages: result.messages, + }; + + // + /** + * Currently return types below are never generated due to the + * whitelist of allowed slash commands in ACP and non-interactive mode. + * We'll try to add more supported return types in the future. + */ + + case 'tool': + return { + type: 'unsupported', + reason: + 'Tool execution from slash commands is not supported in non-interactive mode.', + originalType: 'tool', + }; + + case 'quit': + return { + type: 'unsupported', + reason: + 'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.', + originalType: 'quit', + }; + + case 'dialog': + return { + type: 'unsupported', + reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`, + originalType: 'dialog', + }; + + case 'load_history': + return { + type: 'unsupported', + reason: + 'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.', + originalType: 'load_history', + }; + + case 'confirm_shell_commands': + return { + type: 'unsupported', + reason: + 'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.', + originalType: 'confirm_shell_commands', + }; + + case 'confirm_action': + return { + type: 'unsupported', + reason: + 'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.', + originalType: 'confirm_action', + }; + + default: { + // Exhaustiveness check + const _exhaustive: never = result; + return { + type: 'unsupported', + reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`, + originalType: 'unknown', + }; + } + } +} + /** * Filters commands based on the allowed built-in command names. * @@ -63,10 +202,8 @@ function filterCommandsForNonInteractive( * @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 - * non-interactive mode. + * @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing + * the outcome of the command execution. */ export const handleSlashCommand = async ( rawQuery: string, @@ -74,12 +211,21 @@ export const handleSlashCommand = async ( config: Config, settings: LoadedSettings, allowedBuiltinCommandNames?: string[], -): Promise => { +): Promise => { const trimmed = rawQuery.trim(); if (!trimmed.startsWith('/')) { - return; + return { type: 'no_command' }; } + const isAcpMode = config.getExperimentalZedIntegration(); + const isInteractive = config.isInteractive(); + + const executionMode = isAcpMode + ? 'acp' + : isInteractive + ? 'interactive' + : 'non_interactive'; + const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []); // Only load BuiltinCommandLoader if there are allowed built-in commands @@ -103,64 +249,58 @@ export const handleSlashCommand = async ( filteredCommands, ); - if (commandToExecute) { - if (commandToExecute.action) { - // Not used by custom commands but may be in the future. - const sessionStats: SessionStatsState = { - sessionId: config?.getSessionId(), - sessionStartTime: new Date(), - metrics: uiTelemetryService.getMetrics(), - lastPromptTokenCount: 0, - promptCount: 1, - }; - - const logger = new Logger(config?.getSessionId() || '', config?.storage); - - const context: CommandContext = { - services: { - config, - settings, - git: undefined, - logger, - }, - ui: createNonInteractiveUI(), - session: { - stats: sessionStats, - sessionShellAllowlist: new Set(), - }, - invocation: { - raw: trimmed, - name: commandToExecute.name, - args, - }, - }; - - const result = await commandToExecute.action(context, args); - - if (result) { - switch (result.type) { - case 'submit_prompt': - return result.content; - case 'confirm_shell_commands': - // This result indicates a command attempted to confirm shell commands. - // However note that currently, ShellTool is excluded in non-interactive - // mode unless 'YOLO mode' is active, so confirmation actually won't - // occur because of YOLO mode. - // This ensures that if a command *does* request confirmation (e.g. - // in the future with more granular permissions), it's handled appropriately. - throw new FatalInputError( - 'Exiting due to a confirmation prompt requested by the command.', - ); - default: - throw new FatalInputError( - 'Exiting due to command result that is not supported in non-interactive mode.', - ); - } - } - } + if (!commandToExecute) { + return { type: 'no_command' }; } - return; + if (!commandToExecute.action) { + return { type: 'no_command' }; + } + + // Not used by custom commands but may be in the future. + const sessionStats: SessionStatsState = { + sessionId: config?.getSessionId(), + sessionStartTime: new Date(), + metrics: uiTelemetryService.getMetrics(), + lastPromptTokenCount: 0, + promptCount: 1, + }; + + const logger = new Logger(config?.getSessionId() || '', config?.storage); + + const context: CommandContext = { + executionMode, + services: { + config, + settings, + git: undefined, + logger, + }, + ui: createNonInteractiveUI(), + session: { + stats: sessionStats, + sessionShellAllowlist: new Set(), + }, + invocation: { + raw: trimmed, + name: commandToExecute.name, + args, + }, + }; + + const result = await commandToExecute.action(context, args); + + if (!result) { + // Command executed but returned no result (e.g., void return) + return { + type: 'message', + messageType: 'info', + content: 'Command executed successfully.', + }; + } + + // Handle different result types + return handleCommandResult(result); }; /** diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 399bfa617..8818c42b7 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -19,7 +19,9 @@ export const compressCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: async (context) => { const { ui } = context; - if (ui.pendingItem) { + const executionMode = context.executionMode ?? 'interactive'; + + if (executionMode === 'interactive' && ui.pendingItem) { ui.addItem( { type: MessageType.ERROR, @@ -40,13 +42,80 @@ export const compressCommand: SlashCommand = { }, }; - try { - ui.setPendingItem(pendingMessage); + const config = context.services.config; + const geminiClient = config?.getGeminiClient(); + if (!config || !geminiClient) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const doCompress = async () => { const promptId = `compress-${Date.now()}`; - const compressed = await context.services.config - ?.getGeminiClient() - ?.tryCompressChat(promptId, true); - if (compressed) { + return await geminiClient.tryCompressChat(promptId, true); + }; + + if (executionMode === 'acp') { + const messages = async function* () { + try { + yield { + messageType: 'info' as const, + content: 'Compressing context...', + }; + const compressed = await doCompress(); + if (!compressed) { + yield { + messageType: 'error' as const, + content: t('Failed to compress chat history.'), + }; + return; + } + yield { + messageType: 'info' as const, + content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`, + }; + } catch (e) { + yield { + messageType: 'error' as const, + content: t('Failed to compress chat history: {{error}}', { + error: e instanceof Error ? e.message : String(e), + }), + }; + } + }; + + return { type: 'stream_messages', messages: messages() }; + } + + try { + if (executionMode === 'interactive') { + ui.setPendingItem(pendingMessage); + } + + const compressed = await doCompress(); + + if (!compressed) { + if (executionMode === 'interactive') { + ui.addItem( + { + type: MessageType.ERROR, + text: t('Failed to compress chat history.'), + }, + Date.now(), + ); + return; + } + + return { + type: 'message', + messageType: 'error', + content: t('Failed to compress chat history.'), + }; + } + + if (executionMode === 'interactive') { ui.addItem( { type: MessageType.COMPRESSION, @@ -59,27 +128,39 @@ export const compressCommand: SlashCommand = { } as HistoryItemCompression, Date.now(), ); - } else { + return; + } + + return { + type: 'message', + messageType: 'info', + content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`, + }; + } catch (e) { + if (executionMode === 'interactive') { ui.addItem( { type: MessageType.ERROR, - text: t('Failed to compress chat history.'), + text: t('Failed to compress chat history: {{error}}', { + error: e instanceof Error ? e.message : String(e), + }), }, Date.now(), ); + return; } - } catch (e) { - ui.addItem( - { - type: MessageType.ERROR, - text: t('Failed to compress chat history: {{error}}', { - error: e instanceof Error ? e.message : String(e), - }), - }, - Date.now(), - ); + + return { + type: 'message', + messageType: 'error', + content: t('Failed to compress chat history: {{error}}', { + error: e instanceof Error ? e.message : String(e), + }), + }; } finally { - ui.setPendingItem(null); + if (executionMode === 'interactive') { + ui.setPendingItem(null); + } } }, }; diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 5d943e8ea..0aea709b7 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -26,6 +26,8 @@ export const summaryCommand: SlashCommand = { action: async (context): Promise => { const { config } = context.services; const { ui } = context; + const executionMode = context.executionMode ?? 'interactive'; + if (!config) { return { type: 'message', @@ -43,8 +45,8 @@ export const summaryCommand: SlashCommand = { }; } - // Check if already generating summary - if (ui.pendingItem) { + // Check if already generating summary (interactive UI only) + if (executionMode === 'interactive' && ui.pendingItem) { ui.addItem( { type: 'error' as const, @@ -63,29 +65,15 @@ export const summaryCommand: SlashCommand = { }; } - try { + const generateSummaryMarkdown = async (): Promise => { // Get the current chat history const chat = geminiClient.getChat(); const history = chat.getHistory(); if (history.length <= 2) { - return { - type: 'message', - messageType: 'info', - content: t('No conversation found to summarize.'), - }; + throw new Error(t('No conversation found to summarize.')); } - // Show loading state - const pendingMessage: HistoryItemSummary = { - type: 'summary', - summary: { - isPending: true, - stage: 'generating', - }, - }; - ui.setPendingItem(pendingMessage); - // Build the conversation context for summary generation const conversationContext = history.map((message) => ({ role: message.role, @@ -121,19 +109,21 @@ export const summaryCommand: SlashCommand = { if (!markdownSummary) { throw new Error( - 'Failed to generate summary - no text content received from LLM response', + t( + 'Failed to generate summary - no text content received from LLM response', + ), ); } - // Update loading message to show saving progress - ui.setPendingItem({ - type: 'summary', - summary: { - isPending: true, - stage: 'saving', - }, - }); + return markdownSummary; + }; + const saveSummaryToDisk = async ( + markdownSummary: string, + ): Promise<{ + filePathForDisplay: string; + fullPath: string; + }> => { // Ensure .qwen directory exists const projectRoot = config.getProjectRoot(); const qwenDir = path.join(projectRoot, '.qwen'); @@ -155,25 +145,46 @@ export const summaryCommand: SlashCommand = { await fsPromises.writeFile(summaryPath, summaryContent, 'utf8'); - // Clear pending item and show success message + return { + filePathForDisplay: '.qwen/PROJECT_SUMMARY.md', + fullPath: summaryPath, + }; + }; + + const emitInteractivePending = (stage: 'generating' | 'saving') => { + if (executionMode !== 'interactive') { + return; + } + const pendingMessage: HistoryItemSummary = { + type: 'summary', + summary: { + isPending: true, + stage, + }, + }; + ui.setPendingItem(pendingMessage); + }; + + const completeInteractive = (filePathForDisplay: string) => { + if (executionMode !== 'interactive') { + return; + } ui.setPendingItem(null); const completedSummaryItem: HistoryItemSummary = { type: 'summary', summary: { isPending: false, stage: 'completed', - filePath: '.qwen/PROJECT_SUMMARY.md', + filePath: filePathForDisplay, }, }; ui.addItem(completedSummaryItem, Date.now()); + }; - return { - type: 'message', - messageType: 'info', - content: '', // Empty content since we show the message in UI component - }; - } catch (error) { - // Clear pending item on error + const failInteractive = (error: unknown) => { + if (executionMode !== 'interactive') { + return; + } ui.setPendingItem(null); ui.addItem( { @@ -187,6 +198,96 @@ export const summaryCommand: SlashCommand = { }, Date.now(), ); + }; + + if (executionMode === 'acp') { + const messages = async function* () { + try { + emitInteractivePending('generating'); + yield { + messageType: 'info' as const, + content: t('Generating project summary...'), + }; + + const markdownSummary = await generateSummaryMarkdown(); + + yield { + messageType: 'info' as const, + content: t('Saving project summary...'), + }; + const { filePathForDisplay } = + await saveSummaryToDisk(markdownSummary); + + completeInteractive(filePathForDisplay); + yield { + messageType: 'info' as const, + content: t('Saved project summary to {{filePathForDisplay}}.', { + filePathForDisplay, + }), + }; + } catch (error) { + failInteractive(error); + yield { + messageType: 'error' as const, + content: t( + 'Failed to generate project context summary: {{error}}', + { + error: error instanceof Error ? error.message : String(error), + }, + ), + }; + } + }; + + return { + type: 'stream_messages', + messages: messages(), + }; + } + + try { + emitInteractivePending('generating'); + const markdownSummary = await generateSummaryMarkdown(); + emitInteractivePending('saving'); + const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary); + completeInteractive(filePathForDisplay); + + if (executionMode === 'non_interactive') { + return { + type: 'message', + messageType: 'info', + content: `Saved project summary to ${filePathForDisplay}.`, + }; + } + + // Interactive mode: UI components already display progress and completion. + return { + type: 'message', + messageType: 'info', + content: '', + }; + } catch (error) { + // Convert "no conversation" into a clean info message for non-interactive / interactive modes. + const msg = + error instanceof Error ? error.message : t('Unknown error occurred.'); + + if (msg === t('No conversation found to summarize.')) { + if (executionMode === 'interactive') { + // Keep interactive behavior: show as a normal message. + return { + type: 'message', + messageType: 'info', + content: msg, + }; + } + return { + type: 'message', + messageType: 'info', + content: msg, + }; + } + + failInteractive(error); return { type: 'message', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 8bcc872f0..0762e8b9c 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -22,6 +22,14 @@ import type { // Grouped dependencies for clarity and easier mocking export interface CommandContext { + /** + * Execution mode for the current invocation. + * + * - interactive: React/Ink UI mode + * - non_interactive: non-interactive CLI mode (text/json) + * - acp: ACP/Zed integration mode + */ + executionMode?: 'interactive' | 'non_interactive' | 'acp'; // Invocation properties for when commands are called. invocation?: { /** The raw, untrimmed input string from the user. */ @@ -108,6 +116,19 @@ export interface MessageActionReturn { content: string; } +/** + * The return type for a command action that streams multiple messages. + * Used for long-running operations that need to send progress updates. + */ +export interface StreamMessagesActionReturn { + type: 'stream_messages'; + messages: AsyncGenerator< + { messageType: 'info' | 'error'; content: string }, + void, + unknown + >; +} + /** * The return type for a command action that needs to open a dialog. */ @@ -174,6 +195,7 @@ export interface ConfirmActionReturn { export type SlashCommandActionReturn = | ToolActionReturn | MessageActionReturn + | StreamMessagesActionReturn | QuitActionReturn | OpenDialogActionReturn | LoadHistoryActionReturn diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index ac762904e..ba2b53fc5 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -520,6 +520,13 @@ export const useSlashCommandProcessor = ( true, ); } + case 'stream_messages': { + // stream_messages is only used in ACP/Zed integration mode + // and should not be returned in interactive UI mode + throw new Error( + 'stream_messages result type is not supported in interactive mode', + ); + } default: { const unhandled: never = result; throw new Error(