From dd0dc702532e0b0a985d87e3ffb85b2d88d04127 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 11:30:45 +0800 Subject: [PATCH 1/7] feat(cli): add early input capture to prevent keystroke loss during startup (#3224) Start raw mode stdin listening immediately after setRawMode(true), buffer user input during REPL initialization (200-500ms), then replay it once KeypressProvider is mounted. Prevents keystrokes typed before the REPL is ready from being silently dropped. - Filter out terminal response sequences (DA, DA2, OSC, DCS, APC) while preserving real user input (arrow keys, function keys, etc.) - 64KB buffer limit for safety - Replay via setImmediate() to ensure subscribers are registered first - Disable via QWEN_CODE_DISABLE_EARLY_CAPTURE=1 - Add benchmark-startup.sh / benchmark-startup-simple.sh for baseline startup time measurement Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 4 + .../cli/src/ui/contexts/KeypressContext.tsx | 24 ++ .../cli/src/utils/earlyInputCapture.test.ts | 250 +++++++++++++++ packages/cli/src/utils/earlyInputCapture.ts | 290 ++++++++++++++++++ scripts/benchmark-startup-simple.sh | 81 +++++ scripts/benchmark-startup.sh | 86 ++++++ 6 files changed, 735 insertions(+) create mode 100644 packages/cli/src/utils/earlyInputCapture.test.ts create mode 100644 packages/cli/src/utils/earlyInputCapture.ts create mode 100755 scripts/benchmark-startup-simple.sh create mode 100755 scripts/benchmark-startup.sh diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b106f524ab0..ee55a5c6d92 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -63,6 +63,7 @@ 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 } from './utils/earlyInputCapture.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; @@ -465,6 +466,9 @@ export async function main() { // input showing up in the output. process.stdin.setRawMode(true); + // Startup optimization: start early input capture + startEarlyInputCapture(); + // 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 a2ee13c298d..af4d69c4441 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -39,6 +39,10 @@ import { import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; +import { + stopEarlyInputCapture, + getAndClearCapturedInput, +} from '../../utils/earlyInputCapture.js'; const ESC = '\u001B'; export const PASTE_MODE_PREFIX = `${ESC}[200~`; @@ -167,6 +171,10 @@ export function KeypressProvider({ setRawMode(true); } + // Startup optimization: stop early input capture and get captured input + stopEarlyInputCapture(); + const capturedInput = getAndClearCapturedInput(); + const keypressStream = new PassThrough(); let usePassthrough = false; // Use passthrough mode when pasteWorkaround is enabled, @@ -1102,6 +1110,22 @@ export function KeypressProvider({ stdin.on('keypress', handleKeypress); } + // Startup optimization: replay captured input if available + if (capturedInput.length > 0) { + debugLogger.debug( + `Replaying ${capturedInput.length} bytes of captured input`, + ); + // Process in next event loop tick to ensure subscribers are ready + setImmediate(() => { + if (usePassthrough) { + keypressStream.write(capturedInput); + } else { + // Emit data event directly on stdin + stdin.emit('data', capturedInput); + } + }); + } + return () => { if (usePassthrough) { keypressStream.removeListener('keypress', handleKeypress); diff --git a/packages/cli/src/utils/earlyInputCapture.test.ts b/packages/cli/src/utils/earlyInputCapture.test.ts new file mode 100644 index 00000000000..a3678a98810 --- /dev/null +++ b/packages/cli/src/utils/earlyInputCapture.test.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + startEarlyInputCapture, + stopEarlyInputCapture, + getAndClearCapturedInput, + 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'); + }); + }); + + 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'); + }); + }); + + 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 00000000000..a374cb73166 --- /dev/null +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -0,0 +1,290 @@ +/** + * @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 + */ +interface InputBuffer { + /** Raw byte data */ + rawBytes: Buffer; + /** Whether capture is complete */ + captured: boolean; +} + +let inputBuffer: InputBuffer = { + rawBytes: Buffer.alloc(0), + captured: false, +}; + +let captureHandler: ((data: Buffer) => void) | null = null; +let isCapturing = false; + +/** + * Check if this is a terminal response sequence + * Terminal responses typically start with specific prefixes + * + * 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 isTerminalResponse(data: Buffer, startIdx: number): boolean { + if (startIdx >= data.length || data[startIdx] !== 0x1b) { + return false; + } + + const nextIdx = startIdx + 1; + if (nextIdx >= data.length) { + return false; + } + + 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 true; + } + + // 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) { + const thirdByte = data[thirdIdx]; + if (thirdByte === 0x3f || thirdByte === 0x3e) { + // ESC [ ? or ESC [ > - this is a terminal response + return true; + } + } + } + + return false; +} + +/** + * Skip terminal response sequence + * Returns the index position after skipping + */ +function skipTerminalResponse(data: Buffer, startIdx: number): number { + if (startIdx >= data.length || data[startIdx] !== 0x1b) { + return startIdx + 1; + } + + const nextIdx = startIdx + 1; + if (nextIdx >= data.length) { + return nextIdx; + } + + 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 i + 1; + } + if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x5c) { + return i + 2; + } + i++; + } + return data.length; + } + + // 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 i + 2; + } + i++; + } + return data.length; + } + + // 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 i + 1; + } + i++; + } + return data.length; + } + + return startIdx + 1; +} + +/** + * Filter terminal response sequences (like Kitty protocol responses, device attributes, etc.) + * Preserve user input (including function keys like arrow keys) + */ +function filterTerminalResponses(data: Buffer): Buffer { + const result: number[] = []; + let i = 0; + + while (i < data.length) { + // Detect ESC sequences + if (data[i] === 0x1b) { + // Check if this is a terminal response (should be filtered out) + if (isTerminalResponse(data, i)) { + // Skip the terminal response sequence + i = skipTerminalResponse(data, i); + continue; + } + // User input function keys (like arrow keys ESC [A), preserve + } + // Preserve current byte + result.push(data[i]); + i++; + } + + return Buffer.from(result); +} + +/** + * Start early input capture + * Call immediately after setting raw mode in gemini.tsx + */ +export function startEarlyInputCapture(): void { + if (isCapturing || !process.stdin.isTTY) { + 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 = { + rawBytes: Buffer.alloc(0), + captured: false, + }; + + debugLogger.debug('Starting early input capture'); + + captureHandler = (data: Buffer) => { + if (inputBuffer.captured) { + return; + } + + // Check buffer size limit + if (inputBuffer.rawBytes.length >= MAX_BUFFER_SIZE) { + debugLogger.debug('Buffer size limit reached, stopping capture'); + return; + } + + // Filter out terminal response sequences (like Kitty protocol responses) + const filtered = filterTerminalResponses(data); + if (filtered.length > 0) { + // Limit buffer size + const newLength = inputBuffer.rawBytes.length + filtered.length; + if (newLength > MAX_BUFFER_SIZE) { + const truncated = filtered.subarray( + 0, + MAX_BUFFER_SIZE - inputBuffer.rawBytes.length, + ); + inputBuffer.rawBytes = Buffer.concat([inputBuffer.rawBytes, truncated]); + debugLogger.debug(`Buffer truncated at ${MAX_BUFFER_SIZE} bytes`); + } else { + inputBuffer.rawBytes = Buffer.concat([inputBuffer.rawBytes, filtered]); + debugLogger.debug( + `Captured ${filtered.length} bytes (total: ${inputBuffer.rawBytes.length})`, + ); + } + } + }; + + process.stdin.on('data', captureHandler); +} + +/** + * Stop early input capture + * Call before KeypressProvider mounts + */ +export function stopEarlyInputCapture(): void { + if (!isCapturing || !captureHandler) { + return; + } + + process.stdin.removeListener('data', captureHandler); + captureHandler = null; + isCapturing = false; + inputBuffer.captured = true; + + debugLogger.debug( + `Stopped early input capture: ${inputBuffer.rawBytes.length} bytes`, + ); +} + +/** + * Get and clear captured input + * For use by KeypressContext + */ +export function getAndClearCapturedInput(): Buffer { + const buffer = Buffer.from(inputBuffer.rawBytes); + inputBuffer = { + rawBytes: Buffer.alloc(0), + captured: false, + }; + return buffer; +} + +/** + * Check if there is captured input + */ +export function hasCapturedInput(): boolean { + return inputBuffer.rawBytes.length > 0; +} + +/** + * Reset capture state (for testing only) + */ +export function resetCaptureState(): void { + if (captureHandler) { + process.stdin.removeListener('data', captureHandler); + captureHandler = null; + } + isCapturing = false; + inputBuffer = { + rawBytes: Buffer.alloc(0), + captured: false, + }; +} diff --git a/scripts/benchmark-startup-simple.sh b/scripts/benchmark-startup-simple.sh new file mode 100755 index 00000000000..a11f7b9e55b --- /dev/null +++ b/scripts/benchmark-startup-simple.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Simple Startup Time Benchmark +# Measures baseline CLI startup time (module loading, initialization overhead). +# +# Note: This uses `--version` which exits before the preconnect code path. +# To measure the actual preconnect effect on TCP+TLS handshake time, +# use benchmark-api-latency.sh instead. + +set -e + +ITERATIONS=${ITERATIONS:-5} +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI_PATH="${CLI_PATH:-"$SCRIPT_DIR/../dist/cli.js"}" + +echo "=== Qwen Code Startup Time Benchmark ===" +echo "Iterations: $ITERATIONS" +echo "CLI Path: $CLI_PATH" +echo "" + +# Function: calculate statistics +calculate_stats() { + local file=$1 + local name=$2 + + if command -v python3 &> /dev/null; then + python3 - < "$RESULTS_DIR/startup.txt" + +for i in $(seq 1 $ITERATIONS); do + start=$(node -e "console.log(Date.now())") + node "$CLI_PATH" --version > /dev/null 2>&1 + end=$(node -e "console.log(Date.now())") + + elapsed=$((end - start)) + echo "$elapsed" >> "$RESULTS_DIR/startup.txt" + echo " Run $i: ${elapsed}ms" +done + +# Calculate statistics +echo "" +echo "=== Results ===" +echo "" + +calculate_stats "$RESULTS_DIR/startup.txt" "Startup Time (--version)" diff --git a/scripts/benchmark-startup.sh b/scripts/benchmark-startup.sh new file mode 100755 index 00000000000..e47f8c00c95 --- /dev/null +++ b/scripts/benchmark-startup.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Startup Performance Benchmark +# Measures baseline CLI startup time (module loading, initialization overhead). +# +# Note: This uses `--version` which exits before the preconnect code path. +# To measure the actual preconnect effect on TCP+TLS handshake time, +# use benchmark-api-latency.sh instead. + +set -e + +ITERATIONS=${ITERATIONS:-10} +RESULTS_DIR=$(mktemp -d) +trap "rm -rf $RESULTS_DIR" EXIT +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +CLI_CMD=${CLI_CMD:-"qwen"} + +echo "=== Qwen Code Startup Time Benchmark ===" +echo "Iterations: $ITERATIONS" +echo "Timestamp: $TIMESTAMP" +echo "CLI Command: $CLI_CMD" +echo "" + +# Function: calculate statistics +calculate_stats() { + local file=$1 + local name=$2 + + if command -v python3 &> /dev/null; then + python3 - < "$RESULTS_DIR/startup_$TIMESTAMP.txt" + +for i in $(seq 1 $ITERATIONS); do + start=$(node -e "console.log(Date.now())") + $CLI_CMD --version > /dev/null 2>&1 + end=$(node -e "console.log(Date.now())") + + elapsed=$((end - start)) + echo "$elapsed" >> "$RESULTS_DIR/startup_$TIMESTAMP.txt" + echo " Run $i: ${elapsed}ms" +done + +# Calculate statistics and output results +echo "" +echo "=== Results ===" +echo "" + +calculate_stats "$RESULTS_DIR/startup_$TIMESTAMP.txt" "Startup Time (--version)" From 9ab78ad92f2e8a5a1d8d5f17819f1a35f0481ef5 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 13:04:48 +0800 Subject: [PATCH 2/7] fix(cli): fix bugs and optimize early input capture - Fix getAndClearCapturedInput resetting captured flag, preventing potential re-arm - Fix passthrough mode replay bypassing paste marker handling in KeypressContext - Optimize buffer storage from O(n^2) concat to chunked collection - Optimize filterTerminalResponses to use pre-allocated Buffer instead of number[] - Add atomic stopAndGetCapturedInput API to prevent two-step usage errors - Remove unrelated benchmark shell scripts - Add test for stopAndGetCapturedInput Co-authored-by: Qwen-Coder --- .../cli/src/ui/contexts/KeypressContext.tsx | 19 ++-- .../cli/src/utils/earlyInputCapture.test.ts | 13 +++ packages/cli/src/utils/earlyInputCapture.ts | 67 ++++++++++----- scripts/benchmark-startup-simple.sh | 81 ----------------- scripts/benchmark-startup.sh | 86 ------------------- 5 files changed, 64 insertions(+), 202 deletions(-) delete mode 100755 scripts/benchmark-startup-simple.sh delete mode 100755 scripts/benchmark-startup.sh diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index af4d69c4441..e5e717fc410 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -39,10 +39,7 @@ import { import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; -import { - stopEarlyInputCapture, - getAndClearCapturedInput, -} from '../../utils/earlyInputCapture.js'; +import { stopAndGetCapturedInput } from '../../utils/earlyInputCapture.js'; const ESC = '\u001B'; export const PASTE_MODE_PREFIX = `${ESC}[200~`; @@ -172,8 +169,7 @@ export function KeypressProvider({ } // Startup optimization: stop early input capture and get captured input - stopEarlyInputCapture(); - const capturedInput = getAndClearCapturedInput(); + const capturedInput = stopAndGetCapturedInput(); const keypressStream = new PassThrough(); let usePassthrough = false; @@ -1115,14 +1111,11 @@ export function KeypressProvider({ debugLogger.debug( `Replaying ${capturedInput.length} bytes of captured input`, ); - // Process in next event loop tick to ensure subscribers are ready + // 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. setImmediate(() => { - if (usePassthrough) { - keypressStream.write(capturedInput); - } else { - // Emit data event directly on stdin - stdin.emit('data', capturedInput); - } + stdin.emit('data', capturedInput); }); } diff --git a/packages/cli/src/utils/earlyInputCapture.test.ts b/packages/cli/src/utils/earlyInputCapture.test.ts index a3678a98810..641b7b60537 100644 --- a/packages/cli/src/utils/earlyInputCapture.test.ts +++ b/packages/cli/src/utils/earlyInputCapture.test.ts @@ -9,6 +9,7 @@ import { startEarlyInputCapture, stopEarlyInputCapture, getAndClearCapturedInput, + stopAndGetCapturedInput, hasCapturedInput, resetCaptureState, } from './earlyInputCapture.js'; @@ -100,6 +101,18 @@ describe('earlyInputCapture', () => { 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', () => { diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts index a374cb73166..2962834e2ae 100644 --- a/packages/cli/src/utils/earlyInputCapture.ts +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -20,17 +20,20 @@ const debugLogger = createDebugLogger('EARLY_INPUT'); const MAX_BUFFER_SIZE = 64 * 1024; /** - * Input buffer + * Input buffer - collects chunks and concatenates on retrieval to avoid O(n^2) copies. */ interface InputBuffer { - /** Raw byte data */ - rawBytes: Buffer; + /** Collected raw byte chunks */ + chunks: Buffer[]; + /** Total bytes across all chunks */ + totalBytes: number; /** Whether capture is complete */ captured: boolean; } let inputBuffer: InputBuffer = { - rawBytes: Buffer.alloc(0), + chunks: [], + totalBytes: 0, captured: false, }; @@ -153,9 +156,14 @@ function skipTerminalResponse(data: Buffer, startIdx: number): number { /** * Filter terminal response sequences (like Kitty protocol responses, device attributes, etc.) * Preserve user input (including function keys like arrow keys) + * + * Note: This filter operates on a single data chunk. Terminal response sequences + * split across multiple data events will not be detected and may leak into the + * buffer. In practice this is rare since terminal responses arrive as complete chunks. */ function filterTerminalResponses(data: Buffer): Buffer { - const result: number[] = []; + const result = Buffer.allocUnsafe(data.length); + let writeIdx = 0; let i = 0; while (i < data.length) { @@ -170,11 +178,11 @@ function filterTerminalResponses(data: Buffer): Buffer { // User input function keys (like arrow keys ESC [A), preserve } // Preserve current byte - result.push(data[i]); + result[writeIdx++] = data[i]; i++; } - return Buffer.from(result); + return result.subarray(0, writeIdx); } /** @@ -194,7 +202,8 @@ export function startEarlyInputCapture(): void { isCapturing = true; inputBuffer = { - rawBytes: Buffer.alloc(0), + chunks: [], + totalBytes: 0, captured: false, }; @@ -206,7 +215,7 @@ export function startEarlyInputCapture(): void { } // Check buffer size limit - if (inputBuffer.rawBytes.length >= MAX_BUFFER_SIZE) { + if (inputBuffer.totalBytes >= MAX_BUFFER_SIZE) { debugLogger.debug('Buffer size limit reached, stopping capture'); return; } @@ -215,18 +224,20 @@ export function startEarlyInputCapture(): void { const filtered = filterTerminalResponses(data); if (filtered.length > 0) { // Limit buffer size - const newLength = inputBuffer.rawBytes.length + filtered.length; + const newLength = inputBuffer.totalBytes + filtered.length; if (newLength > MAX_BUFFER_SIZE) { const truncated = filtered.subarray( 0, - MAX_BUFFER_SIZE - inputBuffer.rawBytes.length, + MAX_BUFFER_SIZE - inputBuffer.totalBytes, ); - inputBuffer.rawBytes = Buffer.concat([inputBuffer.rawBytes, truncated]); + inputBuffer.chunks.push(Buffer.from(truncated)); + inputBuffer.totalBytes += truncated.length; debugLogger.debug(`Buffer truncated at ${MAX_BUFFER_SIZE} bytes`); } else { - inputBuffer.rawBytes = Buffer.concat([inputBuffer.rawBytes, filtered]); + inputBuffer.chunks.push(Buffer.from(filtered)); + inputBuffer.totalBytes += filtered.length; debugLogger.debug( - `Captured ${filtered.length} bytes (total: ${inputBuffer.rawBytes.length})`, + `Captured ${filtered.length} bytes (total: ${inputBuffer.totalBytes})`, ); } } @@ -250,7 +261,7 @@ export function stopEarlyInputCapture(): void { inputBuffer.captured = true; debugLogger.debug( - `Stopped early input capture: ${inputBuffer.rawBytes.length} bytes`, + `Stopped early input capture: ${inputBuffer.totalBytes} bytes`, ); } @@ -259,19 +270,30 @@ export function stopEarlyInputCapture(): void { * For use by KeypressContext */ export function getAndClearCapturedInput(): Buffer { - const buffer = Buffer.from(inputBuffer.rawBytes); - inputBuffer = { - rawBytes: Buffer.alloc(0), - captured: false, - }; + const buffer = + inputBuffer.chunks.length > 0 + ? Buffer.concat(inputBuffer.chunks) + : Buffer.alloc(0); + inputBuffer.chunks = []; + inputBuffer.totalBytes = 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.rawBytes.length > 0; + return inputBuffer.totalBytes > 0; } /** @@ -284,7 +306,8 @@ export function resetCaptureState(): void { } isCapturing = false; inputBuffer = { - rawBytes: Buffer.alloc(0), + chunks: [], + totalBytes: 0, captured: false, }; } diff --git a/scripts/benchmark-startup-simple.sh b/scripts/benchmark-startup-simple.sh deleted file mode 100755 index a11f7b9e55b..00000000000 --- a/scripts/benchmark-startup-simple.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# Simple Startup Time Benchmark -# Measures baseline CLI startup time (module loading, initialization overhead). -# -# Note: This uses `--version` which exits before the preconnect code path. -# To measure the actual preconnect effect on TCP+TLS handshake time, -# use benchmark-api-latency.sh instead. - -set -e - -ITERATIONS=${ITERATIONS:-5} -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -CLI_PATH="${CLI_PATH:-"$SCRIPT_DIR/../dist/cli.js"}" - -echo "=== Qwen Code Startup Time Benchmark ===" -echo "Iterations: $ITERATIONS" -echo "CLI Path: $CLI_PATH" -echo "" - -# Function: calculate statistics -calculate_stats() { - local file=$1 - local name=$2 - - if command -v python3 &> /dev/null; then - python3 - < "$RESULTS_DIR/startup.txt" - -for i in $(seq 1 $ITERATIONS); do - start=$(node -e "console.log(Date.now())") - node "$CLI_PATH" --version > /dev/null 2>&1 - end=$(node -e "console.log(Date.now())") - - elapsed=$((end - start)) - echo "$elapsed" >> "$RESULTS_DIR/startup.txt" - echo " Run $i: ${elapsed}ms" -done - -# Calculate statistics -echo "" -echo "=== Results ===" -echo "" - -calculate_stats "$RESULTS_DIR/startup.txt" "Startup Time (--version)" diff --git a/scripts/benchmark-startup.sh b/scripts/benchmark-startup.sh deleted file mode 100755 index e47f8c00c95..00000000000 --- a/scripts/benchmark-startup.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -# Startup Performance Benchmark -# Measures baseline CLI startup time (module loading, initialization overhead). -# -# Note: This uses `--version` which exits before the preconnect code path. -# To measure the actual preconnect effect on TCP+TLS handshake time, -# use benchmark-api-latency.sh instead. - -set -e - -ITERATIONS=${ITERATIONS:-10} -RESULTS_DIR=$(mktemp -d) -trap "rm -rf $RESULTS_DIR" EXIT -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -CLI_CMD=${CLI_CMD:-"qwen"} - -echo "=== Qwen Code Startup Time Benchmark ===" -echo "Iterations: $ITERATIONS" -echo "Timestamp: $TIMESTAMP" -echo "CLI Command: $CLI_CMD" -echo "" - -# Function: calculate statistics -calculate_stats() { - local file=$1 - local name=$2 - - if command -v python3 &> /dev/null; then - python3 - < "$RESULTS_DIR/startup_$TIMESTAMP.txt" - -for i in $(seq 1 $ITERATIONS); do - start=$(node -e "console.log(Date.now())") - $CLI_CMD --version > /dev/null 2>&1 - end=$(node -e "console.log(Date.now())") - - elapsed=$((end - start)) - echo "$elapsed" >> "$RESULTS_DIR/startup_$TIMESTAMP.txt" - echo " Run $i: ${elapsed}ms" -done - -# Calculate statistics and output results -echo "" -echo "=== Results ===" -echo "" - -calculate_stats "$RESULTS_DIR/startup_$TIMESTAMP.txt" "Startup Time (--version)" From 80b8e0ac415d3ccbb3cd906014be23dfc436577e Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 14:49:25 +0800 Subject: [PATCH 3/7] fix(cli): fix listener leak, silent failures, and error handling in early input capture - Register cleanup for stdin listener in gemini.tsx to prevent orphaned listener on any error path before UI mounts - Add try-catch and cancellation guard to setImmediate replay in KeypressContext to handle component unmount and replay errors gracefully - Stop capture immediately and warn when buffer limit is reached instead of silently dropping data with a debug-level log - Capture stdin reference at registration time so removeListener always operates on the correct stream instance - Add debug log when early capture is skipped due to non-TTY stdin Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 7 +++++- .../cli/src/ui/contexts/KeypressContext.tsx | 12 +++++++++- packages/cli/src/utils/earlyInputCapture.ts | 24 ++++++++++++++----- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index ee55a5c6d92..dc9af5727a8 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -63,7 +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 } from './utils/earlyInputCapture.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'; @@ -468,6 +471,8 @@ export async function main() { // 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', () => { diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index e5e717fc410..2d3d6f55a20 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -1107,6 +1107,7 @@ export function KeypressProvider({ } // Startup optimization: replay captured input if available + let replayPending = false; if (capturedInput.length > 0) { debugLogger.debug( `Replaying ${capturedInput.length} bytes of captured input`, @@ -1114,12 +1115,21 @@ export function KeypressProvider({ // 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(() => { - stdin.emit('data', capturedInput); + 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); diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts index 2962834e2ae..678aaace2fb 100644 --- a/packages/cli/src/utils/earlyInputCapture.ts +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -38,6 +38,7 @@ let inputBuffer: InputBuffer = { }; let captureHandler: ((data: Buffer) => void) | null = null; +let captureStdin: NodeJS.ReadStream | null = null; let isCapturing = false; /** @@ -191,6 +192,9 @@ function filterTerminalResponses(data: Buffer): Buffer { */ 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; } @@ -216,7 +220,10 @@ export function startEarlyInputCapture(): void { // Check buffer size limit if (inputBuffer.totalBytes >= MAX_BUFFER_SIZE) { - debugLogger.debug('Buffer size limit reached, stopping capture'); + debugLogger.warn( + `Early input capture buffer full (${MAX_BUFFER_SIZE} bytes). Stopping capture; additional keystrokes during startup will be lost.`, + ); + stopEarlyInputCapture(); return; } @@ -243,7 +250,8 @@ export function startEarlyInputCapture(): void { } }; - process.stdin.on('data', captureHandler); + captureStdin = process.stdin; + captureStdin.on('data', captureHandler); } /** @@ -251,11 +259,12 @@ export function startEarlyInputCapture(): void { * Call before KeypressProvider mounts */ export function stopEarlyInputCapture(): void { - if (!isCapturing || !captureHandler) { + if (!isCapturing || !captureHandler || !captureStdin) { return; } - process.stdin.removeListener('data', captureHandler); + captureStdin.removeListener('data', captureHandler); + captureStdin = null; captureHandler = null; isCapturing = false; inputBuffer.captured = true; @@ -300,10 +309,13 @@ export function hasCapturedInput(): boolean { * Reset capture state (for testing only) */ export function resetCaptureState(): void { - if (captureHandler) { + if (captureHandler && captureStdin) { + captureStdin.removeListener('data', captureHandler); + } else if (captureHandler) { process.stdin.removeListener('data', captureHandler); - captureHandler = null; } + captureStdin = null; + captureHandler = null; isCapturing = false; inputBuffer = { chunks: [], From e2f8e5d83866e1bb37fedf6b60194ece9b7850a3 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 16:11:32 +0800 Subject: [PATCH 4/7] fix(cli): fix early input capture being lost under React StrictMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move stopAndGetCapturedInput() from inside KeypressProvider's useEffect to before render() in startInteractiveUI. When DEBUG=1, React StrictMode deliberately runs effect→cleanup→effect, causing the first mount to drain the buffer and schedule a replay that the cleanup immediately cancels. The second mount found an empty buffer, silently discarding startup keystrokes. By draining once before render() and passing the bytes as a stable prop, StrictMode remounts always read the same data and can schedule replay on the second (stable) mount. Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 6 ++++++ packages/cli/src/ui/contexts/KeypressContext.tsx | 10 +++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index dc9af5727a8..2f30a72ef0b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -208,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(); @@ -225,6 +230,7 @@ export async function startInteractiveUI( pasteWorkaround={ process.platform === 'win32' || nodeMajorVersion < 20 } + initialCapturedInput={initialCapturedInput} > diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 2d3d6f55a20..06c16d6ea9a 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -39,7 +39,6 @@ import { import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; -import { stopAndGetCapturedInput } from '../../utils/earlyInputCapture.js'; const ESC = '\u001B'; export const PASTE_MODE_PREFIX = `${ESC}[200~`; @@ -138,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; @@ -168,8 +169,10 @@ export function KeypressProvider({ setRawMode(true); } - // Startup optimization: stop early input capture and get captured input - const capturedInput = stopAndGetCapturedInput(); + // 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; @@ -1178,6 +1181,7 @@ export function KeypressProvider({ pasteWorkaround, config, subscribers, + initialCapturedInput, ]); return ( From 7e09888c4df48c4c49ffed6467482181e004aee9 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 17:07:20 +0800 Subject: [PATCH 5/7] fix: handle split ESC prefixes in early input capture Co-authored-by: Qwen-Coder --- .../cli/src/utils/earlyInputCapture.test.ts | 59 +++++++++ packages/cli/src/utils/earlyInputCapture.ts | 118 ++++++++++++------ 2 files changed, 140 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/utils/earlyInputCapture.test.ts b/packages/cli/src/utils/earlyInputCapture.test.ts index 641b7b60537..01ddf770d69 100644 --- a/packages/cli/src/utils/earlyInputCapture.test.ts +++ b/packages/cli/src/utils/earlyInputCapture.test.ts @@ -186,6 +186,65 @@ describe('earlyInputCapture', () => { 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 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 keep standalone ESC on capture end', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('\x1b'); + }); }); describe('UTF-8 handling', () => { diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts index 678aaace2fb..fa1f1766949 100644 --- a/packages/cli/src/utils/earlyInputCapture.ts +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -40,24 +40,32 @@ let inputBuffer: InputBuffer = { 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'; /** - * Check if this is a terminal response sequence - * Terminal responses typically start with specific prefixes + * 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 isTerminalResponse(data: Buffer, startIdx: number): boolean { +function classifyEscapeSequence( + data: Buffer, + startIdx: number, +): EscapeSequenceClassification { if (startIdx >= data.length || data[startIdx] !== 0x1b) { - return false; + return 'user'; } const nextIdx = startIdx + 1; if (nextIdx >= data.length) { - return false; + return 'incomplete'; } const nextByte = data[nextIdx]; @@ -71,7 +79,7 @@ function isTerminalResponse(data: Buffer, startIdx: number): boolean { nextByte === 0x5e || // ^ (PM) nextByte === 0x5d // ] (OSC) ) { - return true; + return 'terminal'; } // Check for terminal responses in CSI sequences @@ -80,30 +88,35 @@ function isTerminalResponse(data: Buffer, startIdx: number): boolean { if (nextByte === 0x5b) { // CSI sequence, check third character const thirdIdx = startIdx + 2; - if (thirdIdx < data.length) { - const thirdByte = data[thirdIdx]; - if (thirdByte === 0x3f || thirdByte === 0x3e) { - // ESC [ ? or ESC [ > - this is a terminal response - return true; - } + 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 false; + return 'user'; } /** * Skip terminal response sequence * Returns the index position after skipping */ -function skipTerminalResponse(data: Buffer, startIdx: number): number { +function skipTerminalResponse( + data: Buffer, + startIdx: number, +): { nextIndex: number; complete: boolean } { if (startIdx >= data.length || data[startIdx] !== 0x1b) { - return startIdx + 1; + return { nextIndex: startIdx + 1, complete: true }; } const nextIdx = startIdx + 1; if (nextIdx >= data.length) { - return nextIdx; + return { nextIndex: nextIdx, complete: false }; } const nextByte = data[nextIdx]; @@ -114,14 +127,14 @@ function skipTerminalResponse(data: Buffer, startIdx: number): number { while (i < data.length) { // BEL (0x07) or ST (ESC \) if (data[i] === 0x07) { - return i + 1; + return { nextIndex: i + 1, complete: true }; } if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x5c) { - return i + 2; + return { nextIndex: i + 2, complete: true }; } i++; } - return data.length; + return { nextIndex: data.length, complete: false }; } // DCS/APC/PM sequences: ESC P/_/^ ... ST @@ -130,11 +143,11 @@ function skipTerminalResponse(data: Buffer, startIdx: number): number { while (i < data.length) { // ST (ESC \) if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x5c) { - return i + 2; + return { nextIndex: i + 2, complete: true }; } i++; } - return data.length; + return { nextIndex: data.length, complete: false }; } // CSI sequence: ESC [ ... (ends with 0x40-0x7E) @@ -144,25 +157,24 @@ function skipTerminalResponse(data: Buffer, startIdx: number): number { const byte = data[i]; // CSI sequences end with 0x40-0x7E if (byte >= 0x40 && byte <= 0x7e) { - return i + 1; + return { nextIndex: i + 1, complete: true }; } i++; } - return data.length; + return { nextIndex: data.length, complete: false }; } - return startIdx + 1; + 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) - * - * Note: This filter operates on a single data chunk. Terminal response sequences - * split across multiple data events will not be detected and may leak into the - * buffer. In practice this is rare since terminal responses arrive as complete chunks. */ -function filterTerminalResponses(data: Buffer): Buffer { +function filterTerminalResponses(data: Buffer): { + filtered: Buffer; + trailingPartialTerminalResponse: Buffer; +} { const result = Buffer.allocUnsafe(data.length); let writeIdx = 0; let i = 0; @@ -170,10 +182,24 @@ function filterTerminalResponses(data: Buffer): Buffer { 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 (isTerminalResponse(data, i)) { + if (sequenceType === 'terminal') { // Skip the terminal response sequence - i = skipTerminalResponse(data, i); + 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 @@ -183,7 +209,10 @@ function filterTerminalResponses(data: Buffer): Buffer { i++; } - return result.subarray(0, writeIdx); + return { + filtered: result.subarray(0, writeIdx), + trailingPartialTerminalResponse: Buffer.alloc(0), + }; } /** @@ -210,6 +239,7 @@ export function startEarlyInputCapture(): void { totalBytes: 0, captured: false, }; + pendingTerminalResponse = Buffer.alloc(0); debugLogger.debug('Starting early input capture'); @@ -227,8 +257,19 @@ export function startEarlyInputCapture(): void { 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 = filterTerminalResponses(data); + 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; @@ -279,12 +320,14 @@ export function stopEarlyInputCapture(): void { * For use by KeypressContext */ export function getAndClearCapturedInput(): Buffer { - const buffer = - inputBuffer.chunks.length > 0 - ? Buffer.concat(inputBuffer.chunks) - : Buffer.alloc(0); + const parts = [...inputBuffer.chunks]; + if (pendingTerminalResponse.length > 0) { + 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; } @@ -322,4 +365,5 @@ export function resetCaptureState(): void { totalBytes: 0, captured: false, }; + pendingTerminalResponse = Buffer.alloc(0); } From c8d9ae87ac3af021c0077d9fb0aec822dc5f2f1c Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 17:34:29 +0800 Subject: [PATCH 6/7] fix: conditionally flush pending startup capture bytes Co-authored-by: Qwen-Coder --- .../cli/src/utils/earlyInputCapture.test.ts | 27 +++++++++++++++++++ packages/cli/src/utils/earlyInputCapture.ts | 13 ++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/earlyInputCapture.test.ts b/packages/cli/src/utils/earlyInputCapture.test.ts index 01ddf770d69..68112aaac8b 100644 --- a/packages/cli/src/utils/earlyInputCapture.test.ts +++ b/packages/cli/src/utils/earlyInputCapture.test.ts @@ -227,6 +227,24 @@ describe('earlyInputCapture', () => { 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[')); @@ -237,6 +255,15 @@ describe('earlyInputCapture', () => { expect(input.toString()).toBe('\x1b[A'); }); + it('should keep ESC[ prefix on capture end', () => { + startEarlyInputCapture(); + mockStdin.write(Buffer.from('\x1b[')); + stopEarlyInputCapture(); + + const input = getAndClearCapturedInput(); + expect(input.toString()).toBe('\x1b['); + }); + it('should keep standalone ESC on capture end', () => { startEarlyInputCapture(); mockStdin.write(Buffer.from('\x1b')); diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts index fa1f1766949..24bc0276019 100644 --- a/packages/cli/src/utils/earlyInputCapture.ts +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -215,6 +215,17 @@ function filterTerminalResponses(data: Buffer): { }; } +/** + * 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) !== 'terminal'; +} + /** * Start early input capture * Call immediately after setting raw mode in gemini.tsx @@ -321,7 +332,7 @@ export function stopEarlyInputCapture(): void { */ export function getAndClearCapturedInput(): Buffer { const parts = [...inputBuffer.chunks]; - if (pendingTerminalResponse.length > 0) { + if (shouldReplayPendingAtStop(pendingTerminalResponse)) { parts.push(Buffer.from(pendingTerminalResponse)); } const buffer = parts.length > 0 ? Buffer.concat(parts) : Buffer.alloc(0); From 891ea661f4fa38fc618ddba747a90c2aa8fc0ece Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 17 Apr 2026 10:18:20 +0800 Subject: [PATCH 7/7] fix: drop incomplete escape sequences instead of replaying as user input When capture stops with an incomplete ESC sequence in pendingTerminalResponse (e.g. lone \x1b or \x1b[), classifyEscapeSequence returns 'incomplete'. Previously shouldReplayPendingAtStop used !== 'terminal' which treated incomplete sequences as user input. Changed to === 'user' so only definitively-user input is replayed; ambiguous sequences are safely dropped. Co-authored-by: Qwen-Coder --- packages/cli/src/utils/earlyInputCapture.test.ts | 8 ++++---- packages/cli/src/utils/earlyInputCapture.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/earlyInputCapture.test.ts b/packages/cli/src/utils/earlyInputCapture.test.ts index 68112aaac8b..21431edc1e8 100644 --- a/packages/cli/src/utils/earlyInputCapture.test.ts +++ b/packages/cli/src/utils/earlyInputCapture.test.ts @@ -255,22 +255,22 @@ describe('earlyInputCapture', () => { expect(input.toString()).toBe('\x1b[A'); }); - it('should keep ESC[ prefix on capture end', () => { + it('should drop incomplete ESC[ prefix on capture end', () => { startEarlyInputCapture(); mockStdin.write(Buffer.from('\x1b[')); stopEarlyInputCapture(); const input = getAndClearCapturedInput(); - expect(input.toString()).toBe('\x1b['); + expect(input.toString()).toBe(''); }); - it('should keep standalone ESC on capture end', () => { + it('should drop standalone ESC on capture end', () => { startEarlyInputCapture(); mockStdin.write(Buffer.from('\x1b')); stopEarlyInputCapture(); const input = getAndClearCapturedInput(); - expect(input.toString()).toBe('\x1b'); + expect(input.toString()).toBe(''); }); }); diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts index 24bc0276019..b9abb3ba633 100644 --- a/packages/cli/src/utils/earlyInputCapture.ts +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -223,7 +223,7 @@ function shouldReplayPendingAtStop(pending: Buffer): boolean { if (pending.length === 0) { return false; } - return classifyEscapeSequence(pending, 0) !== 'terminal'; + return classifyEscapeSequence(pending, 0) === 'user'; } /**