mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
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:
parent
56e82279c4
commit
afa7fc3855
4 changed files with 775 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
349
packages/cli/src/utils/earlyInputCapture.test.ts
Normal file
349
packages/cli/src/utils/earlyInputCapture.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
380
packages/cli/src/utils/earlyInputCapture.ts
Normal file
380
packages/cli/src/utils/earlyInputCapture.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue