diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 07eb1a693..fc9763cbb 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -211,6 +211,12 @@ export const BaseTextInput: React.FC = ({ return; } + // Tab — never insert literal tab characters into the buffer; + // consumers that need Tab behaviour should intercept it via onKeypress. + if ((key.name === 'tab' || key.sequence === '\t') && !key.paste) { + return; + } + // Backspace if ( key.name === 'backspace' || diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index d2681eaf3..42718719f 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -2320,7 +2320,13 @@ export function useTextBuffer({ else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del(); else if (key.ctrl && !key.shift && key.name === 'z') undo(); else if (key.ctrl && key.shift && key.name === 'z') redo(); - else if (input && !key.ctrl && !key.meta) { + else if ( + input && + !key.ctrl && + !key.meta && + key.name !== 'tab' && + input !== '\t' + ) { insert(input, { paste: key.paste }); } }, diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 7a5a1c23d..735423065 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -959,6 +959,63 @@ describe('KeypressContext - Kitty Protocol', () => { } }); + it('should keep a literal tab key as a non-paste keypress', () => { + 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('\t')); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tab', + sequence: '\t', + paste: false, + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it('should mark single-line tabbed raw chunks as paste', async () => { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }), + }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + act(() => { + stdin.emit('data', Buffer.from('first\tsecond')); + }); + + await waitFor(() => { + expect(keyHandler).toHaveBeenCalledTimes(1); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + sequence: 'first\tsecond', + paste: true, + }), + ); + }); + it('should concatenate new data and reset timeout', () => { vi.useFakeTimers(); const keyHandler = vi.fn(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index e4ff4db0e..a2ee13c29 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -989,6 +989,14 @@ export function KeypressProvider({ sequence, }); + const shouldFlushRawDataAsPaste = (data: Buffer) => { + const hasReturn = data.includes(0x0d); + const hasEmbeddedTab = data.length > 1 && data.includes(0x09); + const isSingleReturn = data.length <= 2 && hasReturn; + + return !isSingleReturn && (hasReturn || hasEmbeddedTab); + }; + const flushRawBuffer = () => { if (!rawDataBuffer.length) { return; @@ -1045,11 +1053,7 @@ export function KeypressProvider({ return; } - if ( - (rawDataBuffer.length <= 2 && rawDataBuffer.includes(0x0d)) || - !rawDataBuffer.includes(0x0d) || - isPaste - ) { + if (isPaste || !shouldFlushRawDataAsPaste(rawDataBuffer)) { keypressStream.write(rawDataBuffer); } else { // Flush raw data buffer as a paste event