diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index f0054b397..ddb968d1b 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1184,6 +1184,7 @@ export default { // ============================================================================ 'Waiting for user confirmation...': 'Warten auf Benutzerbestätigung...', '(esc to cancel, {{time}})': '(Esc zum Abbrechen, {{time}})', + '(esc to cancel)': '(Esc zum Abbrechen)', // ============================================================================ // Loading Phrases diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 79af44452..e8154ef5c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1178,6 +1178,7 @@ export default { // ============================================================================ 'Waiting for user confirmation...': 'Waiting for user confirmation...', '(esc to cancel, {{time}})': '(esc to cancel, {{time}})', + '(esc to cancel)': '(esc to cancel)', // ============================================================================ // Loading Phrases diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index a9a27c107..5e0167d9c 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -842,6 +842,7 @@ export default { // Loading 'Waiting for user confirmation...': 'ユーザーの確認を待っています...', '(esc to cancel, {{time}})': '(Esc でキャンセル、{{time}})', + '(esc to cancel)': '(Esc でキャンセル)', // Witty Loading Phrases WITTY_LOADING_PHRASES: [ '運任せで検索中...', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 1f085dfcf..0181584b5 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1203,6 +1203,7 @@ export default { // ============================================================================ 'Waiting for user confirmation...': 'Aguardando confirmação do usuário...', '(esc to cancel, {{time}})': '(esc para cancelar, {{time}})', + '(esc to cancel)': '(esc para cancelar)', WITTY_LOADING_PHRASES: [ 'Estou com sorte', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 2a3ad1385..d18d9401b 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1185,6 +1185,7 @@ export default { 'Waiting for user confirmation...': 'Ожидание подтверждения от пользователя...', '(esc to cancel, {{time}})': '(esc для отмены, {{time}})', + '(esc to cancel)': '(esc для отмены)', // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 10530a4ac..43f57ffd5 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1118,6 +1118,7 @@ export default { // ============================================================================ 'Waiting for user confirmation...': '等待用户确认...', '(esc to cancel, {{time}})': '(按 esc 取消,{{time}})', + '(esc to cancel)': '(按 esc 取消)', WITTY_LOADING_PHRASES: [ // --- 职场搬砖系列 --- '正在努力搬砖,请稍候...', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 8181583f9..110a9e573 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -558,6 +558,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.loadHistory, refreshStatic, toggleVimEnabled, + isProcessing, setIsProcessing, setGeminiMdFileCount, slashCommandActions, diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 8818c42b7..cdd07d45d 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -20,6 +20,7 @@ export const compressCommand: SlashCommand = { action: async (context) => { const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; if (executionMode === 'interactive' && ui.pendingItem) { ui.addItem( @@ -96,6 +97,10 @@ export const compressCommand: SlashCommand = { const compressed = await doCompress(); + if (abortSignal?.aborted) { + return; + } + if (!compressed) { if (executionMode === 'interactive') { ui.addItem( @@ -137,6 +142,10 @@ export const compressCommand: SlashCommand = { content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`, }; } catch (e) { + // If cancelled via ESC, don't show error — cancelSlashCommand already handled UI + if (abortSignal?.aborted) { + return; + } if (executionMode === 'interactive') { ui.addItem( { diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index baba59b6c..40bec554f 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -104,6 +104,15 @@ export const setupGithubCommand: SlashCommand = { ): Promise => { const abortController = new AbortController(); + // If we have a context abort signal (from ESC cancellation), link it to our controller + if (context.abortSignal) { + context.abortSignal.addEventListener( + 'abort', + () => abortController.abort(), + { once: true }, + ); + } + if (!isGitHubRepository()) { throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index de75fadd2..5e84bf53e 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -27,6 +27,7 @@ export const summaryCommand: SlashCommand = { const { config } = context.services; const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; if (!config) { return { @@ -101,7 +102,7 @@ export const summaryCommand: SlashCommand = { }, ], {}, - new AbortController().signal, + abortSignal ?? new AbortController().signal, config.getModel(), ); @@ -197,6 +198,10 @@ export const summaryCommand: SlashCommand = { if (executionMode !== 'interactive') { return; } + // If cancelled via ESC, don't show error — cancelSlashCommand already handled UI + if (abortSignal?.aborted) { + return; + } ui.setPendingItem(null); ui.addItem( { @@ -241,6 +246,9 @@ export const summaryCommand: SlashCommand = { }> => { emitInteractivePending('generating'); const markdownSummary = await generateSummaryMarkdown(history); + if (abortSignal?.aborted) { + throw new DOMException('Summary generation cancelled.', 'AbortError'); + } emitInteractivePending('saving'); const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary); completeInteractive(filePathForDisplay); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6c03ec136..90330e988 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -89,6 +89,8 @@ export interface CommandContext { }; // Flag to indicate if an overwrite has been confirmed overwriteConfirmed?: boolean; + /** Abort signal for cancelling long-running slash command operations via ESC. */ + abortSignal?: AbortSignal; } /** diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 53551c547..babfd6555 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Static } from 'ink'; +import { Box, Static, Text } from 'ink'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { Notifications } from './Notifications.js'; @@ -13,6 +13,8 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; +import { theme } from '../semantic-colors.js'; +import { t } from '../../i18n/index.js'; // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. @@ -25,6 +27,7 @@ export const MainContent = () => { const uiState = useUIState(); const { pendingHistoryItems, + pendingSlashCommandHistoryItems, terminalWidth, mainAreaWidth, staticAreaMaxItemHeight, @@ -72,6 +75,11 @@ export const MainContent = () => { embeddedShellFocused={uiState.embeddedShellFocused} /> ))} + {pendingSlashCommandHistoryItems.length > 0 && ( + + {t('(esc to cancel)')} + + )} diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 59ff06bcf..28e1428fc 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo, useEffect, useState } from 'react'; +import { useCallback, useMemo, useEffect, useRef, useState } from 'react'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { @@ -35,6 +35,7 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; +import { useKeypress } from './useKeypress.js'; import { type ExtensionUpdateAction, type ExtensionUpdateStatus, @@ -90,6 +91,7 @@ export const useSlashCommandProcessor = ( loadHistory: UseHistoryManagerReturn['loadHistory'], refreshStatic: () => void, toggleVimEnabled: () => Promise, + isProcessing: boolean, setIsProcessing: (isProcessing: boolean) => void, setGeminiMdFileCount: (count: number) => void, actions: SlashCommandProcessorActions, @@ -131,6 +133,34 @@ export const useSlashCommandProcessor = ( null, ); + // AbortController for cancelling async slash commands via ESC + const abortControllerRef = useRef(null); + + const cancelSlashCommand = useCallback(() => { + if (!abortControllerRef.current) { + return; + } + abortControllerRef.current.abort(); + setPendingItem(null); + addItem( + { + type: MessageType.INFO, + text: 'Command cancelled.', + }, + Date.now(), + ); + setIsProcessing(false); + }, [addItem, setIsProcessing]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + cancelSlashCommand(); + } + }, + { isActive: isProcessing }, + ); + const pendingHistoryItems = useMemo(() => { const items: HistoryItemWithoutId[] = []; if (pendingItem != null) { @@ -319,6 +349,10 @@ export const useSlashCommandProcessor = ( setIsProcessing(true); + // Create a new AbortController for this command execution + const abortController = new AbortController(); + abortControllerRef.current = abortController; + const userMessageTimestamp = Date.now(); addItemWithRecording( { type: MessageType.USER, text: trimmed }, @@ -352,6 +386,7 @@ export const useSlashCommandProcessor = ( args, }, overwriteConfirmed, + abortSignal: abortController.signal, }; // If a one-time list is provided for a "Proceed" action, temporarily @@ -365,10 +400,27 @@ export const useSlashCommandProcessor = ( ]), }; } - const result = await commandToExecute.action( - fullCommandContext, - args, - ); + // Race the command action against the abort signal so that + // ESC cancellation immediately unblocks the await chain. + // Without this, commands like /compress whose underlying + // operation (tryCompressChat) doesn't accept an AbortSignal + // would keep submitQuery stuck until the operation completes. + const abortPromise = new Promise((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => resolve(undefined), + { once: true }, + ); + }); + const result = await Promise.race([ + commandToExecute.action(fullCommandContext, args), + abortPromise, + ]); + + // If the command was cancelled via ESC while executing, skip result processing + if (abortController.signal.aborted) { + return { type: 'handled' }; + } if (result) { switch (result.type) { @@ -561,6 +613,10 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; } catch (e: unknown) { + // If cancelled via ESC, the cancelSlashCommand callback already handled cleanup + if (abortController.signal.aborted) { + return { type: 'handled' }; + } hasError = true; if (config) { const event = makeSlashCommandEvent({