From bff1bf761a05a776616ed1d9845fd997fc4198d3 Mon Sep 17 00:00:00 2001 From: Taha Moosavi Date: Wed, 1 Oct 2025 12:13:02 +0330 Subject: [PATCH] Fixed Arrow Key not working on ghostty and kitty --- packages/cli/src/ui/App.tsx | 4 +- .../src/ui/contexts/KeypressContext.test.tsx | 1166 ++++++----------- .../cli/src/ui/contexts/KeypressContext.tsx | 544 +++++--- .../cli/src/ui/utils/platformConstants.ts | 118 +- 4 files changed, 881 insertions(+), 951 deletions(-) diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 26090018c..aa6eef74d 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -153,11 +153,11 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { export const AppWrapper = (props: AppProps) => { const kittyProtocolStatus = useKittyKeyboardProtocol(); - const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + // const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); return ( { const wrapper = ({ children, kittyProtocolEnabled = true, - pasteWorkaround = false, }: { children: React.ReactNode; kittyProtocolEnabled?: boolean; - pasteWorkaround?: boolean; }) => ( - + {children} ); @@ -351,6 +354,25 @@ describe('KeypressContext - Kitty Protocol', () => { }), ); }); + + it('should recognize Ctrl+Backspace in kitty protocol', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + // Modifier 5 is Ctrl + act(() => { + stdin.sendKittySequence(`\x1b[127;5u`); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'backspace', + kittyProtocol: true, + ctrl: true, + }), + ); + }); }); describe('paste mode', () => { @@ -384,782 +406,6 @@ describe('KeypressContext - Kitty Protocol', () => { }), ); }); - - describe('paste mode markers', () => { - // These tests use pasteWorkaround=true to force passthrough mode for raw keypress testing - - it('should handle complete paste sequence with markers', async () => { - const keyHandler = vi.fn(); - const pastedText = 'pasted content'; - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send complete paste sequence: prefix + content + suffix - act(() => { - stdin.emit('data', Buffer.from(`\x1b[200~${pastedText}\x1b[201~`)); - }); - - await waitFor(() => { - expect(keyHandler).toHaveBeenCalledTimes(1); - }); - - // Should emit a single paste event with the content - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - paste: true, - sequence: pastedText, - name: '', - }), - ); - }); - - it('should handle empty paste sequence', async () => { - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send empty paste sequence: prefix immediately followed by suffix - act(() => { - stdin.emit('data', Buffer.from('\x1b[200~\x1b[201~')); - }); - - await waitFor(() => { - expect(keyHandler).toHaveBeenCalledTimes(1); - }); - - // Should emit a paste event with empty content - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - paste: true, - sequence: '', - name: '', - }), - ); - }); - - it('should handle data before paste markers', async () => { - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send data before paste sequence - act(() => { - stdin.emit('data', Buffer.from('before\x1b[200~pasted\x1b[201~')); - }); - - await waitFor(() => { - expect(keyHandler).toHaveBeenCalledTimes(7); // 6 chars + 1 paste event - }); - - // Should process 'before' as individual characters - expect(keyHandler).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ name: 'b' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ name: 'e' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ name: 'f' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ name: 'o' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 5, - expect.objectContaining({ name: 'r' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 6, - expect.objectContaining({ name: 'e' }), - ); - - // Then emit paste event - expect(keyHandler).toHaveBeenNthCalledWith( - 7, - expect.objectContaining({ - paste: true, - sequence: 'pasted', - }), - ); - }); - - it('should handle data after paste markers', async () => { - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send paste sequence followed by data - act(() => { - stdin.emit('data', Buffer.from('\x1b[200~pasted\x1b[201~after')); - }); - - await waitFor(() => { - expect(keyHandler).toHaveBeenCalledTimes(6); // 1 paste event + 5 individual chars for 'after' - }); - - // Should emit paste event first - expect(keyHandler).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - paste: true, - sequence: 'pasted', - }), - ); - - // Then process 'after' as individual characters (since it doesn't contain return) - expect(keyHandler).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - name: 'a', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - name: 'f', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - name: 't', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 5, - expect.objectContaining({ - name: 'e', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 6, - expect.objectContaining({ - name: 'r', - paste: false, - }), - ); - }); - - it('should handle complex sequence with multiple paste blocks', async () => { - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send complex sequence: data + paste1 + data + paste2 + data - act(() => { - stdin.emit( - 'data', - Buffer.from( - 'start\x1b[200~first\x1b[201~middle\x1b[200~second\x1b[201~end', - ), - ); - }); - - await waitFor(() => { - expect(keyHandler).toHaveBeenCalledTimes(16); // 5 + 1 + 6 + 1 + 3 = 16 calls - }); - - // Check the sequence: 'start' (5 chars) + paste1 + 'middle' (6 chars) + paste2 + 'end' (3 chars as paste) - let callIndex = 1; - - // 'start' - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 's' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 't' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'a' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'r' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 't' }), - ); - - // first paste - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ - paste: true, - sequence: 'first', - }), - ); - - // 'middle' - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'm' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'i' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'd' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'd' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'l' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'e' }), - ); - - // second paste - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ - paste: true, - sequence: 'second', - }), - ); - - // 'end' as individual characters (since it doesn't contain return) - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'e' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'n' }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - callIndex++, - expect.objectContaining({ name: 'd' }), - ); - }); - - it('should handle fragmented paste markers across multiple data events', async () => { - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send fragmented paste sequence - act(() => { - stdin.emit('data', Buffer.from('\x1b[200~partial')); - stdin.emit('data', Buffer.from(' content\x1b[201~')); - }); - - await waitFor(() => { - expect(keyHandler).toHaveBeenCalledTimes(1); - }); - - // Should combine the fragmented content into a single paste event - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - paste: true, - sequence: 'partial content', - }), - ); - }); - - it('should handle multiline content within paste markers', async () => { - const keyHandler = vi.fn(); - const multilineContent = 'line1\nline2\nline3'; - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send paste sequence with multiline content - act(() => { - stdin.emit( - 'data', - Buffer.from(`\x1b[200~${multilineContent}\x1b[201~`), - ); - }); - - await waitFor(() => { - expect(keyHandler).toHaveBeenCalledTimes(1); - }); - - // Should emit a single paste event with the multiline content - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - paste: true, - sequence: multilineContent, - }), - ); - }); - - it('should handle paste markers split across buffer boundaries', async () => { - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send paste marker split across multiple data events - act(() => { - stdin.emit('data', Buffer.from('\x1b[20')); - stdin.emit('data', Buffer.from('0~content\x1b[2')); - stdin.emit('data', Buffer.from('01~')); - }); - - await waitFor(() => { - // With the current implementation, fragmented paste markers get reconstructed - // into a single paste event for 'content' - expect(keyHandler).toHaveBeenCalledTimes(1); - }); - - // Should reconstruct the fragmented paste markers into a single paste event - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - paste: true, - sequence: 'content', - }), - ); - }); - }); - - it('buffers fragmented paste chunks before emitting newlines', () => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - try { - act(() => { - stdin.emit('data', Buffer.from('\r')); - stdin.emit('data', Buffer.from('rest of paste')); - }); - - act(() => { - vi.advanceTimersByTime(8); - }); - - // With the current implementation, fragmented data gets combined and - // treated as a single paste event due to the buffering mechanism - expect(keyHandler).toHaveBeenCalledTimes(1); - - // Should be treated as a paste event with the combined content - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - paste: true, - sequence: '\rrest of paste', - }), - ); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe('Raw keypress pipeline', () => { - // These tests use pasteWorkaround=true to force passthrough mode for raw keypress testing - - it('should buffer input data and wait for timeout', () => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - try { - // Send single character - act(() => { - stdin.emit('data', Buffer.from('a')); - }); - - // With the current implementation, single characters are processed immediately - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'a', - sequence: 'a', - }), - ); - } finally { - vi.useRealTimers(); - } - }); - - it('should concatenate new data and reset timeout', () => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - try { - // Send first chunk - act(() => { - stdin.emit('data', Buffer.from('hel')); - }); - - // Advance timer partially - act(() => { - vi.advanceTimersByTime(4); - }); - - // Send second chunk before timeout - act(() => { - stdin.emit('data', Buffer.from('lo')); - }); - - // With the current implementation, data is processed as individual characters - // since 'hel' doesn't contain return (0x0d) - expect(keyHandler).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - name: 'h', - sequence: 'h', - paste: false, - }), - ); - - expect(keyHandler).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - name: 'e', - sequence: 'e', - paste: false, - }), - ); - - expect(keyHandler).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - name: 'l', - sequence: 'l', - paste: false, - }), - ); - - // Second chunk 'lo' is also processed as individual characters - expect(keyHandler).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - name: 'l', - sequence: 'l', - paste: false, - }), - ); - - expect(keyHandler).toHaveBeenNthCalledWith( - 5, - expect.objectContaining({ - name: 'o', - sequence: 'o', - paste: false, - }), - ); - - expect(keyHandler).toHaveBeenCalledTimes(5); - } finally { - vi.useRealTimers(); - } - }); - - it('should flush immediately when buffer exceeds limit', () => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - try { - // Create a large buffer that exceeds the 64 byte limit - const largeData = 'x'.repeat(65); - - act(() => { - stdin.emit('data', Buffer.from(largeData)); - }); - - // Should flush immediately without waiting for timeout - // Large data without return gets treated as individual characters - expect(keyHandler).toHaveBeenCalledTimes(65); - - // Each character should be processed individually - for (let i = 0; i < 65; i++) { - expect(keyHandler).toHaveBeenNthCalledWith( - i + 1, - expect.objectContaining({ - name: 'x', - sequence: 'x', - paste: false, - }), - ); - } - - // Advancing timer should not cause additional calls - const callCountBefore = keyHandler.mock.calls.length; - act(() => { - vi.advanceTimersByTime(8); - }); - - expect(keyHandler).toHaveBeenCalledTimes(callCountBefore); - } finally { - vi.useRealTimers(); - } - }); - - it('should clear timeout when new data arrives', () => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - try { - // Send first chunk - act(() => { - stdin.emit('data', Buffer.from('a')); - }); - - // Advance timer almost to completion - act(() => { - vi.advanceTimersByTime(7); - }); - - // Send second chunk (should reset timeout) - act(() => { - stdin.emit('data', Buffer.from('b')); - }); - - // With the current implementation, both characters are processed immediately - expect(keyHandler).toHaveBeenCalledTimes(2); - - // First event should be 'a', second should be 'b' - expect(keyHandler).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - name: 'a', - sequence: 'a', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - name: 'b', - sequence: 'b', - paste: false, - }), - ); - } finally { - vi.useRealTimers(); - } - }); - - it('should handle multiple separate keypress events', () => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - try { - // First keypress - act(() => { - stdin.emit('data', Buffer.from('a')); - }); - - act(() => { - vi.advanceTimersByTime(8); - }); - - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - sequence: 'a', - }), - ); - - keyHandler.mockClear(); - - // Second keypress after first completed - act(() => { - stdin.emit('data', Buffer.from('b')); - }); - - act(() => { - vi.advanceTimersByTime(8); - }); - - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - sequence: 'b', - }), - ); - } finally { - vi.useRealTimers(); - } - }); - - it('should handle rapid sequential data within buffer limit', () => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }), - }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - try { - // Send multiple small chunks rapidly - act(() => { - stdin.emit('data', Buffer.from('h')); - stdin.emit('data', Buffer.from('e')); - stdin.emit('data', Buffer.from('l')); - stdin.emit('data', Buffer.from('l')); - stdin.emit('data', Buffer.from('o')); - }); - - // With the current implementation, each character is processed immediately - expect(keyHandler).toHaveBeenCalledTimes(5); - - // Each character should be processed as individual keypress events - expect(keyHandler).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - name: 'h', - sequence: 'h', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - name: 'e', - sequence: 'e', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - name: 'l', - sequence: 'l', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - name: 'l', - sequence: 'l', - paste: false, - }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 5, - expect.objectContaining({ - name: 'o', - sequence: 'o', - paste: false, - }), - ); - } finally { - vi.useRealTimers(); - } - }); }); describe('debug keystroke logging', () => { @@ -1167,8 +413,8 @@ describe('KeypressContext - Kitty Protocol', () => { let consoleWarnSpy: ReturnType; beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { }); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); }); afterEach(() => { @@ -1232,10 +478,13 @@ describe('KeypressContext - Kitty Protocol', () => { '[DEBUG] Kitty buffer accumulating:', expect.stringContaining('\x1b[27u'), ); - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty sequence parsed successfully:', - expect.stringContaining('\x1b[27u'), + const parsedCall = consoleLogSpy.mock.calls.find( + (args) => + typeof args[0] === 'string' && + args[0].includes('[DEBUG] Kitty sequence parsed successfully'), ); + expect(parsedCall).toBeTruthy(); + expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u')); }); it('should log kitty buffer overflow when debugKeystrokeLogging is true', async () => { @@ -1365,4 +614,345 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); }); + + describe('Parameterized functional keys', () => { + it.each([ + // Parameterized + { sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } }, + { sequence: `\x1b[1;5F`, expected: { name: 'end', ctrl: true } }, + { sequence: `\x1b[1;1P`, expected: { name: 'f1' } }, + { sequence: `\x1b[1;3Q`, expected: { name: 'f2', meta: true } }, + { sequence: `\x1b[3~`, expected: { name: 'delete' } }, + { sequence: `\x1b[5~`, expected: { name: 'pageup' } }, + { sequence: `\x1b[6~`, expected: { name: 'pagedown' } }, + { sequence: `\x1b[1~`, expected: { name: 'home' } }, + { sequence: `\x1b[4~`, expected: { name: 'end' } }, + { sequence: `\x1b[2~`, expected: { name: 'insert' } }, + // Legacy Arrows + { + sequence: `\x1b[A`, + expected: { name: 'up', ctrl: false, meta: false, shift: false }, + }, + { + sequence: `\x1b[B`, + expected: { name: 'down', ctrl: false, meta: false, shift: false }, + }, + { + sequence: `\x1b[C`, + expected: { name: 'right', ctrl: false, meta: false, shift: false }, + }, + { + sequence: `\x1b[D`, + expected: { name: 'left', ctrl: false, meta: false, shift: false }, + }, + // Legacy Home/End + { + sequence: `\x1b[H`, + expected: { name: 'home', ctrl: false, meta: false, shift: false }, + }, + { + sequence: `\x1b[F`, + expected: { name: 'end', ctrl: false, meta: false, shift: false }, + }, + ])( + 'should recognize sequence "$sequence" as $expected.name', + ({ sequence, expected }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(sequence)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining(expected), + ); + }, + ); + }); + + describe('Shift+Tab forms', () => { + it.each([ + { sequence: `\x1b[Z`, description: 'legacy reverse Tab' }, + { sequence: `\x1b[1;2Z`, description: 'parameterized reverse Tab' }, + ])( + 'should recognize $description "$sequence" as Shift+Tab', + ({ sequence }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(sequence)); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'tab', shift: true }), + ); + }, + ); + }); + + describe('Double-tap and batching', () => { + it('should emit two delete events for double-tap CSI[3~', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[3~`)); + act(() => stdin.sendKittySequence(`\x1b[3~`)); + + expect(keyHandler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'delete' }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'delete' }), + ); + }); + + it('should parse two concatenated tilde-coded sequences in one chunk', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[3~\x1b[5~`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'delete' }), + ); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'pageup' }), + ); + }); + + it('should ignore incomplete CSI then parse the next complete sequence', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + // Incomplete ESC sequence then a complete Delete + act(() => { + // Provide an incomplete ESC sequence chunk with a real ESC character + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + sequence: '\x1b[1;', + }); + }); + act(() => stdin.sendKittySequence(`\x1b[3~`)); + + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'delete' }), + ); + }); + }); }); + +describe('Drag and Drop Handling', () => { + let stdin: MockStdin; + const mockSetRawMode = vi.fn(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + stdin = new MockStdin(); + (useStdin as Mock).mockReturnValue({ + stdin, + setRawMode: mockSetRawMode, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('drag start by quotes', () => { + it('should start collecting when single quote arrives and not broadcast immediately', async () => { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: SINGLE_QUOTE, + }); + }); + + expect(keyHandler).not.toHaveBeenCalled(); + }); + + it('should start collecting when double quote arrives and not broadcast immediately', async () => { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: DOUBLE_QUOTE, + }); + }); + + expect(keyHandler).not.toHaveBeenCalled(); + }); + }); + + describe('drag collection and completion', () => { + it('should collect single character inputs during drag mode', async () => { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Start by single quote + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: SINGLE_QUOTE, + }); + }); + + // Send single character + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'a', + }); + }); + + // Character should not be immediately broadcast + expect(keyHandler).not.toHaveBeenCalled(); + + // Fast-forward to completion timeout + act(() => { + vi.advanceTimersByTime(DRAG_COMPLETION_TIMEOUT_MS + 10); + }); + + // Should broadcast the collected path as paste (includes starting quote) + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + paste: true, + sequence: `${SINGLE_QUOTE}a`, + }), + ); + }); + + it('should collect multiple characters and complete on timeout', async () => { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Start by single quote + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: SINGLE_QUOTE, + }); + }); + + // Send multiple characters + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'p', + }); + }); + + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'a', + }); + }); + + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 't', + }); + }); + + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'h', + }); + }); + + // Characters should not be immediately broadcast + expect(keyHandler).not.toHaveBeenCalled(); + + // Fast-forward to completion timeout + act(() => { + vi.advanceTimersByTime(DRAG_COMPLETION_TIMEOUT_MS + 10); + }); + + // Should broadcast the collected path as paste (includes starting quote) + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + paste: true, + sequence: `${SINGLE_QUOTE}path`, + }), + ); + }); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 9eb551850..c280ea10e 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -29,6 +29,11 @@ import { KITTY_KEYCODE_NUMPAD_ENTER, KITTY_KEYCODE_TAB, MAX_KITTY_SEQUENCE_LENGTH, + KITTY_MODIFIER_BASE, + KITTY_MODIFIER_EVENT_TYPES_OFFSET, + MODIFIER_SHIFT_BIT, + MODIFIER_ALT_BIT, + MODIFIER_CTRL_BIT, } from '../utils/platformConstants.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; @@ -36,6 +41,9 @@ import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; 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 SINGLE_QUOTE = "'"; +export const DOUBLE_QUOTE = '"'; export interface Key { name: string; @@ -71,18 +79,19 @@ export function useKeypressContext() { export function KeypressProvider({ children, kittyProtocolEnabled, - pasteWorkaround = false, config, debugKeystrokeLogging, }: { - children?: React.ReactNode; + children: React.ReactNode; kittyProtocolEnabled: boolean; - pasteWorkaround?: boolean; config?: Config; debugKeystrokeLogging?: boolean; }) { const { stdin, setRawMode } = useStdin(); const subscribers = useRef>(new Set()).current; + const isDraggingRef = useRef(false); + const dragBufferRef = useRef(''); + const draggingTimerRef = useRef(null); const subscribe = useCallback( (handler: KeypressHandler) => { @@ -99,12 +108,26 @@ export function KeypressProvider({ ); useEffect(() => { - setRawMode(true); + const clearDraggingTimer = () => { + if (draggingTimerRef.current) { + clearTimeout(draggingTimerRef.current); + draggingTimerRef.current = null; + } + }; + + const wasRaw = stdin.isRaw; + if (wasRaw === false) { + setRawMode(true); + } const keypressStream = new PassThrough(); let usePassthrough = false; - // Use passthrough mode when pasteWorkaround is enabled, - if (pasteWorkaround) { + const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + if ( + nodeMajorVersion < 20 || + process.env['PASTE_WORKAROUND'] === '1' || + process.env['PASTE_WORKAROUND'] === 'true' + ) { usePassthrough = true; } @@ -113,51 +136,245 @@ export function KeypressProvider({ let kittySequenceBuffer = ''; let backslashTimeout: NodeJS.Timeout | null = null; let waitingForEnterAfterBackslash = false; - let rawDataBuffer = Buffer.alloc(0); - let rawFlushTimeout: NodeJS.Timeout | null = null; - - const parseKittySequence = (sequence: string): Key | null => { - const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`); - const match = sequence.match(kittyPattern); - if (!match) return null; - - const keyCode = parseInt(match[1], 10); - const modifiers = match[3] ? parseInt(match[3], 10) : 1; - const modifierBits = modifiers - 1; - const shift = (modifierBits & 1) === 1; - const alt = (modifierBits & 2) === 2; - const ctrl = (modifierBits & 4) === 4; - - const keyNameMap: Record = { - [CHAR_CODE_ESC]: 'escape', - [KITTY_KEYCODE_TAB]: 'tab', - [KITTY_KEYCODE_BACKSPACE]: 'backspace', - [KITTY_KEYCODE_ENTER]: 'return', - [KITTY_KEYCODE_NUMPAD_ENTER]: 'return', - }; - - if (keyCode in keyNameMap) { + + // 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 + // in a single chunk, preventing buffer overflow and fragmentation. + // Parse a single complete kitty/parameterized/legacy sequence from the start + // of the buffer and return both the parsed Key and the number of characters + // consumed. This enables peel-and-continue parsing for batched input. + const parseKittyPrefix = ( + buffer: string, + ): { key: Key; length: number } | null => { + // In older terminals ESC [ Z was used as Cursor Backward Tabulation (CBT) + // In newer terminals the same functionality of key combination for moving + // backward through focusable elements is Shift+Tab, hence we will + // map ESC [ Z to Shift+Tab + // 0) Reverse Tab (legacy): ESC [ Z + // Treat as Shift+Tab for UI purposes. + // Regex parts: + // ^ - start of buffer + // ESC [ - CSI introducer + // Z - legacy reverse tab + const revTabLegacy = new RegExp(`^${ESC}\\[Z`); + let m = buffer.match(revTabLegacy); + if (m) { return { - name: keyNameMap[keyCode], - ctrl, - meta: alt, - shift, - paste: false, - sequence, - kittyProtocol: true, + key: { + name: 'tab', + ctrl: false, + meta: false, + shift: true, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, }; } - if (keyCode >= 97 && keyCode <= 122 && ctrl) { - const letter = String.fromCharCode(keyCode); + // 1) Reverse Tab (parameterized): ESC [ 1 ; Z + // Parameterized reverse Tab: ESC [ 1 ; Z + const revTabParam = new RegExp(`^${ESC}\\[1;(\\d+)Z`); + m = buffer.match(revTabParam); + if (m) { + let mods = parseInt(m[1], 10); + if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const bits = mods - KITTY_MODIFIER_BASE; + const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; return { - name: letter, - ctrl: true, - meta: alt, - shift, - paste: false, - sequence, - kittyProtocol: true, + key: { + name: 'tab', + ctrl, + meta: alt, + // Reverse tab implies Shift behavior; force shift regardless of mods + shift: true, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) + // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) + // Arrows, Home/End, F1–F4 with modifiers encoded in . + const arrowPrefix = new RegExp(`^${ESC}\\[1;(\\d+)([ABCDHFPQSR])`); + m = buffer.match(arrowPrefix); + if (m) { + let mods = parseInt(m[1], 10); + if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const bits = mods - KITTY_MODIFIER_BASE; + const shift = (bits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; + const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; + const sym = m[2]; + const symbolToName: { [k: string]: string } = { + A: 'up', + B: 'down', + C: 'right', + D: 'left', + H: 'home', + F: 'end', + P: 'f1', + Q: 'f2', + R: 'f3', + S: 'f4', + }; + const name = symbolToName[sym] || ''; + if (!name) return null; + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // 3) CSI-u form: ESC [ ; (u|~) + // 3) CSI-u and tilde-coded functional keys: ESC [ ; (u|~) + // 'u' terminator: Kitty CSI-u; '~' terminator: tilde-coded function keys. + const csiUPrefix = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])`); + m = buffer.match(csiUPrefix); + if (m) { + const keyCode = parseInt(m[1], 10); + let modifiers = m[3] ? parseInt(m[3], 10) : KITTY_MODIFIER_BASE; + if (modifiers >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + modifiers -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const modifierBits = modifiers - KITTY_MODIFIER_BASE; + const shift = + (modifierBits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; + const alt = (modifierBits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (modifierBits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; + const terminator = m[4]; + + // Tilde-coded functional keys (Delete, Insert, PageUp/Down, Home/End) + if (terminator === '~') { + let name: string | null = null; + switch (keyCode) { + case 1: + name = 'home'; + break; + case 2: + name = 'insert'; + break; + case 3: + name = 'delete'; + break; + case 4: + name = 'end'; + break; + case 5: + name = 'pageup'; + break; + case 6: + name = 'pagedown'; + break; + default: + break; + } + if (name) { + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + } + + const kittyKeyCodeToName: { [key: number]: string } = { + [CHAR_CODE_ESC]: 'escape', + [KITTY_KEYCODE_TAB]: 'tab', + [KITTY_KEYCODE_BACKSPACE]: 'backspace', + [KITTY_KEYCODE_ENTER]: 'return', + [KITTY_KEYCODE_NUMPAD_ENTER]: 'return', + }; + + const name = kittyKeyCodeToName[keyCode]; + if (name) { + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // Ctrl+letters + if ( + ctrl && + keyCode >= 'a'.charCodeAt(0) && + keyCode <= 'z'.charCodeAt(0) + ) { + const letter = String.fromCharCode(keyCode); + return { + key: { + name: letter, + ctrl: true, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + } + + // 4) Legacy function keys (no parameters): ESC [ (A|B|C|D|H|F) + // Arrows + Home/End without modifiers. + const legacyFuncKey = new RegExp(`^${ESC}\\[([ABCDHF])`); + m = buffer.match(legacyFuncKey); + if (m) { + const sym = m[1]; + const nameMap: { [key: string]: string } = { + A: 'up', + B: 'down', + C: 'right', + D: 'left', + H: 'home', + F: 'end', + }; + const name = nameMap[sym]!; + return { + key: { + name, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, }; } @@ -171,6 +388,9 @@ export function KeypressProvider({ }; const handleKeypress = (_: unknown, key: Key) => { + if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { + return; + } if (key.name === 'paste-start') { isPaste = true; return; @@ -194,6 +414,27 @@ export function KeypressProvider({ return; } + if ( + key.sequence === SINGLE_QUOTE || + key.sequence === DOUBLE_QUOTE || + isDraggingRef.current + ) { + isDraggingRef.current = true; + dragBufferRef.current += key.sequence; + + clearDraggingTimer(); + draggingTimerRef.current = setTimeout(() => { + isDraggingRef.current = false; + const seq = dragBufferRef.current; + dragBufferRef.current = ''; + if (seq) { + broadcast({ ...key, name: '', paste: true, sequence: seq }); + } + }, DRAG_COMPLETION_TIMEOUT_MS); + + return; + } + if (key.name === 'return' && waitingForEnterAfterBackslash) { if (backslashTimeout) { clearTimeout(backslashTimeout); @@ -285,18 +526,51 @@ export function KeypressProvider({ ); } - const kittyKey = parseKittySequence(kittySequenceBuffer); - if (kittyKey) { + // 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. + 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) { - console.log( - '[DEBUG] Kitty sequence parsed successfully:', - kittySequenceBuffer, + const parsedSequence = kittySequenceBuffer.slice( + 0, + parsed.length, ); + if (kittySequenceBuffer.length > parsed.length) { + console.log( + '[DEBUG] Kitty sequence parsed successfully (prefix):', + parsedSequence, + ); + } else { + console.log( + '[DEBUG] Kitty sequence parsed successfully:', + parsedSequence, + ); + } } - kittySequenceBuffer = ''; - broadcast(kittyKey); - return; + // Consume the parsed prefix and broadcast it. + kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length); + broadcast(parsed.key); + parsedAny = true; } + if (parsedAny) return; if (config?.getDebugMode() || debugKeystrokeLogging) { const codes = Array.from(kittySequenceBuffer).map((ch) => @@ -332,115 +606,54 @@ export function KeypressProvider({ broadcast({ ...key, paste: isPaste }); }; - const clearRawFlushTimeout = () => { - if (rawFlushTimeout) { - clearTimeout(rawFlushTimeout); - rawFlushTimeout = null; - } - }; - - const createPasteKeyEvent = ( - name: 'paste-start' | 'paste-end' | '' = '', - sequence: string = '', - ): Key => ({ - name, - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence, - }); - - const flushRawBuffer = () => { - if (!rawDataBuffer.length) { - return; - } - + const handleRawKeypress = (data: Buffer) => { const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX); const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX); - const data = rawDataBuffer; - let cursor = 0; - - while (cursor < data.length) { - const prefixPos = data.indexOf(pasteModePrefixBuffer, cursor); - const suffixPos = data.indexOf(pasteModeSuffixBuffer, cursor); - const hasPrefix = - prefixPos !== -1 && - prefixPos + pasteModePrefixBuffer.length <= data.length; - const hasSuffix = - suffixPos !== -1 && - suffixPos + pasteModeSuffixBuffer.length <= data.length; - - let markerPos = -1; + + let pos = 0; + while (pos < data.length) { + const prefixPos = data.indexOf(pasteModePrefixBuffer, pos); + const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos); + const isPrefixNext = + prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos); + const isSuffixNext = + suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos); + + let nextMarkerPos = -1; let markerLength = 0; - let markerType: 'prefix' | 'suffix' | null = null; - - if (hasPrefix && (!hasSuffix || prefixPos < suffixPos)) { - markerPos = prefixPos; - markerLength = pasteModePrefixBuffer.length; - markerType = 'prefix'; - } else if (hasSuffix) { - markerPos = suffixPos; - markerLength = pasteModeSuffixBuffer.length; - markerType = 'suffix'; + + if (isPrefixNext) { + nextMarkerPos = prefixPos; + } else if (isSuffixNext) { + nextMarkerPos = suffixPos; } + markerLength = pasteModeSuffixBuffer.length; - if (markerPos === -1) { - break; + if (nextMarkerPos === -1) { + keypressStream.write(data.slice(pos)); + return; } - const nextData = data.slice(cursor, markerPos); + const nextData = data.slice(pos, nextMarkerPos); if (nextData.length > 0) { keypressStream.write(nextData); } - if (markerType === 'prefix') { + const createPasteKeyEvent = ( + name: 'paste-start' | 'paste-end', + ): Key => ({ + name, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + if (isPrefixNext) { handleKeypress(undefined, createPasteKeyEvent('paste-start')); - } else if (markerType === 'suffix') { + } else if (isSuffixNext) { handleKeypress(undefined, createPasteKeyEvent('paste-end')); } - cursor = markerPos + markerLength; - } - - rawDataBuffer = data.slice(cursor); - - if (rawDataBuffer.length === 0) { - return; - } - - if ( - (rawDataBuffer.length <= 2 && rawDataBuffer.includes(0x0d)) || - !rawDataBuffer.includes(0x0d) || - isPaste - ) { - keypressStream.write(rawDataBuffer); - } else { - // Flush raw data buffer as a paste event - handleKeypress(undefined, createPasteKeyEvent('paste-start')); - keypressStream.write(rawDataBuffer); - handleKeypress(undefined, createPasteKeyEvent('paste-end')); - } - - rawDataBuffer = Buffer.alloc(0); - clearRawFlushTimeout(); - }; - - const handleRawKeypress = (_data: Buffer) => { - const data = Buffer.isBuffer(_data) ? _data : Buffer.from(_data, 'utf8'); - - // Buffer the incoming data - rawDataBuffer = Buffer.concat([rawDataBuffer, data]); - - clearRawFlushTimeout(); - - // On some Windows terminals, during a paste, the terminal might send a - // single return character chunk. In this case, we need to wait a time period - // to know if it is part of a paste or just a return character. - const isReturnChar = - rawDataBuffer.length <= 2 && rawDataBuffer.includes(0x0d); - if (isReturnChar) { - rawFlushTimeout = setTimeout(flushRawBuffer, 100); - } else { - flushRawBuffer(); + pos = nextMarkerPos + markerLength; } }; @@ -470,18 +683,15 @@ export function KeypressProvider({ rl.close(); // Restore the terminal to its original state. - setRawMode(false); + if (wasRaw === false) { + setRawMode(false); + } if (backslashTimeout) { clearTimeout(backslashTimeout); backslashTimeout = null; } - if (rawFlushTimeout) { - clearTimeout(rawFlushTimeout); - rawFlushTimeout = null; - } - // Flush any pending paste data to avoid data loss on exit. if (isPaste) { broadcast({ @@ -494,15 +704,31 @@ export function KeypressProvider({ }); pasteBuffer = Buffer.alloc(0); } + + if (draggingTimerRef.current) { + clearTimeout(draggingTimerRef.current); + draggingTimerRef.current = null; + } + if (isDraggingRef.current && dragBufferRef.current) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: dragBufferRef.current, + }); + isDraggingRef.current = false; + dragBufferRef.current = ''; + } }; }, [ stdin, setRawMode, kittyProtocolEnabled, - debugKeystrokeLogging, - pasteWorkaround, config, subscribers, + debugKeystrokeLogging, ]); return ( @@ -510,4 +736,4 @@ export function KeypressProvider({ {children} ); -} +} \ No newline at end of file diff --git a/packages/cli/src/ui/utils/platformConstants.ts b/packages/cli/src/ui/utils/platformConstants.ts index 976653103..4ed3aba01 100644 --- a/packages/cli/src/ui/utils/platformConstants.ts +++ b/packages/cli/src/ui/utils/platformConstants.ts @@ -1,3 +1,90 @@ +// /** +// * @license +// * Copyright 2025 Google LLC +// * SPDX-License-Identifier: Apache-2.0 +// */ + +// /** +// * Terminal Platform Constants +// * +// * This file contains terminal-related constants used throughout the application, +// * specifically for handling keyboard inputs and terminal protocols. +// */ + +// /** +// * Kitty keyboard protocol sequences for enhanced keyboard input. +// * @see https://sw.kovidgoyal.net/kitty/keyboard-protocol/ +// */ +// export const KITTY_CTRL_C = '[99;5u'; + +// /** +// * Kitty keyboard protocol keycodes +// */ +// export const KITTY_KEYCODE_ENTER = 13; +// export const KITTY_KEYCODE_NUMPAD_ENTER = 57414; +// export const KITTY_KEYCODE_TAB = 9; +// export const KITTY_KEYCODE_BACKSPACE = 127; + +// /** +// * Kitty modifier decoding constants +// * +// * In Kitty/Ghostty, the modifier parameter is encoded as (1 + bitmask). +// * Some terminals also set bit 7 (i.e., add 128) when reporting event types. +// */ +// export const KITTY_MODIFIER_BASE = 1; // Base value per spec before bitmask decode +// export const KITTY_MODIFIER_EVENT_TYPES_OFFSET = 128; // Added when event types are included + +// /** +// * Modifier bit flags for Kitty/Xterm-style parameters. +// * +// * Per spec, the modifiers parameter encodes (1 + bitmask) where: +// * - 1: no modifiers +// * - bit 0 (1): Shift +// * - bit 1 (2): Alt/Option (reported as "alt" in spec; we map to meta) +// * - bit 2 (4): Ctrl +// * +// * Some terminals add 128 to the entire modifiers field when reporting event types. +// * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#modifiers +// */ +// export const MODIFIER_SHIFT_BIT = 1; +// export const MODIFIER_ALT_BIT = 2; +// export const MODIFIER_CTRL_BIT = 4; + +// /** +// * Timing constants for terminal interactions +// */ +// export const CTRL_EXIT_PROMPT_DURATION_MS = 1000; + +// /** +// * VS Code terminal integration constants +// */ +// export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n'; + +// /** +// * Backslash + Enter detection window in milliseconds. +// * Used to detect Shift+Enter pattern where backslash +// * is followed by Enter within this timeframe. +// */ +// export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5; + +// /** +// * Maximum expected length of a Kitty keyboard protocol sequence. +// * Format: ESC [ ; u/~ +// * Example: \x1b[13;2u (Shift+Enter) = 8 chars +// * Longest reasonable: \x1b[127;15~ = 11 chars (Del with all modifiers) +// * We use 12 to provide a small buffer. +// */ +// export const MAX_KITTY_SEQUENCE_LENGTH = 32; + +// /** +// * Character codes for common escape sequences +// */ +// export const CHAR_CODE_ESC = 27; +// export const CHAR_CODE_LEFT_BRACKET = 91; +// export const CHAR_CODE_1 = 49; +// export const CHAR_CODE_2 = 50; + + /** * @license * Copyright 2025 Google LLC @@ -25,6 +112,31 @@ export const KITTY_KEYCODE_NUMPAD_ENTER = 57414; export const KITTY_KEYCODE_TAB = 9; export const KITTY_KEYCODE_BACKSPACE = 127; +/** + * Kitty modifier decoding constants + * + * In Kitty/Ghostty, the modifier parameter is encoded as (1 + bitmask). + * Some terminals also set bit 7 (i.e., add 128) when reporting event types. + */ +export const KITTY_MODIFIER_BASE = 1; // Base value per spec before bitmask decode +export const KITTY_MODIFIER_EVENT_TYPES_OFFSET = 128; // Added when event types are included + +/** + * Modifier bit flags for Kitty/Xterm-style parameters. + * + * Per spec, the modifiers parameter encodes (1 + bitmask) where: + * - 1: no modifiers + * - bit 0 (1): Shift + * - bit 1 (2): Alt/Option (reported as "alt" in spec; we map to meta) + * - bit 2 (4): Ctrl + * + * Some terminals add 128 to the entire modifiers field when reporting event types. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#modifiers + */ +export const MODIFIER_SHIFT_BIT = 1; +export const MODIFIER_ALT_BIT = 2; +export const MODIFIER_CTRL_BIT = 4; + /** * Timing constants for terminal interactions */ @@ -49,7 +161,9 @@ export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5; * Longest reasonable: \x1b[127;15~ = 11 chars (Del with all modifiers) * We use 12 to provide a small buffer. */ -export const MAX_KITTY_SEQUENCE_LENGTH = 12; +// Increased to accommodate parameterized forms and occasional colon subfields +// while still being small enough to avoid pathological buffering. +export const MAX_KITTY_SEQUENCE_LENGTH = 32; /** * Character codes for common escape sequences @@ -57,4 +171,4 @@ export const MAX_KITTY_SEQUENCE_LENGTH = 12; export const CHAR_CODE_ESC = 27; export const CHAR_CODE_LEFT_BRACKET = 91; export const CHAR_CODE_1 = 49; -export const CHAR_CODE_2 = 50; +export const CHAR_CODE_2 = 50; \ No newline at end of file