From e049d5e4e8fc8020a537a92b1607a7f0f28dec0b Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Mon, 12 Jan 2026 13:31:33 -0800 Subject: [PATCH] Fix: add back fastreturn support (#16440) --- .../src/ui/components/InputPrompt.test.tsx | 5 ++ .../src/ui/components/SettingsDialog.test.tsx | 10 +++- .../src/ui/contexts/KeypressContext.test.tsx | 49 +++++++++++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 28 +++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 0d000fc79f..7318d2119c 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -38,6 +38,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js import stripAnsi from 'strip-ansi'; import chalk from 'chalk'; import { StreamingState } from '../types.js'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; vi.mock('../hooks/useShellHistory.js'); vi.mock('../hooks/useCommandCompletion.js'); @@ -124,6 +125,10 @@ describe('InputPrompt', () => { beforeEach(() => { vi.resetAllMocks(); + vi.spyOn( + terminalCapabilityManager, + 'isKittyProtocolEnabled', + ).mockReturnValue(true); mockCommandContext = createMockCommandContext(); diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 78bd5a3917..d8481bc9d7 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -35,9 +35,10 @@ import { type SettingDefinition, type SettingsSchemaType, } from '../../config/settingsSchema.js'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; // Mock the VimModeContext -const mockToggleVimEnabled = vi.fn(); +const mockToggleVimEnabled = vi.fn().mockResolvedValue(undefined); const mockSetVimMode = vi.fn(); vi.mock('../contexts/UIStateContext.js', () => ({ @@ -253,7 +254,12 @@ const renderDialog = ( describe('SettingsDialog', () => { beforeEach(() => { - mockToggleVimEnabled.mockResolvedValue(true); + vi.clearAllMocks(); + vi.spyOn( + terminalCapabilityManager, + 'isKittyProtocolEnabled', + ).mockReturnValue(true); + mockToggleVimEnabled.mockRejectedValue(undefined); }); afterEach(() => { diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index fddca507dd..348f940dbd 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -16,7 +16,9 @@ import { KeypressProvider, useKeypressContext, ESC_TIMEOUT, + FAST_RETURN_TIMEOUT, } from './KeypressContext.js'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; import { useStdin } from 'ink'; import { EventEmitter } from 'node:events'; @@ -154,6 +156,53 @@ describe('KeypressContext', () => { ); }); + describe('Fast return buffering', () => { + let kittySpy: ReturnType; + + beforeEach(() => { + kittySpy = vi + .spyOn(terminalCapabilityManager, 'isKittyProtocolEnabled') + .mockReturnValue(false); + }); + + afterEach(() => kittySpy.mockRestore()); + + it('should buffer return key pressed quickly after another key', async () => { + const { keyHandler } = setupKeypressTest(); + + act(() => stdin.write('a')); + expect(keyHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'a' }), + ); + + act(() => stdin.write('\r')); + + expect(keyHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: '', + sequence: '\r', + insertable: true, + }), + ); + }); + + it('should NOT buffer return key if delay is long enough', async () => { + const { keyHandler } = setupKeypressTest(); + + act(() => stdin.write('a')); + + vi.advanceTimersByTime(FAST_RETURN_TIMEOUT + 1); + + act(() => stdin.write('\r')); + + expect(keyHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'return', + }), + ); + }); + }); + describe('Escape key handling', () => { it('should recognize escape key (keycode 27) in kitty protocol', async () => { const { keyHandler } = setupKeypressTest(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 1faa705220..c0fdd6deac 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -19,6 +19,7 @@ import { ESC } from '../utils/input.js'; import { parseMouseEvent } from '../utils/mouse.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; import { appEvents, AppEvent } from '../../utils/events.js'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; export const BACKSLASH_ENTER_TIMEOUT = 5; export const ESC_TIMEOUT = 50; @@ -143,6 +144,30 @@ function nonKeyboardEventFilter( }; } +/** + * Converts return keys pressed quickly after other keys into plain + * insertable return characters. + * + * This is to accommodate older terminals that paste text without bracketing. + */ +function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler { + let lastKeyTime = 0; + return (key: Key) => { + const now = Date.now(); + if (key.name === 'return' && now - lastKeyTime <= FAST_RETURN_TIMEOUT) { + keypressHandler({ + ...key, + name: '', + sequence: '\r', + insertable: true, + }); + } else { + keypressHandler(key); + } + lastKeyTime = now; + }; +} + /** * Buffers "/" keys to see if they are followed return. * Will flush the buffer if no data is received for DRAG_COMPLETION_TIMEOUT_MS @@ -641,6 +666,9 @@ export function KeypressProvider({ process.stdin.setEncoding('utf8'); // Make data events emit strings let processor = nonKeyboardEventFilter(broadcast); + if (!terminalCapabilityManager.isKittyProtocolEnabled()) { + processor = bufferFastReturn(processor); + } processor = bufferBackslashEnter(processor); processor = bufferPaste(processor); let dataListener = createDataListener(processor);