diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index bc43cc7a20..a6b5477567 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -30,6 +30,7 @@ import { } from '@google/gemini-cli-core'; import type { Content, Part } from '@google/genai'; +import readline from 'node:readline'; import { handleSlashCommand } from './nonInteractiveCliCommands.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; @@ -82,9 +83,95 @@ export async function runNonInteractive({ ? new StreamJsonFormatter() : null; + const abortController = new AbortController(); + + // Track cancellation state + let isAborting = false; + let cancelMessageTimer: NodeJS.Timeout | null = null; + + // Setup stdin listener for Ctrl+C detection + let stdinWasRaw = false; + let rl: readline.Interface | null = null; + + const setupStdinCancellation = () => { + // Only setup if stdin is a TTY (user can interact) + if (!process.stdin.isTTY) { + return; + } + + // Save original raw mode state + stdinWasRaw = process.stdin.isRaw || false; + + // Enable raw mode to capture individual keypresses + process.stdin.setRawMode(true); + process.stdin.resume(); + + // Setup readline to emit keypress events + rl = readline.createInterface({ + input: process.stdin, + escapeCodeTimeout: 0, + }); + readline.emitKeypressEvents(process.stdin, rl); + + // Listen for Ctrl+C + const keypressHandler = ( + str: string, + key: { name?: string; ctrl?: boolean }, + ) => { + // Detect Ctrl+C: either ctrl+c key combo or raw character code 3 + if ((key && key.ctrl && key.name === 'c') || str === '\u0003') { + // Only handle once + if (isAborting) { + return; + } + + isAborting = true; + + // Only show message if cancellation takes longer than 200ms + // This reduces verbosity for fast cancellations + cancelMessageTimer = setTimeout(() => { + process.stderr.write('\nCancelling...\n'); + }, 200); + + abortController.abort(); + // Note: Don't exit here - let the abort flow through the system + // and trigger handleCancellationError() which will exit with proper code + } + }; + + process.stdin.on('keypress', keypressHandler); + }; + + const cleanupStdinCancellation = () => { + // Clear any pending cancel message timer + if (cancelMessageTimer) { + clearTimeout(cancelMessageTimer); + cancelMessageTimer = null; + } + + // Cleanup readline and stdin listeners + if (rl) { + rl.close(); + rl = null; + } + + // Remove keypress listener + process.stdin.removeAllListeners('keypress'); + + // Restore stdin to original state + if (process.stdin.isTTY) { + process.stdin.setRawMode(stdinWasRaw); + process.stdin.pause(); + } + }; + let errorToHandle: unknown | undefined; try { consolePatcher.patch(); + + // Setup stdin cancellation listener + setupStdinCancellation(); + coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); coreEvents.drainFeedbackBacklog(); @@ -108,8 +195,6 @@ export async function runNonInteractive({ }); } - const abortController = new AbortController(); - let query: Part[] | undefined; if (isSlashCommand(input)) { @@ -336,6 +421,9 @@ export async function runNonInteractive({ } catch (error) { errorToHandle = error; } finally { + // Cleanup stdin cancellation before other cleanup + cleanupStdinCancellation(); + consolePatcher.cleanup(); coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); if (isTelemetrySdkInitialized()) {