mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 13:34:15 -07:00
alternate buffer support (#12471)
This commit is contained in:
@@ -188,6 +188,40 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle lone Escape key (keycode 27) with timeout when kitty protocol is enabled', async () => {
|
||||
// Use real timers for this test to avoid issues with stream/buffer timing
|
||||
vi.useRealTimers();
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider kittyProtocolEnabled={true}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
// Send just ESC
|
||||
act(() => {
|
||||
stdin.write('\x1b');
|
||||
});
|
||||
|
||||
// Should be buffered initially
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for timeout
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'escape',
|
||||
meta: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab and Backspace handling', () => {
|
||||
@@ -350,13 +384,13 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
act(() => stdin.write('\x1b[27u'));
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[DEBUG] Kitty buffer accumulating:',
|
||||
'[DEBUG] Input buffer accumulating:',
|
||||
expect.stringContaining('"\\u001b[27u"'),
|
||||
);
|
||||
const parsedCall = consoleLogSpy.mock.calls.find(
|
||||
(args) =>
|
||||
typeof args[0] === 'string' &&
|
||||
args[0].includes('[DEBUG] Kitty sequence parsed successfully'),
|
||||
args[0].includes('[DEBUG] Sequence parsed successfully'),
|
||||
);
|
||||
expect(parsedCall).toBeTruthy();
|
||||
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\\u001b[27u'));
|
||||
@@ -383,7 +417,7 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
act(() => stdin.write(longSequence));
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[DEBUG] Kitty buffer overflow, clearing:',
|
||||
'[DEBUG] Input buffer overflow, clearing:',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
@@ -410,7 +444,7 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
act(() => stdin.write('\x03'));
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[DEBUG] Kitty buffer cleared on Ctrl+C:',
|
||||
'[DEBUG] Input buffer cleared on Ctrl+C:',
|
||||
INCOMPLETE_KITTY_SEQUENCE,
|
||||
);
|
||||
|
||||
@@ -444,13 +478,13 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
|
||||
// Verify debug logging for accumulation
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[DEBUG] Kitty buffer accumulating:',
|
||||
'[DEBUG] Input buffer accumulating:',
|
||||
JSON.stringify(INCOMPLETE_KITTY_SEQUENCE),
|
||||
);
|
||||
|
||||
// Verify warning for char codes
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Kitty sequence buffer has content:',
|
||||
'Input sequence buffer has content:',
|
||||
JSON.stringify(INCOMPLETE_KITTY_SEQUENCE),
|
||||
);
|
||||
});
|
||||
@@ -1164,4 +1198,179 @@ describe('Kitty Sequence Parsing', () => {
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('SGR Mouse Handling', () => {
|
||||
it('should ignore SGR mouse sequences', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
// Send various SGR mouse sequences
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M'); // Mouse press
|
||||
stdin.write('\x1b[<0;10;20m'); // Mouse release
|
||||
stdin.write('\x1b[<32;30;40M'); // Mouse drag
|
||||
stdin.write('\x1b[<64;5;5M'); // Scroll up
|
||||
});
|
||||
|
||||
// Should not broadcast any of these as keystrokes
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle mixed SGR mouse and key sequences', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
// Send mouse event then a key press
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
stdin.write('a');
|
||||
});
|
||||
|
||||
// Should only broadcast 'a'
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'a',
|
||||
sequence: 'a',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore X11 mouse sequences', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
// Send X11 mouse sequence: ESC [ M followed by 3 bytes
|
||||
// Space is 32. 32+0=32 (button 0), 32+33=65 ('A', col 33), 32+34=66 ('B', row 34)
|
||||
const x11Seq = '\x1b[M AB';
|
||||
|
||||
act(() => {
|
||||
stdin.write(x11Seq);
|
||||
});
|
||||
|
||||
// Should not broadcast as keystrokes
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not flush slow SGR mouse sequences as garbage', async () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
// Send start of SGR sequence
|
||||
act(() => stdin.write('\x1b[<'));
|
||||
|
||||
// Advance time past the normal kitty timeout (50ms)
|
||||
act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10));
|
||||
|
||||
// Send the rest
|
||||
act(() => stdin.write('0;37;25M'));
|
||||
|
||||
// Should NOT have flushed the prefix as garbage, and should have consumed the whole thing
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should ignore specific SGR mouse sequence sandwiched between keystrokes', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => {
|
||||
stdin.write('H');
|
||||
stdin.write('\x1b[<64;96;8M');
|
||||
stdin.write('I');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: 'h', sequence: 'H', shift: true }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ name: 'i', sequence: 'I', shift: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ignored Sequences', () => {
|
||||
describe.each([true, false])(
|
||||
'with kittyProtocolEnabled = %s',
|
||||
(kittyEnabled) => {
|
||||
it.each([
|
||||
{ name: 'Focus In', sequence: '\x1b[I' },
|
||||
{ name: 'Focus Out', sequence: '\x1b[O' },
|
||||
{ name: 'SGR Mouse Release', sequence: '\u001b[<0;44;18m' },
|
||||
{ name: 'something mouse', sequence: '\u001b[<0;53;19M' },
|
||||
{ name: 'another mouse', sequence: '\u001b[<0;29;19m' },
|
||||
])('should ignore $name sequence', async ({ sequence }) => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyEnabled}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper,
|
||||
});
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
for (const char of sequence) {
|
||||
act(() => {
|
||||
stdin.write(char);
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
}
|
||||
|
||||
act(() => {
|
||||
stdin.write('HI');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: 'h', sequence: 'H', shift: true }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ name: 'i', sequence: 'I', shift: true }),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle F12 when kittyProtocolEnabled is false', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => {
|
||||
stdin.write('\u001b[24~');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'f12', sequence: '\u001b[24~' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,9 +37,10 @@ import {
|
||||
MODIFIER_CTRL_BIT,
|
||||
} from '../utils/platformConstants.js';
|
||||
|
||||
import { ESC, couldBeMouseSequence } from '../utils/input.js';
|
||||
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
|
||||
import { isIncompleteMouseSequence, parseMouseEvent } from '../utils/mouse.js';
|
||||
|
||||
const ESC = '\u001B';
|
||||
export const PASTE_MODE_START = `${ESC}[200~`;
|
||||
export const PASTE_MODE_END = `${ESC}[201~`;
|
||||
export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input
|
||||
@@ -108,6 +109,8 @@ function couldBeKittySequence(buffer: string): boolean {
|
||||
|
||||
if (!buffer.startsWith(`${ESC}[`)) return false;
|
||||
|
||||
if (couldBeMouseSequence(buffer)) return true;
|
||||
|
||||
// Check for known kitty sequence patterns:
|
||||
// 1. ESC[<digit> - could be CSI-u or tilde-coded
|
||||
// 2. ESC[1;<digit> - parameterized functional
|
||||
@@ -256,7 +259,7 @@ function parseKittyPrefix(buffer: string): { key: Key; length: number } | null {
|
||||
shift,
|
||||
paste: false,
|
||||
sequence: buffer.slice(0, m[0].length),
|
||||
kittyProtocol: true,
|
||||
kittyProtocol: false,
|
||||
},
|
||||
length: m[0].length,
|
||||
};
|
||||
@@ -324,7 +327,7 @@ function parseKittyPrefix(buffer: string): { key: Key; length: number } | null {
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: buffer.slice(0, m[0].length),
|
||||
kittyProtocol: true,
|
||||
kittyProtocol: false,
|
||||
},
|
||||
length: m[0].length,
|
||||
};
|
||||
@@ -505,9 +508,9 @@ export function KeypressProvider({
|
||||
// Used to turn "\" quickly followed by a "enter" into a shift enter
|
||||
let backslashTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
// Buffers incomplete Kitty sequences and timer to flush it
|
||||
let kittySequenceBuffer = '';
|
||||
let kittySequenceTimeout: NodeJS.Timeout | null = null;
|
||||
// Buffers incomplete sequences (Kitty or Mouse) and timer to flush it
|
||||
let inputBuffer = '';
|
||||
let inputTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
// Used to detect filename drag-and-drops.
|
||||
let dragBuffer = '';
|
||||
@@ -520,12 +523,12 @@ export function KeypressProvider({
|
||||
}
|
||||
};
|
||||
|
||||
const flushKittyBufferOnInterrupt = (reason: string) => {
|
||||
if (kittySequenceBuffer) {
|
||||
const flushInputBufferOnInterrupt = (reason: string) => {
|
||||
if (inputBuffer) {
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
`[DEBUG] Kitty sequence flushed due to ${reason}:`,
|
||||
JSON.stringify(kittySequenceBuffer),
|
||||
`[DEBUG] Input sequence flushed due to ${reason}:`,
|
||||
JSON.stringify(inputBuffer),
|
||||
);
|
||||
}
|
||||
broadcast({
|
||||
@@ -534,23 +537,23 @@ export function KeypressProvider({
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: kittySequenceBuffer,
|
||||
sequence: inputBuffer,
|
||||
});
|
||||
kittySequenceBuffer = '';
|
||||
inputBuffer = '';
|
||||
}
|
||||
if (kittySequenceTimeout) {
|
||||
clearTimeout(kittySequenceTimeout);
|
||||
kittySequenceTimeout = null;
|
||||
if (inputTimeout) {
|
||||
clearTimeout(inputTimeout);
|
||||
inputTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeypress = (_: unknown, key: Key) => {
|
||||
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
|
||||
flushKittyBufferOnInterrupt('focus event');
|
||||
flushInputBufferOnInterrupt('focus event');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'paste-start') {
|
||||
flushKittyBufferOnInterrupt('paste start');
|
||||
flushInputBufferOnInterrupt('paste start');
|
||||
pasteBuffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
@@ -649,16 +652,16 @@ export function KeypressProvider({
|
||||
(key.ctrl && key.name === 'c') ||
|
||||
key.sequence === `${ESC}${KITTY_CTRL_C}`
|
||||
) {
|
||||
if (kittySequenceBuffer && debugKeystrokeLogging) {
|
||||
if (inputBuffer && debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Kitty buffer cleared on Ctrl+C:',
|
||||
kittySequenceBuffer,
|
||||
'[DEBUG] Input buffer cleared on Ctrl+C:',
|
||||
inputBuffer,
|
||||
);
|
||||
}
|
||||
kittySequenceBuffer = '';
|
||||
if (kittySequenceTimeout) {
|
||||
clearTimeout(kittySequenceTimeout);
|
||||
kittySequenceTimeout = null;
|
||||
inputBuffer = '';
|
||||
if (inputTimeout) {
|
||||
clearTimeout(inputTimeout);
|
||||
inputTimeout = null;
|
||||
}
|
||||
if (key.sequence === `${ESC}${KITTY_CTRL_C}`) {
|
||||
broadcast({
|
||||
@@ -676,153 +679,214 @@ export function KeypressProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
if (kittyProtocolEnabled) {
|
||||
// Clear any pending timeout when new input arrives
|
||||
if (kittySequenceTimeout) {
|
||||
clearTimeout(kittySequenceTimeout);
|
||||
kittySequenceTimeout = null;
|
||||
// Clear any pending timeout when new input arrives
|
||||
if (inputTimeout) {
|
||||
clearTimeout(inputTimeout);
|
||||
inputTimeout = null;
|
||||
}
|
||||
|
||||
// Always check if this could start a sequence we need to buffer (Kitty or Mouse)
|
||||
// We only want to intercept if it starts with ESC[ (CSI) or is EXACTLY ESC (waiting for more).
|
||||
// Other ESC sequences (like Alt+Key which is ESC+Key) should be let through if readline parsed them.
|
||||
const isCSI = key.sequence.startsWith(`${ESC}[`);
|
||||
const isExactEsc = key.sequence === ESC;
|
||||
const shouldBuffer = isCSI || isExactEsc;
|
||||
|
||||
const isExcluded = [
|
||||
PASTE_MODE_START,
|
||||
PASTE_MODE_END,
|
||||
FOCUS_IN,
|
||||
FOCUS_OUT,
|
||||
].some((prefix) => key.sequence.startsWith(prefix));
|
||||
|
||||
if (inputBuffer || (shouldBuffer && !isExcluded)) {
|
||||
inputBuffer += key.sequence;
|
||||
|
||||
if (debugKeystrokeLogging && !couldBeMouseSequence(inputBuffer)) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Input buffer accumulating:',
|
||||
JSON.stringify(inputBuffer),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this could start a kitty sequence
|
||||
const startsWithEsc = key.sequence.startsWith(ESC);
|
||||
const isExcluded = [
|
||||
PASTE_MODE_START,
|
||||
PASTE_MODE_END,
|
||||
FOCUS_IN,
|
||||
FOCUS_OUT,
|
||||
].some((prefix) => key.sequence.startsWith(prefix));
|
||||
// Try immediate parsing
|
||||
let remainingBuffer = inputBuffer;
|
||||
let parsedAny = false;
|
||||
|
||||
if (kittySequenceBuffer || (startsWithEsc && !isExcluded)) {
|
||||
kittySequenceBuffer += key.sequence;
|
||||
while (remainingBuffer) {
|
||||
const parsed = parseKittyPrefix(remainingBuffer);
|
||||
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Kitty buffer accumulating:',
|
||||
JSON.stringify(kittySequenceBuffer),
|
||||
);
|
||||
}
|
||||
|
||||
// Try immediate parsing
|
||||
let remainingBuffer = kittySequenceBuffer;
|
||||
let parsedAny = false;
|
||||
|
||||
while (remainingBuffer) {
|
||||
const parsed = parseKittyPrefix(remainingBuffer);
|
||||
|
||||
if (parsed) {
|
||||
if (parsed) {
|
||||
// If kitty protocol is disabled, only allow legacy/standard sequences.
|
||||
// parseKittyPrefix returns true for kittyProtocol if it's a modern kitty sequence.
|
||||
if (kittyProtocolEnabled || !parsed.key.kittyProtocol) {
|
||||
if (debugKeystrokeLogging) {
|
||||
const parsedSequence = remainingBuffer.slice(0, parsed.length);
|
||||
debugLogger.log(
|
||||
'[DEBUG] Kitty sequence parsed successfully:',
|
||||
'[DEBUG] 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);
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Dropping incomplete sequence before next ESC:',
|
||||
JSON.stringify(garbage),
|
||||
);
|
||||
}
|
||||
// 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
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
debugLogger.log(
|
||||
'[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) {
|
||||
debugLogger.log(
|
||||
'[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;
|
||||
} else {
|
||||
if (config?.getDebugMode() || debugKeystrokeLogging) {
|
||||
debugLogger.warn(
|
||||
'Kitty sequence buffer has content:',
|
||||
JSON.stringify(kittySequenceBuffer),
|
||||
);
|
||||
}
|
||||
// Could be valid but incomplete - set timeout
|
||||
kittySequenceTimeout = setTimeout(() => {
|
||||
if (kittySequenceBuffer) {
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[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;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
kittySequenceBuffer = remainingBuffer;
|
||||
if (parsedAny || kittySequenceBuffer) return;
|
||||
const mouseParsed = parseMouseEvent(remainingBuffer);
|
||||
if (mouseParsed) {
|
||||
// These are handled by the separate mouse sequence parser.
|
||||
// All we need to do is make sure we don't get confused by these
|
||||
// sequences.
|
||||
remainingBuffer = remainingBuffer.slice(mouseParsed.length);
|
||||
parsedAny = true;
|
||||
continue;
|
||||
}
|
||||
// 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);
|
||||
|
||||
// Special case: if garbage is exactly ESC, it's likely a rapid ESC press.
|
||||
if (garbage === ESC) {
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Flushing rapid ESC before next ESC:',
|
||||
JSON.stringify(garbage),
|
||||
);
|
||||
}
|
||||
broadcast({
|
||||
name: 'escape',
|
||||
ctrl: false,
|
||||
meta: true,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: garbage,
|
||||
});
|
||||
} else {
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Dropping incomplete sequence before next ESC:',
|
||||
JSON.stringify(garbage),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue parsing from next ESC
|
||||
remainingBuffer = remainingBuffer.slice(nextEscIndex);
|
||||
// We made progress, so we can continue the loop to parse the next sequence
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if buffer could become a valid sequence
|
||||
const couldBeValidKitty =
|
||||
kittyProtocolEnabled && couldBeKittySequence(remainingBuffer);
|
||||
const isMouse = isIncompleteMouseSequence(remainingBuffer);
|
||||
const couldBeValid = couldBeValidKitty || isMouse;
|
||||
|
||||
if (!couldBeValid) {
|
||||
// Not a valid sequence - flush as regular input immediately
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Not a valid 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) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Input buffer overflow, clearing:',
|
||||
JSON.stringify(remainingBuffer),
|
||||
);
|
||||
}
|
||||
if (config && kittyProtocolEnabled) {
|
||||
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;
|
||||
} else {
|
||||
if (
|
||||
(config?.getDebugMode() || debugKeystrokeLogging) &&
|
||||
!couldBeMouseSequence(inputBuffer)
|
||||
) {
|
||||
debugLogger.warn(
|
||||
'Input sequence buffer has content:',
|
||||
JSON.stringify(inputBuffer),
|
||||
);
|
||||
}
|
||||
// Could be valid but incomplete - set timeout
|
||||
// Only set timeout if it's NOT a mouse sequence.
|
||||
// Mouse sequences might be slow (e.g. over network) and we don't want to
|
||||
// flush them as garbage keypresses.
|
||||
// However, if it's just ESC or ESC[, it might be a user typing slowly,
|
||||
// so we should still timeout in that case.
|
||||
const isAmbiguousPrefix =
|
||||
remainingBuffer === ESC || remainingBuffer === `${ESC}[`;
|
||||
|
||||
if (!isMouse || isAmbiguousPrefix) {
|
||||
inputTimeout = setTimeout(() => {
|
||||
if (inputBuffer) {
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Input sequence timeout, flushing:',
|
||||
JSON.stringify(inputBuffer),
|
||||
);
|
||||
}
|
||||
const isEscape = inputBuffer === ESC;
|
||||
broadcast({
|
||||
name: isEscape ? 'escape' : '',
|
||||
ctrl: false,
|
||||
meta: isEscape,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: inputBuffer,
|
||||
});
|
||||
inputBuffer = '';
|
||||
}
|
||||
inputTimeout = null;
|
||||
}, KITTY_SEQUENCE_TIMEOUT_MS);
|
||||
} else {
|
||||
// It IS a mouse sequence and it's long enough to be unambiguously NOT just a user hitting ESC slowly.
|
||||
// We just wait for more data.
|
||||
if (inputTimeout) {
|
||||
clearTimeout(inputTimeout);
|
||||
inputTimeout = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
inputBuffer = remainingBuffer;
|
||||
if (parsedAny || inputBuffer) return;
|
||||
}
|
||||
|
||||
if (key.name === 'return' && key.sequence === `${ESC}\r`) {
|
||||
@@ -880,22 +944,22 @@ export function KeypressProvider({
|
||||
backslashTimeout = null;
|
||||
}
|
||||
|
||||
if (kittySequenceTimeout) {
|
||||
clearTimeout(kittySequenceTimeout);
|
||||
kittySequenceTimeout = null;
|
||||
if (inputTimeout) {
|
||||
clearTimeout(inputTimeout);
|
||||
inputTimeout = null;
|
||||
}
|
||||
|
||||
// Flush any pending kitty sequence data to avoid data loss on exit.
|
||||
if (kittySequenceBuffer) {
|
||||
if (inputBuffer) {
|
||||
broadcast({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: kittySequenceBuffer,
|
||||
sequence: inputBuffer,
|
||||
});
|
||||
kittySequenceBuffer = '';
|
||||
inputBuffer = '';
|
||||
}
|
||||
|
||||
// Flush any pending paste data to avoid data loss on exit.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { act } from 'react';
|
||||
import { MouseProvider, useMouseContext, useMouse } from './MouseContext.js';
|
||||
import { vi, type Mock } from 'vitest';
|
||||
import type React from 'react';
|
||||
import { useStdin } from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Mock the 'ink' module to control stdin
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...original,
|
||||
useStdin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
class MockStdin extends EventEmitter {
|
||||
isTTY = true;
|
||||
setRawMode = vi.fn();
|
||||
override on = this.addListener;
|
||||
override removeListener = super.removeListener;
|
||||
resume = vi.fn();
|
||||
pause = vi.fn();
|
||||
|
||||
write(text: string) {
|
||||
this.emit('data', text);
|
||||
}
|
||||
}
|
||||
|
||||
describe('MouseContext', () => {
|
||||
let stdin: MockStdin;
|
||||
let wrapper: React.FC<{ children: React.ReactNode }>;
|
||||
|
||||
beforeEach(() => {
|
||||
stdin = new MockStdin();
|
||||
(useStdin as Mock).mockReturnValue({
|
||||
stdin,
|
||||
setRawMode: vi.fn(),
|
||||
});
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should subscribe and unsubscribe a handler', () => {
|
||||
const handler = vi.fn();
|
||||
const { result } = renderHook(() => useMouseContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(handler);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
result.current.unsubscribe(handler);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call handler if not active', () => {
|
||||
const handler = vi.fn();
|
||||
renderHook(() => useMouse(handler, { isActive: false }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
});
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('SGR Mouse Events', () => {
|
||||
it.each([
|
||||
{
|
||||
sequence: '\x1b[<0;10;20M',
|
||||
expected: {
|
||||
name: 'left-press',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: '\x1b[<0;10;20m',
|
||||
expected: {
|
||||
name: 'left-release',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: '\x1b[<2;10;20M',
|
||||
expected: {
|
||||
name: 'right-press',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: '\x1b[<1;10;20M',
|
||||
expected: {
|
||||
name: 'middle-press',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: '\x1b[<64;10;20M',
|
||||
expected: {
|
||||
name: 'scroll-up',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: '\x1b[<65;10;20M',
|
||||
expected: {
|
||||
name: 'scroll-down',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: '\x1b[<32;10;20M',
|
||||
expected: {
|
||||
name: 'move',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: '\x1b[<4;10;20M',
|
||||
expected: { name: 'left-press', shift: true },
|
||||
}, // Shift + left press
|
||||
{
|
||||
sequence: '\x1b[<8;10;20M',
|
||||
expected: { name: 'left-press', meta: true },
|
||||
}, // Alt + left press
|
||||
{
|
||||
sequence: '\x1b[<20;10;20M',
|
||||
expected: { name: 'left-press', ctrl: true, shift: true },
|
||||
}, // Ctrl + Shift + left press
|
||||
{
|
||||
sequence: '\x1b[<68;10;20M',
|
||||
expected: { name: 'scroll-up', shift: true },
|
||||
}, // Shift + scroll up
|
||||
])(
|
||||
'should recognize sequence "$sequence" as $expected.name',
|
||||
({ sequence, expected }) => {
|
||||
const mouseHandler = vi.fn();
|
||||
const { result } = renderHook(() => useMouseContext(), { wrapper });
|
||||
act(() => result.current.subscribe(mouseHandler));
|
||||
|
||||
act(() => stdin.write(sequence));
|
||||
|
||||
expect(mouseHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ...expected }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useStdin } from 'ink';
|
||||
import type React from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { ESC } from '../utils/input.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
isIncompleteMouseSequence,
|
||||
parseMouseEvent,
|
||||
type MouseEvent,
|
||||
type MouseEventName,
|
||||
type MouseHandler,
|
||||
} from '../utils/mouse.js';
|
||||
|
||||
export type { MouseEvent, MouseEventName, MouseHandler };
|
||||
|
||||
const MAX_MOUSE_BUFFER_SIZE = 4096;
|
||||
|
||||
interface MouseContextValue {
|
||||
subscribe: (handler: MouseHandler) => void;
|
||||
unsubscribe: (handler: MouseHandler) => void;
|
||||
}
|
||||
|
||||
const MouseContext = createContext<MouseContextValue | undefined>(undefined);
|
||||
|
||||
export function useMouseContext() {
|
||||
const context = useContext(MouseContext);
|
||||
if (!context) {
|
||||
throw new Error('useMouseContext must be used within a MouseProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useMouse(handler: MouseHandler, { isActive = true } = {}) {
|
||||
const { subscribe, unsubscribe } = useMouseContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
subscribe(handler);
|
||||
return () => unsubscribe(handler);
|
||||
}, [isActive, handler, subscribe, unsubscribe]);
|
||||
}
|
||||
|
||||
export function MouseProvider({
|
||||
children,
|
||||
mouseEventsEnabled,
|
||||
debugKeystrokeLogging,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
mouseEventsEnabled?: boolean;
|
||||
debugKeystrokeLogging?: boolean;
|
||||
}) {
|
||||
const { stdin } = useStdin();
|
||||
const subscribers = useRef<Set<MouseHandler>>(new Set()).current;
|
||||
|
||||
const subscribe = useCallback(
|
||||
(handler: MouseHandler) => {
|
||||
subscribers.add(handler);
|
||||
},
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
const unsubscribe = useCallback(
|
||||
(handler: MouseHandler) => {
|
||||
subscribers.delete(handler);
|
||||
},
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mouseEventsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mouseBuffer = '';
|
||||
|
||||
const broadcast = (event: MouseEvent) => {
|
||||
for (const handler of subscribers) {
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleData = (data: Buffer | string) => {
|
||||
mouseBuffer += typeof data === 'string' ? data : data.toString('utf-8');
|
||||
|
||||
// Safety cap to prevent infinite buffer growth on garbage
|
||||
if (mouseBuffer.length > MAX_MOUSE_BUFFER_SIZE) {
|
||||
mouseBuffer = mouseBuffer.slice(-MAX_MOUSE_BUFFER_SIZE);
|
||||
}
|
||||
|
||||
while (mouseBuffer.length > 0) {
|
||||
const parsed = parseMouseEvent(mouseBuffer);
|
||||
|
||||
if (parsed) {
|
||||
if (debugKeystrokeLogging) {
|
||||
debugLogger.log(
|
||||
'[DEBUG] Mouse event parsed:',
|
||||
JSON.stringify(parsed.event),
|
||||
);
|
||||
}
|
||||
broadcast(parsed.event);
|
||||
mouseBuffer = mouseBuffer.slice(parsed.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isIncompleteMouseSequence(mouseBuffer)) {
|
||||
break; // Wait for more data
|
||||
}
|
||||
|
||||
// Not a valid sequence at start, and not waiting for more data.
|
||||
// Discard garbage until next possible sequence start.
|
||||
const nextEsc = mouseBuffer.indexOf(ESC, 1);
|
||||
if (nextEsc !== -1) {
|
||||
mouseBuffer = mouseBuffer.slice(nextEsc);
|
||||
// Loop continues to try parsing at new location
|
||||
} else {
|
||||
mouseBuffer = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stdin.on('data', handleData);
|
||||
|
||||
return () => {
|
||||
stdin.removeListener('data', handleData);
|
||||
};
|
||||
}, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]);
|
||||
|
||||
return (
|
||||
<MouseContext.Provider value={{ subscribe, unsubscribe }}>
|
||||
{children}
|
||||
</MouseContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user