feat(cli): add early input capture to prevent keystroke loss during startup (#3319)

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

* fix(cli): fix early input capture being lost under React StrictMode

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 <qwen-coder@alibabacloud.com>

* fix: handle split ESC prefixes in early input capture

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: conditionally flush pending startup capture bytes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* 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 <qwen-coder@alibabacloud.com>

---------

Co-authored-by: jinye.djy <jinye.djy@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
jinye 2026-04-19 00:40:44 +08:00 committed by GitHub
parent 56e82279c4
commit afa7fc3855
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 775 additions and 0 deletions

View file

@ -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}
>
<SessionStatsProvider sessionId={config.getSessionId()}>
<VimModeProvider settings={settings}>
@ -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);

View file

@ -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<Set<KeypressHandler>>(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 (

View file

@ -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);
});
});
});

View file

@ -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);
}