alternate buffer support (#12471)

This commit is contained in:
Jacob Richman
2025-11-03 13:41:58 -08:00
committed by GitHub
parent 60973aacd9
commit 4fc9b1cde2
26 changed files with 1893 additions and 257 deletions
@@ -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~' }),
);
});
});
});
+224 -160
View File
@@ -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>
);
}