mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 00:21:09 -07:00
fix(ux) keyboard input hangs while waiting for keyboard input. (#10121)
This commit is contained in:
@@ -73,12 +73,14 @@ export const renderWithProviders = (
|
||||
settings = mockSettings,
|
||||
uiState: providedUiState,
|
||||
width,
|
||||
kittyProtocolEnabled = true,
|
||||
config = configProxy as unknown as Config,
|
||||
}: {
|
||||
shellFocus?: boolean;
|
||||
settings?: LoadedSettings;
|
||||
uiState?: Partial<UIState>;
|
||||
width?: number;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
config?: Config;
|
||||
} = {},
|
||||
): ReturnType<typeof render> => {
|
||||
@@ -115,7 +117,7 @@ export const renderWithProviders = (
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<KeypressProvider kittyProtocolEnabled={true}>
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
{component}
|
||||
</KeypressProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { waitFor, act } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { FolderTrustDialog } from './FolderTrustDialog.js';
|
||||
import * as processUtils from '../../utils/processUtils.js';
|
||||
@@ -50,7 +50,9 @@ describe('FolderTrustDialog', () => {
|
||||
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
|
||||
);
|
||||
|
||||
stdin.write('\x1b'); // escape key
|
||||
act(() => {
|
||||
stdin.write('\u001b[27u'); // Press kitty escape key
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
@@ -87,7 +89,9 @@ describe('FolderTrustDialog', () => {
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
|
||||
);
|
||||
|
||||
stdin.write('r');
|
||||
act(() => {
|
||||
stdin.write('r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedExit).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1616,6 +1616,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: true },
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
@@ -1661,6 +1662,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -1682,6 +1684,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
|
||||
stdin.write('\x1B');
|
||||
@@ -1703,6 +1706,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -1722,6 +1726,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -1733,23 +1738,27 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it('should not call onEscapePromptChange when not provided', async () => {
|
||||
vi.useFakeTimers();
|
||||
props.onEscapePromptChange = undefined;
|
||||
props.buffer.setText('some text');
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
await wait();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
stdin.write('\x1B');
|
||||
await wait();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not interfere with existing keyboard shortcuts', async () => {
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -1821,6 +1830,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\x12');
|
||||
await wait();
|
||||
stdin.write('\x1B');
|
||||
stdin.write('\u001b[27u'); // Press kitty escape key
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
@@ -1922,7 +1932,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\x12');
|
||||
await wait();
|
||||
expect(stdout.lastFrame()).toContain('(r:)');
|
||||
stdin.write('\x1B');
|
||||
stdin.write('\u001b[27u'); // Press kitty escape key
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
||||
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x1b'); // escape key
|
||||
stdin.write('\u001b[27u'); // Kitty escape key
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -201,7 +201,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
||||
|
||||
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
|
||||
|
||||
act(() => stdin.write('\x1b')); // Press escape
|
||||
act(() => stdin.write('\u001b[27u')); // Press kitty escape key
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCommitTrustLevelChange).not.toHaveBeenCalled();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
KeypressProvider,
|
||||
useKeypressContext,
|
||||
DRAG_COMPLETION_TIMEOUT_MS,
|
||||
KITTY_SEQUENCE_TIMEOUT_MS,
|
||||
// CSI_END_O,
|
||||
// SS3_END,
|
||||
SINGLE_QUOTE,
|
||||
@@ -70,7 +71,7 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
children: React.ReactNode;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
}) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled ?? false}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
@@ -476,7 +477,7 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[DEBUG] Kitty buffer accumulating:',
|
||||
expect.stringContaining('\x1b[27u'),
|
||||
expect.stringContaining('"\\u001b[27u"'),
|
||||
);
|
||||
const parsedCall = consoleLogSpy.mock.calls.find(
|
||||
(args) =>
|
||||
@@ -484,7 +485,7 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
args[0].includes('[DEBUG] Kitty sequence parsed successfully'),
|
||||
);
|
||||
expect(parsedCall).toBeTruthy();
|
||||
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u'));
|
||||
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\\u001b[27u'));
|
||||
});
|
||||
|
||||
it('should log kitty buffer overflow when debugKeystrokeLogging is true', async () => {
|
||||
@@ -505,10 +506,10 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send an invalid long sequence to trigger overflow
|
||||
const longInvalidSequence = '\x1b[' + 'x'.repeat(100);
|
||||
// Send a long sequence starting with a valid kitty prefix to trigger overflow
|
||||
const longSequence = '\x1b[1;' + '1'.repeat(100);
|
||||
act(() => {
|
||||
stdin.sendKittySequence(longInvalidSequence);
|
||||
stdin.sendKittySequence(longSequence);
|
||||
});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
@@ -604,13 +605,13 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
// Verify debug logging for accumulation
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[DEBUG] Kitty buffer accumulating:',
|
||||
sequence,
|
||||
JSON.stringify(sequence),
|
||||
);
|
||||
|
||||
// Verify warning for char codes
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Kitty sequence buffer has char codes:',
|
||||
[27, 91, 49, 50],
|
||||
'Kitty sequence buffer has content:',
|
||||
JSON.stringify(sequence),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -753,8 +754,16 @@ describe('Drag and Drop Handling', () => {
|
||||
let stdin: MockStdin;
|
||||
const mockSetRawMode = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider kittyProtocolEnabled={true}>{children}</KeypressProvider>
|
||||
const wrapper = ({
|
||||
children,
|
||||
kittyProtocolEnabled = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
}) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -957,16 +966,25 @@ describe('Drag and Drop Handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal-specific Alt+key combinations', () => {
|
||||
describe('Kitty Sequence Parsing', () => {
|
||||
let stdin: MockStdin;
|
||||
const mockSetRawMode = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider kittyProtocolEnabled={true}>{children}</KeypressProvider>
|
||||
const wrapper = ({
|
||||
children,
|
||||
kittyProtocolEnabled = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
}) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
stdin = new MockStdin();
|
||||
(useStdin as Mock).mockReturnValue({
|
||||
stdin,
|
||||
@@ -974,6 +992,10 @@ describe('Terminal-specific Alt+key combinations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Terminals to test
|
||||
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
|
||||
|
||||
@@ -1009,6 +1031,7 @@ describe('Terminal-specific Alt+key combinations', () => {
|
||||
return {
|
||||
terminal,
|
||||
key,
|
||||
kitty: false,
|
||||
input: {
|
||||
sequence: `\x1b${key}`,
|
||||
name: key,
|
||||
@@ -1059,13 +1082,22 @@ describe('Terminal-specific Alt+key combinations', () => {
|
||||
kittySequence,
|
||||
input,
|
||||
expected,
|
||||
kitty = true,
|
||||
}: {
|
||||
kittySequence?: string;
|
||||
input?: Partial<Key>;
|
||||
expected: Partial<Key>;
|
||||
kitty?: boolean;
|
||||
}) => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
const testWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kitty}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: testWrapper,
|
||||
});
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
if (kittySequence) {
|
||||
@@ -1118,4 +1150,502 @@ describe('Terminal-specific Alt+key combinations', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should timeout and flush incomplete kitty sequences after 50ms', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send incomplete kitty sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x1b[1;',
|
||||
});
|
||||
});
|
||||
|
||||
// Should not broadcast immediately
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Advance time just before timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS - 5);
|
||||
});
|
||||
|
||||
// Still shouldn't broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
|
||||
// Should now broadcast the incomplete sequence as regular input
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: '\x1b[1;',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should immediately flush non-kitty CSI sequences', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send a CSI sequence that doesn't match kitty patterns
|
||||
// ESC[m is SGR reset, not a kitty sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x1b[m',
|
||||
});
|
||||
});
|
||||
|
||||
// Should broadcast immediately as it's not a valid kitty pattern
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: '\x1b[m',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse valid kitty sequences immediately when complete', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send complete kitty sequence for Ctrl+A
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x1b[97;5u',
|
||||
});
|
||||
});
|
||||
|
||||
// Should parse and broadcast immediately
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'a',
|
||||
ctrl: true,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle batched kitty sequences correctly', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send multiple kitty sequences at once
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x1b[97;5u\x1b[98;5u', // Ctrl+a followed by Ctrl+b
|
||||
});
|
||||
});
|
||||
|
||||
// Should parse both sequences
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
name: 'a',
|
||||
ctrl: true,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
name: 'b',
|
||||
ctrl: true,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear kitty buffer and timeout on Ctrl+C', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send incomplete kitty sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x1b[1;',
|
||||
});
|
||||
});
|
||||
|
||||
// Press Ctrl+C
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: 'c',
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x03',
|
||||
});
|
||||
});
|
||||
|
||||
// Advance past timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10);
|
||||
});
|
||||
|
||||
// Should only have received Ctrl+C, not the incomplete sequence
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'c',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid sequences', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send valid kitty sequence followed by invalid CSI
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x1b[13u\x1b[!', // Valid enter, then invalid sequence
|
||||
});
|
||||
});
|
||||
|
||||
// Should parse valid sequence and flush invalid immediately
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: '\x1b[!',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not buffer sequences when kitty protocol is disabled', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, kittyProtocolEnabled: false }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send what would be a kitty sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x1b[13u',
|
||||
});
|
||||
});
|
||||
|
||||
// Should pass through without parsing
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sequence: '\x1b[13u',
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle sequences arriving character by character', async () => {
|
||||
vi.useRealTimers(); // Required for correct buffering timing.
|
||||
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send kitty sequence character by character
|
||||
const sequence = '\x1b[27u'; // Escape key
|
||||
for (const char of sequence) {
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from(char));
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
// Should parse once complete
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'escape',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset timeout when new input arrives', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Start incomplete sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x1b[1',
|
||||
});
|
||||
});
|
||||
|
||||
// Advance time partway
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30);
|
||||
});
|
||||
|
||||
// Add more to sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '3',
|
||||
});
|
||||
});
|
||||
|
||||
// Advance time from the first timeout point
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(25);
|
||||
});
|
||||
|
||||
// Should not have timed out yet (timeout restarted)
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Complete the sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: 'u',
|
||||
});
|
||||
});
|
||||
|
||||
// Should now parse as complete enter key
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should flush incomplete kitty sequence on FOCUS_IN event', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send incomplete kitty sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
sequence: '\x1b[1;',
|
||||
});
|
||||
});
|
||||
|
||||
// Incomplete sequence should be buffered, not broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Send FOCUS_IN event
|
||||
const FOCUS_IN = '\x1b[I';
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
sequence: FOCUS_IN,
|
||||
});
|
||||
});
|
||||
|
||||
// The buffered sequence should be flushed
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: '\x1b[1;',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should flush incomplete kitty sequence on FOCUS_OUT event', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send incomplete kitty sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
sequence: '\x1b[1;',
|
||||
});
|
||||
});
|
||||
|
||||
// Incomplete sequence should be buffered, not broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Send FOCUS_OUT event
|
||||
const FOCUS_OUT = '\x1b[O';
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
sequence: FOCUS_OUT,
|
||||
});
|
||||
});
|
||||
|
||||
// The buffered sequence should be flushed
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: '\x1b[1;',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should flush incomplete kitty sequence on paste event', async () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send incomplete kitty sequence
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
sequence: '\x1b[1;',
|
||||
});
|
||||
});
|
||||
|
||||
// Incomplete sequence should be buffered, not broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Send paste start sequence
|
||||
const PASTE_MODE_PREFIX = `\x1b[200~`;
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from(PASTE_MODE_PREFIX));
|
||||
});
|
||||
|
||||
// The buffered sequence should be flushed
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: '\x1b[1;',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Now send some paste content and end paste to make sure paste still works
|
||||
const pastedText = 'hello';
|
||||
const PASTE_MODE_SUFFIX = `\x1b[201~`;
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from(pastedText));
|
||||
stdin.emit('data', Buffer.from(PASTE_MODE_SUFFIX));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
// The paste event should be broadcast
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: pastedText,
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ const ESC = '\u001B';
|
||||
export const PASTE_MODE_PREFIX = `${ESC}[200~`;
|
||||
export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
|
||||
export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input
|
||||
export const KITTY_SEQUENCE_TIMEOUT_MS = 50; // Flush incomplete kitty sequences after 50ms
|
||||
export const SINGLE_QUOTE = "'";
|
||||
export const DOUBLE_QUOTE = '"';
|
||||
|
||||
@@ -163,9 +164,39 @@ export function KeypressProvider({
|
||||
let isPaste = false;
|
||||
let pasteBuffer = Buffer.alloc(0);
|
||||
let kittySequenceBuffer = '';
|
||||
let kittySequenceTimeout: NodeJS.Timeout | null = null;
|
||||
let backslashTimeout: NodeJS.Timeout | null = null;
|
||||
let waitingForEnterAfterBackslash = false;
|
||||
|
||||
// Check if a buffer could potentially be a valid kitty sequence or its prefix
|
||||
const 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;
|
||||
};
|
||||
|
||||
// Parse a single complete kitty sequence from the start (prefix) of the
|
||||
// buffer and return both the Key and the number of characters consumed.
|
||||
// This lets us "peel off" one complete event when multiple sequences arrive
|
||||
@@ -416,11 +447,37 @@ export function KeypressProvider({
|
||||
}
|
||||
};
|
||||
|
||||
const flushKittyBufferOnInterrupt = (reason: string) => {
|
||||
if (kittySequenceBuffer) {
|
||||
if (debugKeystrokeLogging) {
|
||||
console.log(
|
||||
`[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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeypress = (_: unknown, key: Key) => {
|
||||
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
|
||||
flushKittyBufferOnInterrupt('focus event');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'paste-start') {
|
||||
flushKittyBufferOnInterrupt('paste start');
|
||||
isPaste = true;
|
||||
return;
|
||||
}
|
||||
@@ -534,6 +591,10 @@ export function KeypressProvider({
|
||||
);
|
||||
}
|
||||
kittySequenceBuffer = '';
|
||||
if (kittySequenceTimeout) {
|
||||
clearTimeout(kittySequenceTimeout);
|
||||
kittySequenceTimeout = null;
|
||||
}
|
||||
if (key.sequence === `${ESC}${KITTY_CTRL_C}`) {
|
||||
broadcast({
|
||||
name: 'c',
|
||||
@@ -551,94 +612,151 @@ export function KeypressProvider({
|
||||
}
|
||||
|
||||
if (kittyProtocolEnabled) {
|
||||
if (
|
||||
kittySequenceBuffer ||
|
||||
(key.sequence.startsWith(`${ESC}[`) &&
|
||||
!key.sequence.startsWith(PASTE_MODE_PREFIX) &&
|
||||
!key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
|
||||
!key.sequence.startsWith(FOCUS_IN) &&
|
||||
!key.sequence.startsWith(FOCUS_OUT))
|
||||
) {
|
||||
// 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 = [
|
||||
PASTE_MODE_PREFIX,
|
||||
PASTE_MODE_SUFFIX,
|
||||
FOCUS_IN,
|
||||
FOCUS_OUT,
|
||||
].some((prefix) => key.sequence.startsWith(prefix));
|
||||
|
||||
if (kittySequenceBuffer || (startsWithEsc && !isExcluded)) {
|
||||
kittySequenceBuffer += key.sequence;
|
||||
|
||||
if (debugKeystrokeLogging) {
|
||||
console.log(
|
||||
'[DEBUG] Kitty buffer accumulating:',
|
||||
kittySequenceBuffer,
|
||||
JSON.stringify(kittySequenceBuffer),
|
||||
);
|
||||
}
|
||||
|
||||
// Try to peel off as many complete sequences as are available at the
|
||||
// start of the buffer. This handles batched inputs cleanly. If the
|
||||
// prefix is incomplete or invalid, skip to the next CSI introducer
|
||||
// (ESC[) so that a following valid sequence can still be parsed.
|
||||
// Try immediate parsing
|
||||
let remainingBuffer = kittySequenceBuffer;
|
||||
let parsedAny = false;
|
||||
while (kittySequenceBuffer) {
|
||||
const parsed = parseKittyPrefix(kittySequenceBuffer);
|
||||
if (!parsed) {
|
||||
// Look for the next potential CSI start beyond index 0
|
||||
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
|
||||
if (nextStart > 0) {
|
||||
if (debugKeystrokeLogging) {
|
||||
console.log(
|
||||
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
|
||||
kittySequenceBuffer.slice(0, nextStart),
|
||||
);
|
||||
}
|
||||
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (debugKeystrokeLogging) {
|
||||
const parsedSequence = kittySequenceBuffer.slice(
|
||||
0,
|
||||
parsed.length,
|
||||
);
|
||||
if (kittySequenceBuffer.length > parsed.length) {
|
||||
console.log(
|
||||
'[DEBUG] Kitty sequence parsed successfully (prefix):',
|
||||
parsedSequence,
|
||||
);
|
||||
} else {
|
||||
|
||||
while (remainingBuffer) {
|
||||
const parsed = parseKittyPrefix(remainingBuffer);
|
||||
|
||||
if (parsed) {
|
||||
if (debugKeystrokeLogging) {
|
||||
const parsedSequence = remainingBuffer.slice(0, parsed.length);
|
||||
console.log(
|
||||
'[DEBUG] Kitty sequence parsed successfully:',
|
||||
parsedSequence,
|
||||
JSON.stringify(parsedSequence),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Consume the parsed prefix and broadcast it.
|
||||
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
|
||||
broadcast(parsed.key);
|
||||
parsedAny = true;
|
||||
}
|
||||
if (parsedAny) return;
|
||||
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) {
|
||||
console.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;
|
||||
}
|
||||
|
||||
if (config?.getDebugMode() || debugKeystrokeLogging) {
|
||||
const codes = Array.from(kittySequenceBuffer).map((ch) =>
|
||||
ch.charCodeAt(0),
|
||||
);
|
||||
console.warn('Kitty sequence buffer has char codes:', codes);
|
||||
// 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) {
|
||||
console.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) {
|
||||
console.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) {
|
||||
console.warn(
|
||||
'Kitty sequence buffer has content:',
|
||||
JSON.stringify(kittySequenceBuffer),
|
||||
);
|
||||
}
|
||||
// Could be valid but incomplete - set timeout
|
||||
kittySequenceTimeout = setTimeout(() => {
|
||||
if (kittySequenceBuffer) {
|
||||
if (debugKeystrokeLogging) {
|
||||
console.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
|
||||
if (debugKeystrokeLogging) {
|
||||
console.log(
|
||||
'[DEBUG] Kitty buffer overflow, clearing:',
|
||||
kittySequenceBuffer,
|
||||
);
|
||||
}
|
||||
if (config) {
|
||||
const event = new KittySequenceOverflowEvent(
|
||||
kittySequenceBuffer.length,
|
||||
kittySequenceBuffer,
|
||||
);
|
||||
logKittySequenceOverflow(config, event);
|
||||
}
|
||||
kittySequenceBuffer = '';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
kittySequenceBuffer = remainingBuffer;
|
||||
if (parsedAny || kittySequenceBuffer) return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +852,24 @@ export function KeypressProvider({
|
||||
backslashTimeout = null;
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
// Flush any pending paste data to avoid data loss on exit.
|
||||
if (isPaste) {
|
||||
broadcast({
|
||||
|
||||
Reference in New Issue
Block a user