2025-08-15 10:54:00 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @license
|
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-08-26 00:04:53 +02:00
|
|
|
|
import type { Config } from '@google/gemini-cli-core';
|
2025-08-15 10:54:00 -07:00
|
|
|
|
import {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger,
|
2025-08-15 10:54:00 -07:00
|
|
|
|
KittySequenceOverflowEvent,
|
|
|
|
|
|
logKittySequenceOverflow,
|
|
|
|
|
|
} from '@google/gemini-cli-core';
|
|
|
|
|
|
import { useStdin } from 'ink';
|
2025-08-26 00:04:53 +02:00
|
|
|
|
import type React from 'react';
|
|
|
|
|
|
import {
|
2025-08-15 10:54:00 -07:00
|
|
|
|
createContext,
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useContext,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
} from 'react';
|
2025-08-25 22:11:27 +02:00
|
|
|
|
import readline from 'node:readline';
|
|
|
|
|
|
import { PassThrough } from 'node:stream';
|
2025-08-15 10:54:00 -07:00
|
|
|
|
import {
|
|
|
|
|
|
BACKSLASH_ENTER_DETECTION_WINDOW_MS,
|
2025-08-25 17:14:07 -04:00
|
|
|
|
CHAR_CODE_ESC,
|
2025-08-15 10:54:00 -07:00
|
|
|
|
KITTY_CTRL_C,
|
2025-08-25 14:41:10 -04:00
|
|
|
|
KITTY_KEYCODE_BACKSPACE,
|
2025-08-15 15:30:57 -04:00
|
|
|
|
KITTY_KEYCODE_ENTER,
|
|
|
|
|
|
KITTY_KEYCODE_NUMPAD_ENTER,
|
2025-08-25 14:41:10 -04:00
|
|
|
|
KITTY_KEYCODE_TAB,
|
2025-08-15 10:54:00 -07:00
|
|
|
|
MAX_KITTY_SEQUENCE_LENGTH,
|
2025-09-03 05:03:38 +05:30
|
|
|
|
KITTY_MODIFIER_BASE,
|
|
|
|
|
|
KITTY_MODIFIER_EVENT_TYPES_OFFSET,
|
|
|
|
|
|
MODIFIER_SHIFT_BIT,
|
|
|
|
|
|
MODIFIER_ALT_BIT,
|
|
|
|
|
|
MODIFIER_CTRL_BIT,
|
2025-08-15 10:54:00 -07:00
|
|
|
|
} from '../utils/platformConstants.js';
|
|
|
|
|
|
|
|
|
|
|
|
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
|
|
|
|
|
|
|
|
|
|
|
|
const ESC = '\u001B';
|
2025-10-24 18:52:03 -07:00
|
|
|
|
export const PASTE_MODE_START = `${ESC}[200~`;
|
|
|
|
|
|
export const PASTE_MODE_END = `${ESC}[201~`;
|
2025-09-05 07:06:04 +09:00
|
|
|
|
export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input
|
2025-10-16 20:38:06 -07:00
|
|
|
|
export const KITTY_SEQUENCE_TIMEOUT_MS = 50; // Flush incomplete kitty sequences after 50ms
|
2025-10-24 18:52:03 -07:00
|
|
|
|
export const PASTE_CODE_TIMEOUT_MS = 50; // Flush incomplete paste code after 50ms
|
2025-09-05 07:06:04 +09:00
|
|
|
|
export const SINGLE_QUOTE = "'";
|
|
|
|
|
|
export const DOUBLE_QUOTE = '"';
|
2025-08-15 10:54:00 -07:00
|
|
|
|
|
2025-10-14 09:12:20 -07:00
|
|
|
|
const ALT_KEY_CHARACTER_MAP: Record<string, string> = {
|
|
|
|
|
|
'\u00E5': 'a',
|
|
|
|
|
|
'\u222B': 'b',
|
|
|
|
|
|
'\u00E7': 'c',
|
|
|
|
|
|
'\u2202': 'd',
|
|
|
|
|
|
'\u00B4': 'e',
|
|
|
|
|
|
'\u0192': 'f',
|
|
|
|
|
|
'\u00A9': 'g',
|
|
|
|
|
|
'\u02D9': 'h',
|
|
|
|
|
|
'\u02C6': 'i',
|
|
|
|
|
|
'\u2206': 'j',
|
|
|
|
|
|
'\u02DA': 'k',
|
|
|
|
|
|
'\u00AC': 'l',
|
|
|
|
|
|
'\u00B5': 'm',
|
|
|
|
|
|
'\u02DC': 'n',
|
|
|
|
|
|
'\u00F8': 'o',
|
|
|
|
|
|
'\u03C0': 'p',
|
|
|
|
|
|
'\u0153': 'q',
|
|
|
|
|
|
'\u00AE': 'r',
|
|
|
|
|
|
'\u00DF': 's',
|
|
|
|
|
|
'\u2020': 't',
|
|
|
|
|
|
'\u00A8': 'u',
|
|
|
|
|
|
'\u221A': 'v',
|
|
|
|
|
|
'\u2211': 'w',
|
|
|
|
|
|
'\u2248': 'x',
|
|
|
|
|
|
'\u00A5': 'y',
|
|
|
|
|
|
'\u03A9': 'z',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Check if a buffer could potentially be a valid kitty sequence or its prefix.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function couldBeKittySequence(buffer: string): boolean {
|
|
|
|
|
|
// Kitty sequences always start with ESC[.
|
|
|
|
|
|
if (buffer.length === 0) return true;
|
|
|
|
|
|
if (buffer === ESC || buffer === `${ESC}[`) return true;
|
|
|
|
|
|
|
|
|
|
|
|
if (!buffer.startsWith(`${ESC}[`)) return false;
|
|
|
|
|
|
|
|
|
|
|
|
// Check for known kitty sequence patterns:
|
|
|
|
|
|
// 1. ESC[<digit> - could be CSI-u or tilde-coded
|
|
|
|
|
|
// 2. ESC[1;<digit> - parameterized functional
|
|
|
|
|
|
// 3. ESC[<letter> - legacy functional keys
|
|
|
|
|
|
// 4. ESC[Z - reverse tab
|
|
|
|
|
|
const afterCSI = buffer.slice(2);
|
|
|
|
|
|
|
|
|
|
|
|
// Check if it starts with a digit (could be CSI-u or parameterized)
|
|
|
|
|
|
if (/^\d/.test(afterCSI)) return true;
|
|
|
|
|
|
|
|
|
|
|
|
// Check for known single-letter sequences
|
|
|
|
|
|
if (/^[ABCDHFPQRSZ]/.test(afterCSI)) return true;
|
|
|
|
|
|
|
|
|
|
|
|
// Check for 1; pattern (parameterized sequences)
|
|
|
|
|
|
if (/^1;\d/.test(afterCSI)) return true;
|
|
|
|
|
|
|
|
|
|
|
|
// Anything else starting with ESC[ that doesn't match our patterns
|
|
|
|
|
|
// is likely not a kitty sequence we handle
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Parses a single complete kitty/parameterized/legacy sequence from the start
|
|
|
|
|
|
* of the buffer.
|
|
|
|
|
|
*
|
|
|
|
|
|
* This enables peel-and-continue parsing for batched input, allowing us to
|
|
|
|
|
|
* "peel off" one complete event when multiple sequences arrive in a single
|
|
|
|
|
|
* chunk, preventing buffer overflow and fragmentation.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param buffer - The input buffer string to parse.
|
|
|
|
|
|
* @returns The parsed Key and the number of characters consumed, or null if
|
|
|
|
|
|
* no complete sequence is found at the start of the buffer.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function parseKittyPrefix(buffer: string): { key: Key; length: number } | null {
|
|
|
|
|
|
// In older terminals ESC [ Z was used as Cursor Backward Tabulation (CBT)
|
|
|
|
|
|
// In newer terminals the same functionality of key combination for moving
|
|
|
|
|
|
// backward through focusable elements is Shift+Tab, hence we will
|
|
|
|
|
|
// map ESC [ Z to Shift+Tab
|
|
|
|
|
|
// 0) Reverse Tab (legacy): ESC [ Z
|
|
|
|
|
|
// Treat as Shift+Tab for UI purposes.
|
|
|
|
|
|
// Regex parts:
|
|
|
|
|
|
// ^ - start of buffer
|
|
|
|
|
|
// ESC [ - CSI introducer
|
|
|
|
|
|
// Z - legacy reverse tab
|
|
|
|
|
|
const revTabLegacy = new RegExp(`^${ESC}\\[Z`);
|
|
|
|
|
|
let m = buffer.match(revTabLegacy);
|
|
|
|
|
|
if (m) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: {
|
|
|
|
|
|
name: 'tab',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: true,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: buffer.slice(0, m[0].length),
|
|
|
|
|
|
kittyProtocol: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
length: m[0].length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1) Reverse Tab (parameterized): ESC [ 1 ; <mods> Z
|
|
|
|
|
|
// Parameterized reverse Tab: ESC [ 1 ; <mods> Z
|
|
|
|
|
|
const revTabParam = new RegExp(`^${ESC}\\[1;(\\d+)Z`);
|
|
|
|
|
|
m = buffer.match(revTabParam);
|
|
|
|
|
|
if (m) {
|
|
|
|
|
|
let mods = parseInt(m[1], 10);
|
|
|
|
|
|
if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
|
|
|
|
|
|
mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
|
|
|
|
|
|
}
|
|
|
|
|
|
const bits = mods - KITTY_MODIFIER_BASE;
|
|
|
|
|
|
const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
|
|
|
|
|
|
const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: {
|
|
|
|
|
|
name: 'tab',
|
|
|
|
|
|
ctrl,
|
|
|
|
|
|
meta: alt,
|
|
|
|
|
|
// Reverse tab implies Shift behavior; force shift regardless of mods
|
|
|
|
|
|
shift: true,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: buffer.slice(0, m[0].length),
|
|
|
|
|
|
kittyProtocol: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
length: m[0].length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) Parameterized functional: ESC [ 1 ; <mods> (A|B|C|D|H|F|P|Q|R|S)
|
|
|
|
|
|
// 2) Parameterized functional: ESC [ 1 ; <mods> (A|B|C|D|H|F|P|Q|R|S)
|
|
|
|
|
|
// Arrows, Home/End, F1–F4 with modifiers encoded in <mods>.
|
|
|
|
|
|
const arrowPrefix = new RegExp(`^${ESC}\\[1;(\\d+)([ABCDHFPQSR])`);
|
|
|
|
|
|
m = buffer.match(arrowPrefix);
|
|
|
|
|
|
if (m) {
|
|
|
|
|
|
let mods = parseInt(m[1], 10);
|
|
|
|
|
|
if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
|
|
|
|
|
|
mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
|
|
|
|
|
|
}
|
|
|
|
|
|
const bits = mods - KITTY_MODIFIER_BASE;
|
|
|
|
|
|
const shift = (bits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT;
|
|
|
|
|
|
const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
|
|
|
|
|
|
const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
|
|
|
|
|
|
const sym = m[2];
|
|
|
|
|
|
const symbolToName: { [k: string]: string } = {
|
|
|
|
|
|
A: 'up',
|
|
|
|
|
|
B: 'down',
|
|
|
|
|
|
C: 'right',
|
|
|
|
|
|
D: 'left',
|
|
|
|
|
|
H: 'home',
|
|
|
|
|
|
F: 'end',
|
|
|
|
|
|
P: 'f1',
|
|
|
|
|
|
Q: 'f2',
|
|
|
|
|
|
R: 'f3',
|
|
|
|
|
|
S: 'f4',
|
|
|
|
|
|
};
|
|
|
|
|
|
const name = symbolToName[sym] || '';
|
|
|
|
|
|
if (!name) return null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: {
|
|
|
|
|
|
name,
|
|
|
|
|
|
ctrl,
|
|
|
|
|
|
meta: alt,
|
|
|
|
|
|
shift,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: buffer.slice(0, m[0].length),
|
|
|
|
|
|
kittyProtocol: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
length: m[0].length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3) CSI-u form: ESC [ <code> ; <mods> (u|~)
|
|
|
|
|
|
// 3) CSI-u and tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
|
|
|
|
|
|
// 'u' terminator: Kitty CSI-u; '~' terminator: tilde-coded function keys.
|
|
|
|
|
|
const csiUPrefix = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])`);
|
|
|
|
|
|
m = buffer.match(csiUPrefix);
|
|
|
|
|
|
if (m) {
|
|
|
|
|
|
const keyCode = parseInt(m[1], 10);
|
|
|
|
|
|
let modifiers = m[3] ? parseInt(m[3], 10) : KITTY_MODIFIER_BASE;
|
|
|
|
|
|
if (modifiers >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
|
|
|
|
|
|
modifiers -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
|
|
|
|
|
|
}
|
|
|
|
|
|
const modifierBits = modifiers - KITTY_MODIFIER_BASE;
|
|
|
|
|
|
const shift = (modifierBits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT;
|
|
|
|
|
|
const alt = (modifierBits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
|
|
|
|
|
|
const ctrl = (modifierBits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
|
|
|
|
|
|
const terminator = m[4];
|
|
|
|
|
|
|
|
|
|
|
|
// Tilde-coded functional keys (Delete, Insert, PageUp/Down, Home/End)
|
|
|
|
|
|
if (terminator === '~') {
|
|
|
|
|
|
let name: string | null = null;
|
|
|
|
|
|
switch (keyCode) {
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
name = 'home';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 2:
|
|
|
|
|
|
name = 'insert';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 3:
|
|
|
|
|
|
name = 'delete';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 4:
|
|
|
|
|
|
name = 'end';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 5:
|
|
|
|
|
|
name = 'pageup';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 6:
|
|
|
|
|
|
name = 'pagedown';
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (name) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: {
|
|
|
|
|
|
name,
|
|
|
|
|
|
ctrl,
|
|
|
|
|
|
meta: alt,
|
|
|
|
|
|
shift,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: buffer.slice(0, m[0].length),
|
|
|
|
|
|
kittyProtocol: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
length: m[0].length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const kittyKeyCodeToName: { [key: number]: string } = {
|
|
|
|
|
|
[CHAR_CODE_ESC]: 'escape',
|
|
|
|
|
|
[KITTY_KEYCODE_TAB]: 'tab',
|
|
|
|
|
|
[KITTY_KEYCODE_BACKSPACE]: 'backspace',
|
|
|
|
|
|
[KITTY_KEYCODE_ENTER]: 'return',
|
|
|
|
|
|
[KITTY_KEYCODE_NUMPAD_ENTER]: 'return',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const name = kittyKeyCodeToName[keyCode];
|
|
|
|
|
|
if (name) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: {
|
|
|
|
|
|
name,
|
|
|
|
|
|
ctrl,
|
|
|
|
|
|
meta: alt,
|
|
|
|
|
|
shift,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: buffer.slice(0, m[0].length),
|
|
|
|
|
|
kittyProtocol: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
length: m[0].length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Ctrl+letters and Alt+letters
|
|
|
|
|
|
if (
|
|
|
|
|
|
(ctrl || alt) &&
|
|
|
|
|
|
keyCode >= 'a'.charCodeAt(0) &&
|
|
|
|
|
|
keyCode <= 'z'.charCodeAt(0)
|
|
|
|
|
|
) {
|
|
|
|
|
|
const letter = String.fromCharCode(keyCode);
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: {
|
|
|
|
|
|
name: letter,
|
|
|
|
|
|
ctrl,
|
|
|
|
|
|
meta: alt,
|
|
|
|
|
|
shift,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: buffer.slice(0, m[0].length),
|
|
|
|
|
|
kittyProtocol: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
length: m[0].length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4) Legacy function keys (no parameters): ESC [ (A|B|C|D|H|F)
|
|
|
|
|
|
// Arrows + Home/End without modifiers.
|
|
|
|
|
|
const legacyFuncKey = new RegExp(`^${ESC}\\[([ABCDHF])`);
|
|
|
|
|
|
m = buffer.match(legacyFuncKey);
|
|
|
|
|
|
if (m) {
|
|
|
|
|
|
const sym = m[1];
|
|
|
|
|
|
const nameMap: { [key: string]: string } = {
|
|
|
|
|
|
A: 'up',
|
|
|
|
|
|
B: 'down',
|
|
|
|
|
|
C: 'right',
|
|
|
|
|
|
D: 'left',
|
|
|
|
|
|
H: 'home',
|
|
|
|
|
|
F: 'end',
|
|
|
|
|
|
};
|
|
|
|
|
|
const name = nameMap[sym]!;
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: {
|
|
|
|
|
|
name,
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: buffer.slice(0, m[0].length),
|
|
|
|
|
|
kittyProtocol: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
length: m[0].length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-24 18:52:03 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Returns the first index before which we are certain there is no paste marker.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function earliestPossiblePasteMarker(data: string): number {
|
|
|
|
|
|
// Check data for full start-paste or end-paste markers.
|
|
|
|
|
|
const startIndex = data.indexOf(PASTE_MODE_START);
|
|
|
|
|
|
const endIndex = data.indexOf(PASTE_MODE_END);
|
|
|
|
|
|
if (startIndex !== -1 && endIndex !== -1) {
|
|
|
|
|
|
return Math.min(startIndex, endIndex);
|
|
|
|
|
|
} else if (startIndex !== -1) {
|
|
|
|
|
|
return startIndex;
|
|
|
|
|
|
} else if (endIndex !== -1) {
|
|
|
|
|
|
return endIndex;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// data contains no full start-paste or end-paste.
|
|
|
|
|
|
// Check if data ends with a prefix of start-paste or end-paste.
|
|
|
|
|
|
const codeLength = PASTE_MODE_START.length;
|
|
|
|
|
|
for (let i = Math.min(data.length, codeLength - 1); i > 0; i--) {
|
|
|
|
|
|
const candidate = data.slice(data.length - i);
|
|
|
|
|
|
if (
|
|
|
|
|
|
PASTE_MODE_START.indexOf(candidate) === 0 ||
|
|
|
|
|
|
PASTE_MODE_END.indexOf(candidate) === 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
return data.length - i;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return data.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A generator that takes in data chunks and spits out paste-start and
|
|
|
|
|
|
* paste-end keypresses. All non-paste marker data is passed to passthrough.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function* pasteMarkerParser(
|
|
|
|
|
|
passthrough: PassThrough,
|
|
|
|
|
|
keypressHandler: (_: unknown, key: Key) => void,
|
|
|
|
|
|
): Generator<void, void, string> {
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
let data = yield;
|
|
|
|
|
|
if (data.length === 0) {
|
|
|
|
|
|
continue; // we timed out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
const index = earliestPossiblePasteMarker(data);
|
|
|
|
|
|
if (index === data.length) {
|
|
|
|
|
|
// no possible paste markers were found
|
|
|
|
|
|
passthrough.write(data);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (index > 0) {
|
|
|
|
|
|
// snip off and send the part that doesn't have a paste marker
|
|
|
|
|
|
passthrough.write(data.slice(0, index));
|
|
|
|
|
|
data = data.slice(index);
|
|
|
|
|
|
}
|
|
|
|
|
|
// data starts with a possible paste marker
|
|
|
|
|
|
const codeLength = PASTE_MODE_START.length;
|
|
|
|
|
|
if (data.length < codeLength) {
|
|
|
|
|
|
// we have a prefix. Concat the next data and try again.
|
|
|
|
|
|
const newData = yield;
|
|
|
|
|
|
if (newData.length === 0) {
|
|
|
|
|
|
// we timed out. Just dump what we have and start over.
|
|
|
|
|
|
passthrough.write(data);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
data += newData;
|
|
|
|
|
|
} else if (data.startsWith(PASTE_MODE_START)) {
|
|
|
|
|
|
keypressHandler(undefined, {
|
|
|
|
|
|
name: 'paste-start',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
data = data.slice(PASTE_MODE_START.length);
|
|
|
|
|
|
} else if (data.startsWith(PASTE_MODE_END)) {
|
|
|
|
|
|
keypressHandler(undefined, {
|
|
|
|
|
|
name: 'paste-end',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
data = data.slice(PASTE_MODE_END.length);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// This should never happen.
|
|
|
|
|
|
passthrough.write(data);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-15 10:54:00 -07:00
|
|
|
|
export interface Key {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
ctrl: boolean;
|
|
|
|
|
|
meta: boolean;
|
|
|
|
|
|
shift: boolean;
|
|
|
|
|
|
paste: boolean;
|
|
|
|
|
|
sequence: string;
|
|
|
|
|
|
kittyProtocol?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type KeypressHandler = (key: Key) => void;
|
|
|
|
|
|
|
|
|
|
|
|
interface KeypressContextValue {
|
|
|
|
|
|
subscribe: (handler: KeypressHandler) => void;
|
|
|
|
|
|
unsubscribe: (handler: KeypressHandler) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const KeypressContext = createContext<KeypressContextValue | undefined>(
|
|
|
|
|
|
undefined,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
export function useKeypressContext() {
|
|
|
|
|
|
const context = useContext(KeypressContext);
|
|
|
|
|
|
if (!context) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
'useKeypressContext must be used within a KeypressProvider',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return context;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Determines if the passthrough stream workaround should be used.
|
|
|
|
|
|
* This is necessary for Node.js versions older than 20 or when the
|
|
|
|
|
|
* PASTE_WORKAROUND environment variable is set, to correctly handle
|
|
|
|
|
|
* paste events.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function shouldUsePassthrough(): boolean {
|
|
|
|
|
|
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
|
|
|
|
|
|
return (
|
|
|
|
|
|
nodeMajorVersion < 20 ||
|
|
|
|
|
|
process.env['PASTE_WORKAROUND'] === '1' ||
|
|
|
|
|
|
process.env['PASTE_WORKAROUND'] === 'true'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-15 10:54:00 -07:00
|
|
|
|
export function KeypressProvider({
|
|
|
|
|
|
children,
|
|
|
|
|
|
kittyProtocolEnabled,
|
|
|
|
|
|
config,
|
2025-08-22 19:31:55 -04:00
|
|
|
|
debugKeystrokeLogging,
|
2025-08-15 10:54:00 -07:00
|
|
|
|
}: {
|
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
|
kittyProtocolEnabled: boolean;
|
|
|
|
|
|
config?: Config;
|
2025-08-22 19:31:55 -04:00
|
|
|
|
debugKeystrokeLogging?: boolean;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
}) {
|
|
|
|
|
|
const { stdin, setRawMode } = useStdin();
|
|
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
const subscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
const subscribe = useCallback(
|
2025-10-22 17:51:03 -07:00
|
|
|
|
(handler: KeypressHandler) => subscribers.add(handler),
|
2025-08-15 10:54:00 -07:00
|
|
|
|
[subscribers],
|
|
|
|
|
|
);
|
|
|
|
|
|
const unsubscribe = useCallback(
|
2025-10-22 17:51:03 -07:00
|
|
|
|
(handler: KeypressHandler) => subscribers.delete(handler),
|
|
|
|
|
|
[subscribers],
|
|
|
|
|
|
);
|
|
|
|
|
|
const broadcast = useCallback(
|
|
|
|
|
|
(key: Key) => subscribers.forEach((handler) => handler(key)),
|
2025-08-15 10:54:00 -07:00
|
|
|
|
[subscribers],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-09-05 17:18:51 -07:00
|
|
|
|
const wasRaw = stdin.isRaw;
|
|
|
|
|
|
if (wasRaw === false) {
|
|
|
|
|
|
setRawMode(true);
|
|
|
|
|
|
}
|
2025-08-15 10:54:00 -07:00
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
const keypressStream = shouldUsePassthrough() ? new PassThrough() : null;
|
2025-10-16 20:38:06 -07:00
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
// If non-null that means we are in paste mode
|
|
|
|
|
|
let pasteBuffer: Buffer | null = null;
|
2025-10-16 20:38:06 -07:00
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
// Used to turn "\" quickly followed by a "enter" into a shift enter
|
|
|
|
|
|
let backslashTimeout: NodeJS.Timeout | null = null;
|
2025-09-03 05:03:38 +05:30
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
// Buffers incomplete Kitty sequences and timer to flush it
|
|
|
|
|
|
let kittySequenceBuffer = '';
|
|
|
|
|
|
let kittySequenceTimeout: NodeJS.Timeout | null = null;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
// Used to detect filename drag-and-drops.
|
|
|
|
|
|
let dragBuffer = '';
|
|
|
|
|
|
let draggingTimer: NodeJS.Timeout | null = null;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
const clearDraggingTimer = () => {
|
|
|
|
|
|
if (draggingTimer) {
|
|
|
|
|
|
clearTimeout(draggingTimer);
|
|
|
|
|
|
draggingTimer = null;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-16 20:38:06 -07:00
|
|
|
|
const flushKittyBufferOnInterrupt = (reason: string) => {
|
|
|
|
|
|
if (kittySequenceBuffer) {
|
|
|
|
|
|
if (debugKeystrokeLogging) {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.log(
|
2025-10-16 20:38:06 -07:00
|
|
|
|
`[DEBUG] Kitty sequence flushed due to ${reason}:`,
|
|
|
|
|
|
JSON.stringify(kittySequenceBuffer),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: kittySequenceBuffer,
|
|
|
|
|
|
});
|
|
|
|
|
|
kittySequenceBuffer = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (kittySequenceTimeout) {
|
|
|
|
|
|
clearTimeout(kittySequenceTimeout);
|
|
|
|
|
|
kittySequenceTimeout = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-15 10:54:00 -07:00
|
|
|
|
const handleKeypress = (_: unknown, key: Key) => {
|
2025-09-20 10:59:37 -07:00
|
|
|
|
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
|
2025-10-16 20:38:06 -07:00
|
|
|
|
flushKittyBufferOnInterrupt('focus event');
|
2025-09-20 10:59:37 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-08-19 13:41:08 -07:00
|
|
|
|
if (key.name === 'paste-start') {
|
2025-10-16 20:38:06 -07:00
|
|
|
|
flushKittyBufferOnInterrupt('paste start');
|
2025-10-22 17:51:03 -07:00
|
|
|
|
pasteBuffer = Buffer.alloc(0);
|
2025-08-19 13:41:08 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key.name === 'paste-end') {
|
2025-10-22 17:51:03 -07:00
|
|
|
|
if (pasteBuffer !== null) {
|
|
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: true,
|
|
|
|
|
|
sequence: pasteBuffer.toString(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
pasteBuffer = null;
|
2025-08-19 13:41:08 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
if (pasteBuffer !== null) {
|
2025-08-19 13:41:08 -07:00
|
|
|
|
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-05 07:06:04 +09:00
|
|
|
|
if (
|
|
|
|
|
|
key.sequence === SINGLE_QUOTE ||
|
|
|
|
|
|
key.sequence === DOUBLE_QUOTE ||
|
2025-10-22 17:51:03 -07:00
|
|
|
|
draggingTimer !== null
|
2025-09-05 07:06:04 +09:00
|
|
|
|
) {
|
2025-10-22 17:51:03 -07:00
|
|
|
|
dragBuffer += key.sequence;
|
2025-09-05 07:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
clearDraggingTimer();
|
2025-10-22 17:51:03 -07:00
|
|
|
|
draggingTimer = setTimeout(() => {
|
|
|
|
|
|
draggingTimer = null;
|
|
|
|
|
|
const seq = dragBuffer;
|
|
|
|
|
|
dragBuffer = '';
|
2025-09-05 07:06:04 +09:00
|
|
|
|
if (seq) {
|
|
|
|
|
|
broadcast({ ...key, name: '', paste: true, sequence: seq });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, DRAG_COMPLETION_TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-14 09:12:20 -07:00
|
|
|
|
const mappedLetter = ALT_KEY_CHARACTER_MAP[key.sequence];
|
|
|
|
|
|
if (mappedLetter && !key.meta) {
|
|
|
|
|
|
broadcast({
|
|
|
|
|
|
name: mappedLetter,
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: true,
|
|
|
|
|
|
shift: false,
|
2025-10-22 17:51:03 -07:00
|
|
|
|
paste: pasteBuffer !== null,
|
2025-10-14 09:12:20 -07:00
|
|
|
|
sequence: key.sequence,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
if (key.name === 'return' && backslashTimeout !== null) {
|
|
|
|
|
|
clearTimeout(backslashTimeout);
|
|
|
|
|
|
backslashTimeout = null;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
broadcast({
|
|
|
|
|
|
...key,
|
|
|
|
|
|
shift: true,
|
|
|
|
|
|
sequence: '\r', // Corrected escaping for newline
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (key.sequence === '\\' && !key.name) {
|
|
|
|
|
|
// Corrected escaping for backslash
|
|
|
|
|
|
backslashTimeout = setTimeout(() => {
|
|
|
|
|
|
backslashTimeout = null;
|
|
|
|
|
|
broadcast(key);
|
|
|
|
|
|
}, BACKSLASH_ENTER_DETECTION_WINDOW_MS);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
if (backslashTimeout !== null && key.name !== 'return') {
|
|
|
|
|
|
clearTimeout(backslashTimeout);
|
|
|
|
|
|
backslashTimeout = null;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
sequence: '\\',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (['up', 'down', 'left', 'right'].includes(key.name)) {
|
|
|
|
|
|
broadcast(key);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
(key.ctrl && key.name === 'c') ||
|
|
|
|
|
|
key.sequence === `${ESC}${KITTY_CTRL_C}`
|
|
|
|
|
|
) {
|
2025-08-22 19:31:55 -04:00
|
|
|
|
if (kittySequenceBuffer && debugKeystrokeLogging) {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.log(
|
2025-08-22 19:31:55 -04:00
|
|
|
|
'[DEBUG] Kitty buffer cleared on Ctrl+C:',
|
|
|
|
|
|
kittySequenceBuffer,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-08-15 10:54:00 -07:00
|
|
|
|
kittySequenceBuffer = '';
|
2025-10-16 20:38:06 -07:00
|
|
|
|
if (kittySequenceTimeout) {
|
|
|
|
|
|
clearTimeout(kittySequenceTimeout);
|
|
|
|
|
|
kittySequenceTimeout = null;
|
|
|
|
|
|
}
|
2025-08-15 10:54:00 -07:00
|
|
|
|
if (key.sequence === `${ESC}${KITTY_CTRL_C}`) {
|
|
|
|
|
|
broadcast({
|
|
|
|
|
|
name: 'c',
|
|
|
|
|
|
ctrl: true,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: key.sequence,
|
|
|
|
|
|
kittyProtocol: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
broadcast(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (kittyProtocolEnabled) {
|
2025-10-16 20:38:06 -07:00
|
|
|
|
// Clear any pending timeout when new input arrives
|
|
|
|
|
|
if (kittySequenceTimeout) {
|
|
|
|
|
|
clearTimeout(kittySequenceTimeout);
|
|
|
|
|
|
kittySequenceTimeout = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if this could start a kitty sequence
|
|
|
|
|
|
const startsWithEsc = key.sequence.startsWith(ESC);
|
|
|
|
|
|
const isExcluded = [
|
2025-10-24 18:52:03 -07:00
|
|
|
|
PASTE_MODE_START,
|
|
|
|
|
|
PASTE_MODE_END,
|
2025-10-16 20:38:06 -07:00
|
|
|
|
FOCUS_IN,
|
|
|
|
|
|
FOCUS_OUT,
|
|
|
|
|
|
].some((prefix) => key.sequence.startsWith(prefix));
|
|
|
|
|
|
|
|
|
|
|
|
if (kittySequenceBuffer || (startsWithEsc && !isExcluded)) {
|
2025-08-15 10:54:00 -07:00
|
|
|
|
kittySequenceBuffer += key.sequence;
|
2025-08-22 19:31:55 -04:00
|
|
|
|
|
|
|
|
|
|
if (debugKeystrokeLogging) {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.log(
|
2025-08-22 19:31:55 -04:00
|
|
|
|
'[DEBUG] Kitty buffer accumulating:',
|
2025-10-16 20:38:06 -07:00
|
|
|
|
JSON.stringify(kittySequenceBuffer),
|
2025-08-22 19:31:55 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-16 20:38:06 -07:00
|
|
|
|
// Try immediate parsing
|
|
|
|
|
|
let remainingBuffer = kittySequenceBuffer;
|
2025-09-03 05:03:38 +05:30
|
|
|
|
let parsedAny = false;
|
2025-10-16 20:38:06 -07:00
|
|
|
|
|
|
|
|
|
|
while (remainingBuffer) {
|
|
|
|
|
|
const parsed = parseKittyPrefix(remainingBuffer);
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed) {
|
|
|
|
|
|
if (debugKeystrokeLogging) {
|
|
|
|
|
|
const parsedSequence = remainingBuffer.slice(0, parsed.length);
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.log(
|
2025-10-16 20:38:06 -07:00
|
|
|
|
'[DEBUG] Kitty sequence parsed successfully:',
|
|
|
|
|
|
JSON.stringify(parsedSequence),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
broadcast(parsed.key);
|
|
|
|
|
|
remainingBuffer = remainingBuffer.slice(parsed.length);
|
|
|
|
|
|
parsedAny = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// If we can't parse a sequence at the start, check if there's
|
|
|
|
|
|
// another ESC later in the buffer. If so, the data before it
|
|
|
|
|
|
// is garbage/incomplete and should be dropped so we can
|
|
|
|
|
|
// process the next sequence.
|
|
|
|
|
|
const nextEscIndex = remainingBuffer.indexOf(ESC, 1);
|
|
|
|
|
|
if (nextEscIndex !== -1) {
|
|
|
|
|
|
const garbage = remainingBuffer.slice(0, nextEscIndex);
|
2025-09-03 05:03:38 +05:30
|
|
|
|
if (debugKeystrokeLogging) {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.log(
|
2025-10-16 20:38:06 -07:00
|
|
|
|
'[DEBUG] Dropping incomplete sequence before next ESC:',
|
|
|
|
|
|
JSON.stringify(garbage),
|
2025-09-03 05:03:38 +05:30
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-10-16 20:38:06 -07:00
|
|
|
|
// Drop garbage and continue parsing from next ESC
|
|
|
|
|
|
remainingBuffer = remainingBuffer.slice(nextEscIndex);
|
|
|
|
|
|
// We made progress, so we can continue the loop to parse the next sequence
|
2025-09-03 05:03:38 +05:30
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2025-10-16 20:38:06 -07:00
|
|
|
|
|
|
|
|
|
|
// Check if buffer could become a valid kitty sequence
|
|
|
|
|
|
const couldBeValid = couldBeKittySequence(remainingBuffer);
|
|
|
|
|
|
|
|
|
|
|
|
if (!couldBeValid) {
|
|
|
|
|
|
// Not a kitty sequence - flush as regular input immediately
|
|
|
|
|
|
if (debugKeystrokeLogging) {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.log(
|
2025-10-16 20:38:06 -07:00
|
|
|
|
'[DEBUG] Not a kitty sequence, flushing:',
|
|
|
|
|
|
JSON.stringify(remainingBuffer),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: remainingBuffer,
|
|
|
|
|
|
});
|
|
|
|
|
|
remainingBuffer = '';
|
|
|
|
|
|
parsedAny = true;
|
|
|
|
|
|
} else if (remainingBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
|
|
|
|
|
|
// Buffer overflow - log and clear
|
|
|
|
|
|
if (debugKeystrokeLogging) {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.log(
|
2025-10-16 20:38:06 -07:00
|
|
|
|
'[DEBUG] Kitty buffer overflow, clearing:',
|
|
|
|
|
|
JSON.stringify(remainingBuffer),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (config) {
|
|
|
|
|
|
const event = new KittySequenceOverflowEvent(
|
|
|
|
|
|
remainingBuffer.length,
|
|
|
|
|
|
remainingBuffer,
|
|
|
|
|
|
);
|
|
|
|
|
|
logKittySequenceOverflow(config, event);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Flush as regular input
|
|
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: remainingBuffer,
|
|
|
|
|
|
});
|
|
|
|
|
|
remainingBuffer = '';
|
|
|
|
|
|
parsedAny = true;
|
2025-09-03 05:03:38 +05:30
|
|
|
|
} else {
|
2025-10-16 20:38:06 -07:00
|
|
|
|
if (config?.getDebugMode() || debugKeystrokeLogging) {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.warn(
|
2025-10-16 20:38:06 -07:00
|
|
|
|
'Kitty sequence buffer has content:',
|
|
|
|
|
|
JSON.stringify(kittySequenceBuffer),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Could be valid but incomplete - set timeout
|
|
|
|
|
|
kittySequenceTimeout = setTimeout(() => {
|
|
|
|
|
|
if (kittySequenceBuffer) {
|
|
|
|
|
|
if (debugKeystrokeLogging) {
|
2025-10-17 18:00:23 -04:00
|
|
|
|
debugLogger.log(
|
2025-10-16 20:38:06 -07:00
|
|
|
|
'[DEBUG] Kitty sequence timeout, flushing:',
|
|
|
|
|
|
JSON.stringify(kittySequenceBuffer),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: kittySequenceBuffer,
|
|
|
|
|
|
});
|
|
|
|
|
|
kittySequenceBuffer = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
kittySequenceTimeout = null;
|
|
|
|
|
|
}, KITTY_SEQUENCE_TIMEOUT_MS);
|
|
|
|
|
|
break;
|
2025-09-03 05:03:38 +05:30
|
|
|
|
}
|
2025-08-22 19:31:55 -04:00
|
|
|
|
}
|
2025-08-15 10:54:00 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-16 20:38:06 -07:00
|
|
|
|
kittySequenceBuffer = remainingBuffer;
|
|
|
|
|
|
if (parsedAny || kittySequenceBuffer) return;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-19 13:41:08 -07:00
|
|
|
|
if (key.name === 'return' && key.sequence === `${ESC}\r`) {
|
|
|
|
|
|
key.meta = true;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
}
|
2025-10-22 17:51:03 -07:00
|
|
|
|
broadcast({ ...key, paste: pasteBuffer !== null });
|
2025-08-15 10:54:00 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-24 18:52:03 -07:00
|
|
|
|
let cleanup = () => {};
|
2025-08-15 10:54:00 -07:00
|
|
|
|
let rl: readline.Interface;
|
2025-10-22 17:51:03 -07:00
|
|
|
|
if (keypressStream !== null) {
|
2025-08-15 10:54:00 -07:00
|
|
|
|
rl = readline.createInterface({
|
|
|
|
|
|
input: keypressStream,
|
|
|
|
|
|
escapeCodeTimeout: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
readline.emitKeypressEvents(keypressStream, rl);
|
2025-10-24 18:52:03 -07:00
|
|
|
|
|
|
|
|
|
|
const parser = pasteMarkerParser(keypressStream, handleKeypress);
|
|
|
|
|
|
parser.next(); // prime the generator so it starts listening.
|
|
|
|
|
|
let timeoutId: NodeJS.Timeout;
|
|
|
|
|
|
const handleRawKeypress = (data: string) => {
|
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
|
parser.next(data);
|
|
|
|
|
|
timeoutId = setTimeout(() => parser.next(''), PASTE_CODE_TIMEOUT_MS);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-15 10:54:00 -07:00
|
|
|
|
keypressStream.on('keypress', handleKeypress);
|
2025-10-24 18:52:03 -07:00
|
|
|
|
process.stdin.setEncoding('utf8'); // so handleRawKeypress gets strings
|
2025-08-15 10:54:00 -07:00
|
|
|
|
stdin.on('data', handleRawKeypress);
|
2025-10-24 18:52:03 -07:00
|
|
|
|
|
|
|
|
|
|
cleanup = () => {
|
|
|
|
|
|
keypressStream.removeListener('keypress', handleKeypress);
|
|
|
|
|
|
stdin.removeListener('data', handleRawKeypress);
|
|
|
|
|
|
};
|
2025-08-15 10:54:00 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 });
|
|
|
|
|
|
readline.emitKeypressEvents(stdin, rl);
|
2025-10-24 18:52:03 -07:00
|
|
|
|
|
2025-08-15 10:54:00 -07:00
|
|
|
|
stdin.on('keypress', handleKeypress);
|
2025-10-24 18:52:03 -07:00
|
|
|
|
|
|
|
|
|
|
cleanup = () => stdin.removeListener('keypress', handleKeypress);
|
2025-08-15 10:54:00 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
2025-10-24 18:52:03 -07:00
|
|
|
|
cleanup();
|
2025-08-15 10:54:00 -07:00
|
|
|
|
rl.close();
|
|
|
|
|
|
|
|
|
|
|
|
// Restore the terminal to its original state.
|
2025-09-05 17:18:51 -07:00
|
|
|
|
if (wasRaw === false) {
|
|
|
|
|
|
setRawMode(false);
|
|
|
|
|
|
}
|
2025-08-15 10:54:00 -07:00
|
|
|
|
|
|
|
|
|
|
if (backslashTimeout) {
|
|
|
|
|
|
clearTimeout(backslashTimeout);
|
|
|
|
|
|
backslashTimeout = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-16 20:38:06 -07:00
|
|
|
|
if (kittySequenceTimeout) {
|
|
|
|
|
|
clearTimeout(kittySequenceTimeout);
|
|
|
|
|
|
kittySequenceTimeout = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Flush any pending kitty sequence data to avoid data loss on exit.
|
|
|
|
|
|
if (kittySequenceBuffer) {
|
|
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: false,
|
|
|
|
|
|
sequence: kittySequenceBuffer,
|
|
|
|
|
|
});
|
|
|
|
|
|
kittySequenceBuffer = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-15 10:54:00 -07:00
|
|
|
|
// Flush any pending paste data to avoid data loss on exit.
|
2025-10-22 17:51:03 -07:00
|
|
|
|
if (pasteBuffer !== null) {
|
2025-08-15 10:54:00 -07:00
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: true,
|
|
|
|
|
|
sequence: pasteBuffer.toString(),
|
|
|
|
|
|
});
|
2025-10-22 17:51:03 -07:00
|
|
|
|
pasteBuffer = null;
|
2025-08-15 10:54:00 -07:00
|
|
|
|
}
|
2025-09-05 07:06:04 +09:00
|
|
|
|
|
2025-10-22 17:51:03 -07:00
|
|
|
|
clearDraggingTimer();
|
|
|
|
|
|
if (dragBuffer) {
|
2025-09-05 07:06:04 +09:00
|
|
|
|
broadcast({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
ctrl: false,
|
|
|
|
|
|
meta: false,
|
|
|
|
|
|
shift: false,
|
|
|
|
|
|
paste: true,
|
2025-10-22 17:51:03 -07:00
|
|
|
|
sequence: dragBuffer,
|
2025-09-05 07:06:04 +09:00
|
|
|
|
});
|
2025-10-22 17:51:03 -07:00
|
|
|
|
dragBuffer = '';
|
2025-09-05 07:06:04 +09:00
|
|
|
|
}
|
2025-08-15 10:54:00 -07:00
|
|
|
|
};
|
2025-08-22 19:31:55 -04:00
|
|
|
|
}, [
|
|
|
|
|
|
stdin,
|
|
|
|
|
|
setRawMode,
|
|
|
|
|
|
kittyProtocolEnabled,
|
|
|
|
|
|
config,
|
|
|
|
|
|
debugKeystrokeLogging,
|
2025-10-22 17:51:03 -07:00
|
|
|
|
broadcast,
|
2025-08-22 19:31:55 -04:00
|
|
|
|
]);
|
2025-08-15 10:54:00 -07:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<KeypressContext.Provider value={{ subscribe, unsubscribe }}>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</KeypressContext.Provider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|