qwen-code/packages/cli/src/ui/utils/kittyProtocolDetector.ts
顾盼 2aad7c0617
fix(cli): disable Kitty keyboard protocol on SIGINT to prevent garbled 9;5u output (#3544)
* fix(cli): disable Kitty keyboard protocol on SIGINT to prevent garbled 9;5u output

When a Kitty-capable terminal (iTerm2, Kitty, WezTerm) is used, the CLI
enables the Kitty keyboard protocol at startup via ESC[>1u. On exit, the
protocol must be disabled with ESC[<u to restore the terminal's default
key encoding. Failing to do so leaves the terminal in Kitty mode: any
subsequent Ctrl+C press is encoded as ESC[99;5u, and since the shell does
not understand this sequence, it echoes the trailing '9;5u' as garbled
text.

Root cause: kittyProtocolDetector registered cleanup handlers for 'exit'
and 'SIGTERM', but omitted SIGINT. A process terminated via SIGINT (e.g.
kill -INT <pid>, a parent process sending SIGINT, or certain process
managers) would exit without disabling the protocol.

Fix:
1. Add process.on('SIGINT', disableProtocol) alongside the existing
   'exit' and 'SIGTERM' handlers in kittyProtocolDetector.ts.
2. Export a new disableKittyProtocol() function for explicit call sites.
3. Call disableKittyProtocol() in the registerCleanup callback in
   gemini.tsx before instance.unmount(), so the disable sequence is
   written while stdout is fully operational regardless of exit path.

Fixes #3528

* fix(test): add disableKittyProtocol to kittyProtocolDetector mock
2026-04-24 15:27:55 +08:00

136 lines
4.1 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
let detectionComplete = false;
let protocolSupported = false;
let protocolEnabled = false;
/**
* Detects Kitty keyboard protocol support.
* Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/
* This function should be called once at app startup.
*/
export async function detectAndEnableKittyProtocol(): Promise<boolean> {
if (detectionComplete) {
return protocolSupported;
}
return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
detectionComplete = true;
resolve(false);
return;
}
const originalRawMode = process.stdin.isRaw;
if (!originalRawMode) {
process.stdin.setRawMode(true);
}
let responseBuffer = '';
let progressiveEnhancementReceived = false;
let timeoutId: NodeJS.Timeout | undefined;
const onTimeout = () => {
timeoutId = undefined;
process.stdin.removeListener('data', handleData);
// Keep a drain handler briefly to consume any late-arriving terminal
// responses that would otherwise leak into the application input.
const drainHandler = () => {};
process.stdin.on('data', drainHandler);
setTimeout(() => {
process.stdin.removeListener('data', drainHandler);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
}, 100);
};
const handleData = (data: Buffer) => {
if (timeoutId === undefined) {
// Race condition. We have already timed out.
return;
}
responseBuffer += data.toString();
// Check for progressive enhancement response (CSI ? <flags> u)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) {
progressiveEnhancementReceived = true;
// Give more time to get the full set of kitty responses if we have an
// indication the terminal probably supports kitty and we just need to
// wait a bit longer for a response.
clearTimeout(timeoutId);
timeoutId = setTimeout(onTimeout, 1000);
}
// Check for device attributes response (CSI ? <attrs> c)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) {
clearTimeout(timeoutId);
timeoutId = undefined;
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
if (progressiveEnhancementReceived) {
// Enable the protocol
process.stdout.write('\x1b[>1u');
protocolSupported = true;
protocolEnabled = true;
// Set up cleanup on exit (exit covers process.exit() calls,
// SIGTERM/SIGINT cover signal-based terminations).
process.on('exit', disableProtocol);
process.on('SIGTERM', disableProtocol);
process.on('SIGINT', disableProtocol);
}
detectionComplete = true;
resolve(protocolSupported);
}
};
process.stdin.on('data', handleData);
// Send queries
process.stdout.write('\x1b[?u'); // Query progressive enhancement
process.stdout.write('\x1b[c'); // Query device attributes
// Timeout after 200ms
// When a iterm2 terminal does not have focus this can take over 90s on a
// fast macbook so we need a somewhat longer threshold than would be ideal.
timeoutId = setTimeout(onTimeout, 200);
});
}
function disableProtocol() {
if (protocolEnabled) {
process.stdout.write('\x1b[<u');
protocolEnabled = false;
}
}
/**
* Explicitly disables the Kitty keyboard protocol. Should be called during
* application cleanup before process.exit() to ensure the terminal is restored
* even if the 'exit' event handler does not fire in time (e.g. on SIGKILL).
*/
export function disableKittyProtocol(): void {
disableProtocol();
}
export function isKittyProtocolEnabled(): boolean {
return protocolEnabled;
}
export function isKittyProtocolSupported(): boolean {
return protocolSupported;
}