diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b106f524a..2f30a72ef 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -63,6 +63,10 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getCliVersion } from './utils/version.js'; import { writeStderrLine } from './utils/stdioHelpers.js'; import { computeWindowTitle } from './utils/windowTitle.js'; +import { + startEarlyInputCapture, + stopAndGetCapturedInput, +} from './utils/earlyInputCapture.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; @@ -204,6 +208,11 @@ export async function startInteractiveUI( } } + // Drain the early-captured input exactly once, before any React rendering. + // Must be outside any component/effect so StrictMode's mount/cleanup/remount + // always reads from the same stable prop rather than the (now empty) module buffer. + const initialCapturedInput = stopAndGetCapturedInput(); + // Create wrapper component to use hooks inside render const AppWrapper = () => { const kittyProtocolStatus = useKittyKeyboardProtocol(); @@ -221,6 +230,7 @@ export async function startInteractiveUI( pasteWorkaround={ process.platform === 'win32' || nodeMajorVersion < 20 } + initialCapturedInput={initialCapturedInput} > @@ -465,6 +475,11 @@ export async function main() { // input showing up in the output. process.stdin.setRawMode(true); + // Startup optimization: start early input capture + startEarlyInputCapture(); + // Ensure the stdin listener is removed on any exit path (error, signal, etc.) + registerCleanup(() => stopAndGetCapturedInput()); + // This cleanup isn't strictly needed but may help in certain situations. process.on('SIGTERM', () => { process.stdin.setRawMode(wasRaw); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index a2ee13c29..06c16d6ea 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -137,12 +137,14 @@ export function KeypressProvider({ pasteWorkaround = false, config, debugKeystrokeLogging, + initialCapturedInput, }: { children?: React.ReactNode; kittyProtocolEnabled: boolean; pasteWorkaround?: boolean; config?: Config; debugKeystrokeLogging?: boolean; + initialCapturedInput?: Buffer; }) { const { stdin, setRawMode } = useStdin(); const subscribers = useRef>(new Set()).current; @@ -167,6 +169,11 @@ export function KeypressProvider({ setRawMode(true); } + // Use pre-drained captured input passed from outside React. + // Draining happens before render() so StrictMode's mount/cleanup/remount + // always reads from the stable prop reference, not the (already empty) module buffer. + const capturedInput = initialCapturedInput ?? Buffer.alloc(0); + const keypressStream = new PassThrough(); let usePassthrough = false; // Use passthrough mode when pasteWorkaround is enabled, @@ -1102,7 +1109,30 @@ export function KeypressProvider({ stdin.on('keypress', handleKeypress); } + // Startup optimization: replay captured input if available + let replayPending = false; + if (capturedInput.length > 0) { + debugLogger.debug( + `Replaying ${capturedInput.length} bytes of captured input`, + ); + // Process in next event loop tick to ensure subscribers are ready. + // Always emit on stdin so that handleRawKeypress processes paste markers + // correctly in passthrough mode. + // In non-passthrough mode, readline.emitKeypressEvents installs an internal + // 'data' listener on stdin that converts data events to keypress events. + replayPending = true; + setImmediate(() => { + if (!replayPending) return; + try { + stdin.emit('data', capturedInput); + } catch (err) { + debugLogger.error('Failed to replay captured input:', err); + } + }); + } + return () => { + replayPending = false; if (usePassthrough) { keypressStream.removeListener('keypress', handleKeypress); stdin.removeListener('data', handleRawKeypress); @@ -1151,6 +1181,7 @@ export function KeypressProvider({ pasteWorkaround, config, subscribers, + initialCapturedInput, ]); return ( diff --git a/packages/cli/src/utils/earlyInputCapture.test.ts b/packages/cli/src/utils/earlyInputCapture.test.ts new file mode 100644 index 000000000..21431edc1 --- /dev/null +++ b/packages/cli/src/utils/earlyInputCapture.test.ts @@ -0,0 +1,349 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + startEarlyInputCapture, + stopEarlyInputCapture, + getAndClearCapturedInput, + stopAndGetCapturedInput, + hasCapturedInput, + resetCaptureState, +} from './earlyInputCapture.js'; +import { PassThrough } from 'node:stream'; + +describe('earlyInputCapture', () => { + let mockStdin: PassThrough; + let originalStdin: typeof process.stdin; + let originalIsTTY: boolean; + + beforeEach(() => { + resetCaptureState(); + + // Save original stdin + originalStdin = process.stdin; + originalIsTTY = process.stdin.isTTY ?? false; + + // Create mock stdin + mockStdin = new PassThrough(); + Object.defineProperty(process, 'stdin', { + value: mockStdin, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + + delete process.env['QWEN_CODE_DISABLE_EARLY_CAPTURE']; + }); + + afterEach(() => { + resetCaptureState(); + + // Restore original stdin + Object.defineProperty(process, 'stdin', { + value: originalStdin, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + writable: true, + configurable: true, + }); + }); + + describe('capture lifecycle', () => { + it('should start and stop capture correctly', () => { + startEarlyInputCapture(); + expect(hasCapturedInput()).toBe(false); + + mockStdin.write(Buffer.from('a')); + expect(hasCapturedInput()).toBe(true); + + stopEarlyInputCapture(); + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('a'); + }); + + it('should not capture after stop', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('a')); + stopEarlyInputCapture(); + mockStdin.write(Buffer.from('b')); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('a'); + }); + + it('should not start capture if not TTY', () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false }); + startEarlyInputCapture(); + mockStdin.write(Buffer.from('a')); + stopEarlyInputCapture(); + + expect(hasCapturedInput()).toBe(false); + }); + + it('should not start capture twice', () => { + startEarlyInputCapture(); + startEarlyInputCapture(); // Second call should be ignored + + mockStdin.write(Buffer.from('a')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('a'); + }); + + it('stopAndGetCapturedInput should atomically stop and return input', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('hello')); + + const input = stopAndGetCapturedInput(); + expect(input.toString()).toBe('hello'); + + // Further writes should not be captured + mockStdin.write(Buffer.from('world')); + expect(hasCapturedInput()).toBe(false); + }); + }); + + describe('terminal response filtering', () => { + it('should filter DEC private mode responses (ESC [ ?)', () => { + startEarlyInputCapture(); + // DEC private mode response: ESC [ ? 1 0 0 4 h + mockStdin.write(Buffer.from('\x1b[?1004h')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should filter DA2 responses (ESC [ >)', () => { + startEarlyInputCapture(); + // DA2 response: ESC [ > 0 ; 9 5 ; 0 c + mockStdin.write(Buffer.from('\x1b[>0;95;0c')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should filter OSC sequences (ESC ])', () => { + startEarlyInputCapture(); + // OSC sequence: ESC ] 0 ; title BEL + mockStdin.write(Buffer.from('\x1b]0;window title\x07')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should filter DCS sequences (ESC P)', () => { + startEarlyInputCapture(); + // DCS sequence: ESC P ... ST + mockStdin.write(Buffer.from('\x1bP$data\x1b\\')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should keep user input mixed with terminal responses', () => { + startEarlyInputCapture(); + // Mix of user input and terminal response + mockStdin.write(Buffer.from('a\x1b[?1004hb\x1b]0;title\x07c')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('abc'); + }); + + it('should keep arrow key sequences (user input)', () => { + startEarlyInputCapture(); + // Arrow up: ESC [ A (this is a user input, not terminal response) + mockStdin.write(Buffer.from('\x1b[A')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + // Arrow key sequence should be kept (it's user input) + expect(input.toString()).toBe('\x1b[A'); + }); + + it('should keep function key sequences (user input)', () => { + startEarlyInputCapture(); + // F1: ESC O P + mockStdin.write(Buffer.from('\x1bOP')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('\x1bOP'); + }); + + it('should filter terminal responses split across chunks', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b[?1004')); + mockStdin.write(Buffer.from('h')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should keep user input around split terminal responses', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('a\x1b[?100')); + mockStdin.write(Buffer.from('4hbc')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('abc'); + }); + + it('should filter terminal responses split at ESC[ prefix', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b[')); + mockStdin.write(Buffer.from('?1004h')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should filter terminal responses split at ESC prefix', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b')); + mockStdin.write(Buffer.from('[?1004h')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should drop incomplete DEC private response on capture end', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b[?1004')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should drop incomplete OSC sequence on capture end', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b]0;title')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should keep arrow key sequence split across chunks', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b[')); + mockStdin.write(Buffer.from('A')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('\x1b[A'); + }); + + it('should drop incomplete ESC[ prefix on capture end', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b[')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe(''); + }); + + it('should drop standalone ESC on capture end', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe(''); + }); + }); + + describe('UTF-8 handling', () => { + it('should capture simple ASCII characters', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('abc')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('abc'); + }); + + it('should capture UTF-8 multibyte characters', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('你好世界')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('你好世界'); + }); + + it('should capture emoji', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('👋🎉')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('👋🎉'); + }); + }); + + describe('edge cases', () => { + it('should handle empty input', () => { + startEarlyInputCapture(); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.length).toBe(0); + }); + + it('should clear captured input after getAndClearCapturedInput', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('test')); + stopEarlyInputCapture(); + + const input1 = getAndClearCapturedInput(); + expect(input1.toString()).toBe('test'); + + const input2 = getAndClearCapturedInput(); + expect(input2.length).toBe(0); + }); + + it('should skip when QWEN_CODE_DISABLE_EARLY_CAPTURE is set', () => { + process.env['QWEN_CODE_DISABLE_EARLY_CAPTURE'] = '1'; + startEarlyInputCapture(); + mockStdin.write(Buffer.from('a')); + stopEarlyInputCapture(); + + expect(hasCapturedInput()).toBe(false); + }); + + it('should limit buffer size', () => { + startEarlyInputCapture(); + + // Write more than 64KB + const largeData = Buffer.alloc(100 * 1024, 'a'); + mockStdin.write(largeData); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + // Should be truncated to 64KB + expect(input.length).toBeLessThanOrEqual(64 * 1024); + }); + }); +}); diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts new file mode 100644 index 000000000..b9abb3ba6 --- /dev/null +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -0,0 +1,380 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Early Input Capture - Capture user input during REPL initialization + * + * Principle: Start raw mode stdin listening at the earliest CLI entry point, + * then inject buffered content when REPL is ready. Solves the problem of + * user input being lost during startup. + */ + +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('EARLY_INPUT'); + +/** Maximum buffer size (64KB) */ +const MAX_BUFFER_SIZE = 64 * 1024; + +/** + * Input buffer - collects chunks and concatenates on retrieval to avoid O(n^2) copies. + */ +interface InputBuffer { + /** Collected raw byte chunks */ + chunks: Buffer[]; + /** Total bytes across all chunks */ + totalBytes: number; + /** Whether capture is complete */ + captured: boolean; +} + +let inputBuffer: InputBuffer = { + chunks: [], + totalBytes: 0, + captured: false, +}; + +let captureHandler: ((data: Buffer) => void) | null = null; +let captureStdin: NodeJS.ReadStream | null = null; +let isCapturing = false; +let pendingTerminalResponse = Buffer.alloc(0); + +type EscapeSequenceClassification = 'terminal' | 'user' | 'incomplete'; + +/** + * Classify ESC sequences seen during startup capture. + * - terminal: known terminal response/query payloads that should be filtered + * - user: known user key sequences that should be preserved + * - incomplete: prefix too short to classify yet, buffer for next chunk + * + * Note: User input function key sequences should be preserved: + * - ESC [ A/B/C/D - Arrow keys + * - ESC O P/Q/R/S - F1-F4 (SS3 sequences) + * - ESC [ 1;5A - Ctrl+arrow and other modified keys + */ +function classifyEscapeSequence( + data: Buffer, + startIdx: number, +): EscapeSequenceClassification { + if (startIdx >= data.length || data[startIdx] !== 0x1b) { + return 'user'; + } + + const nextIdx = startIdx + 1; + if (nextIdx >= data.length) { + return 'incomplete'; + } + + const nextByte = data[nextIdx]; + + // Check for special characters directly after ESC + // P = 0x50 (DCS), _ = 0x5F (APC), ^ = 0x5E (PM), ] = 0x5D (OSC) + // Note: O = 0x4F is SS3 sequence for function keys, should be preserved + if ( + nextByte === 0x50 || // P (DCS) + nextByte === 0x5f || // _ (APC) + nextByte === 0x5e || // ^ (PM) + nextByte === 0x5d // ] (OSC) + ) { + return 'terminal'; + } + + // Check for terminal responses in CSI sequences + // ESC [ ? ... (DEC private mode response) + // ESC [ > ... (DA2 response) + if (nextByte === 0x5b) { + // CSI sequence, check third character + const thirdIdx = startIdx + 2; + if (thirdIdx >= data.length) { + return 'incomplete'; + } + const thirdByte = data[thirdIdx]; + if (thirdByte === 0x3f || thirdByte === 0x3e) { + // ESC [ ? or ESC [ > - this is a terminal response + return 'terminal'; + } + return 'user'; + } + + return 'user'; +} + +/** + * Skip terminal response sequence + * Returns the index position after skipping + */ +function skipTerminalResponse( + data: Buffer, + startIdx: number, +): { nextIndex: number; complete: boolean } { + if (startIdx >= data.length || data[startIdx] !== 0x1b) { + return { nextIndex: startIdx + 1, complete: true }; + } + + const nextIdx = startIdx + 1; + if (nextIdx >= data.length) { + return { nextIndex: nextIdx, complete: false }; + } + + const nextByte = data[nextIdx]; + + // OSC sequence: ESC ] ... BEL or ESC ] ... ST + if (nextByte === 0x5d) { + let i = startIdx + 2; + while (i < data.length) { + // BEL (0x07) or ST (ESC \) + if (data[i] === 0x07) { + return { nextIndex: i + 1, complete: true }; + } + if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x5c) { + return { nextIndex: i + 2, complete: true }; + } + i++; + } + return { nextIndex: data.length, complete: false }; + } + + // DCS/APC/PM sequences: ESC P/_/^ ... ST + if (nextByte === 0x50 || nextByte === 0x5f || nextByte === 0x5e) { + let i = startIdx + 2; + while (i < data.length) { + // ST (ESC \) + if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x5c) { + return { nextIndex: i + 2, complete: true }; + } + i++; + } + return { nextIndex: data.length, complete: false }; + } + + // CSI sequence: ESC [ ... (ends with 0x40-0x7E) + if (nextByte === 0x5b) { + let i = startIdx + 2; + while (i < data.length) { + const byte = data[i]; + // CSI sequences end with 0x40-0x7E + if (byte >= 0x40 && byte <= 0x7e) { + return { nextIndex: i + 1, complete: true }; + } + i++; + } + return { nextIndex: data.length, complete: false }; + } + + return { nextIndex: startIdx + 1, complete: true }; +} + +/** + * Filter terminal response sequences (like Kitty protocol responses, device attributes, etc.) + * Preserve user input (including function keys like arrow keys) + */ +function filterTerminalResponses(data: Buffer): { + filtered: Buffer; + trailingPartialTerminalResponse: Buffer; +} { + const result = Buffer.allocUnsafe(data.length); + let writeIdx = 0; + let i = 0; + + while (i < data.length) { + // Detect ESC sequences + if (data[i] === 0x1b) { + const sequenceType = classifyEscapeSequence(data, i); + if (sequenceType === 'incomplete') { + return { + filtered: result.subarray(0, writeIdx), + trailingPartialTerminalResponse: data.subarray(i), + }; + } + // Check if this is a terminal response (should be filtered out) + if (sequenceType === 'terminal') { + // Skip the terminal response sequence + const skipResult = skipTerminalResponse(data, i); + if (!skipResult.complete) { + return { + filtered: result.subarray(0, writeIdx), + trailingPartialTerminalResponse: data.subarray(i), + }; + } + i = skipResult.nextIndex; + continue; + } + // User input function keys (like arrow keys ESC [A), preserve + } + // Preserve current byte + result[writeIdx++] = data[i]; + i++; + } + + return { + filtered: result.subarray(0, writeIdx), + trailingPartialTerminalResponse: Buffer.alloc(0), + }; +} + +/** + * Decide whether pending trailing bytes should be replayed when capture stops. + * Known terminal-response prefixes are dropped; user/ambiguous prefixes are kept. + */ +function shouldReplayPendingAtStop(pending: Buffer): boolean { + if (pending.length === 0) { + return false; + } + return classifyEscapeSequence(pending, 0) === 'user'; +} + +/** + * Start early input capture + * Call immediately after setting raw mode in gemini.tsx + */ +export function startEarlyInputCapture(): void { + if (isCapturing || !process.stdin.isTTY) { + if (!process.stdin.isTTY) { + debugLogger.debug('Early input capture skipped: stdin is not a TTY'); + } + return; + } + + // Check if disabled + if (process.env['QWEN_CODE_DISABLE_EARLY_CAPTURE'] === '1') { + debugLogger.debug('Early input capture disabled by environment variable'); + return; + } + + isCapturing = true; + inputBuffer = { + chunks: [], + totalBytes: 0, + captured: false, + }; + pendingTerminalResponse = Buffer.alloc(0); + + debugLogger.debug('Starting early input capture'); + + captureHandler = (data: Buffer) => { + if (inputBuffer.captured) { + return; + } + + // Check buffer size limit + if (inputBuffer.totalBytes >= MAX_BUFFER_SIZE) { + debugLogger.warn( + `Early input capture buffer full (${MAX_BUFFER_SIZE} bytes). Stopping capture; additional keystrokes during startup will be lost.`, + ); + stopEarlyInputCapture(); + return; + } + + const dataToFilter = + pendingTerminalResponse.length > 0 + ? Buffer.concat([pendingTerminalResponse, data]) + : data; + pendingTerminalResponse = Buffer.alloc(0); + + // Filter out terminal response sequences (like Kitty protocol responses) + const { filtered, trailingPartialTerminalResponse } = + filterTerminalResponses(dataToFilter); + if (trailingPartialTerminalResponse.length > 0) { + pendingTerminalResponse = Buffer.from(trailingPartialTerminalResponse); + } + + if (filtered.length > 0) { + // Limit buffer size + const newLength = inputBuffer.totalBytes + filtered.length; + if (newLength > MAX_BUFFER_SIZE) { + const truncated = filtered.subarray( + 0, + MAX_BUFFER_SIZE - inputBuffer.totalBytes, + ); + inputBuffer.chunks.push(Buffer.from(truncated)); + inputBuffer.totalBytes += truncated.length; + debugLogger.debug(`Buffer truncated at ${MAX_BUFFER_SIZE} bytes`); + } else { + inputBuffer.chunks.push(Buffer.from(filtered)); + inputBuffer.totalBytes += filtered.length; + debugLogger.debug( + `Captured ${filtered.length} bytes (total: ${inputBuffer.totalBytes})`, + ); + } + } + }; + + captureStdin = process.stdin; + captureStdin.on('data', captureHandler); +} + +/** + * Stop early input capture + * Call before KeypressProvider mounts + */ +export function stopEarlyInputCapture(): void { + if (!isCapturing || !captureHandler || !captureStdin) { + return; + } + + captureStdin.removeListener('data', captureHandler); + captureStdin = null; + captureHandler = null; + isCapturing = false; + inputBuffer.captured = true; + + debugLogger.debug( + `Stopped early input capture: ${inputBuffer.totalBytes} bytes`, + ); +} + +/** + * Get and clear captured input + * For use by KeypressContext + */ +export function getAndClearCapturedInput(): Buffer { + const parts = [...inputBuffer.chunks]; + if (shouldReplayPendingAtStop(pendingTerminalResponse)) { + parts.push(Buffer.from(pendingTerminalResponse)); + } + const buffer = parts.length > 0 ? Buffer.concat(parts) : Buffer.alloc(0); + inputBuffer.chunks = []; + inputBuffer.totalBytes = 0; + pendingTerminalResponse = Buffer.alloc(0); + // Keep captured=true — capture has completed, don't re-arm + return buffer; +} + +/** + * Stop capture and return captured input in one atomic operation. + * Preferred over calling stopEarlyInputCapture + getAndClearCapturedInput separately. + */ +export function stopAndGetCapturedInput(): Buffer { + stopEarlyInputCapture(); + return getAndClearCapturedInput(); +} + +/** + * Check if there is captured input + */ +export function hasCapturedInput(): boolean { + return inputBuffer.totalBytes > 0; +} + +/** + * Reset capture state (for testing only) + */ +export function resetCaptureState(): void { + if (captureHandler && captureStdin) { + captureStdin.removeListener('data', captureHandler); + } else if (captureHandler) { + process.stdin.removeListener('data', captureHandler); + } + captureStdin = null; + captureHandler = null; + isCapturing = false; + inputBuffer = { + chunks: [], + totalBytes: 0, + captured: false, + }; + pendingTerminalResponse = Buffer.alloc(0); +}