diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 662a74c0fa..d2107ff40b 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -9,7 +9,15 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import type { Mock } from 'vitest'; import { vi } from 'vitest'; import type { Key } from './KeypressContext.js'; -import { KeypressProvider, useKeypressContext } from './KeypressContext.js'; +import { + KeypressProvider, + useKeypressContext, + DRAG_COMPLETION_TIMEOUT_MS, + // CSI_END_O, + // SS3_END, + SINGLE_QUOTE, + DOUBLE_QUOTE, +} from './KeypressContext.js'; import { useStdin } from 'ink'; import { EventEmitter } from 'node:events'; @@ -740,3 +748,211 @@ describe('KeypressContext - Kitty Protocol', () => { }); }); }); + +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`, + }), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 73afe70c5b..c0d1a600fe 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -41,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; @@ -86,6 +89,9 @@ export function KeypressProvider({ }) { 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) => { @@ -102,6 +108,13 @@ export function KeypressProvider({ ); useEffect(() => { + const clearDraggingTimer = () => { + if (draggingTimerRef.current) { + clearTimeout(draggingTimerRef.current); + draggingTimerRef.current = null; + } + }; + setRawMode(true); const keypressStream = new PassThrough(); @@ -395,6 +408,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); @@ -662,6 +696,23 @@ 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,