summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks')
-rw-r--r--packages/cli/src/ui/hooks/useKeypress.test.ts261
-rw-r--r--packages/cli/src/ui/hooks/useKeypress.ts88
2 files changed, 345 insertions, 4 deletions
diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.ts
new file mode 100644
index 00000000..a30eabf2
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useKeypress.test.ts
@@ -0,0 +1,261 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderHook, act } from '@testing-library/react';
+import { useKeypress, Key } from './useKeypress.js';
+import { useStdin } from 'ink';
+import { EventEmitter } from 'events';
+import { PassThrough } from 'stream';
+
+// Mock the 'ink' module to control stdin
+vi.mock('ink', async (importOriginal) => {
+ const original = await importOriginal<typeof import('ink')>();
+ return {
+ ...original,
+ useStdin: vi.fn(),
+ };
+});
+
+// Mock the 'readline' module
+vi.mock('readline', () => {
+ const mockedReadline = {
+ createInterface: vi.fn().mockReturnValue({ close: vi.fn() }),
+ // The paste workaround involves replacing stdin with a PassThrough stream.
+ // This mock ensures that when emitKeypressEvents is called on that
+ // stream, we simulate the 'keypress' events that the hook expects.
+ emitKeypressEvents: vi.fn((stream: EventEmitter) => {
+ if (stream instanceof PassThrough) {
+ stream.on('data', (data) => {
+ const str = data.toString();
+ for (const char of str) {
+ stream.emit('keypress', null, {
+ name: char,
+ sequence: char,
+ ctrl: false,
+ meta: false,
+ shift: false,
+ });
+ }
+ });
+ }
+ }),
+ };
+ return {
+ ...mockedReadline,
+ default: mockedReadline,
+ };
+});
+
+class MockStdin extends EventEmitter {
+ isTTY = true;
+ setRawMode = vi.fn();
+ on = this.addListener;
+ removeListener = this.removeListener;
+ write = vi.fn();
+ resume = vi.fn();
+
+ private isLegacy = false;
+
+ setLegacy(isLegacy: boolean) {
+ this.isLegacy = isLegacy;
+ }
+
+ // Helper to simulate a full paste event.
+ paste(text: string) {
+ if (this.isLegacy) {
+ const PASTE_START = '\x1B[200~';
+ const PASTE_END = '\x1B[201~';
+ this.emit('data', Buffer.from(`${PASTE_START}${text}${PASTE_END}`));
+ } else {
+ this.emit('keypress', null, { name: 'paste-start' });
+ this.emit('keypress', null, { sequence: text });
+ this.emit('keypress', null, { name: 'paste-end' });
+ }
+ }
+
+ // Helper to simulate the start of a paste, without the end.
+ startPaste(text: string) {
+ if (this.isLegacy) {
+ this.emit('data', Buffer.from('\x1B[200~' + text));
+ } else {
+ this.emit('keypress', null, { name: 'paste-start' });
+ this.emit('keypress', null, { sequence: text });
+ }
+ }
+
+ // Helper to simulate a single keypress event.
+ pressKey(key: Partial<Key>) {
+ if (this.isLegacy) {
+ this.emit('data', Buffer.from(key.sequence ?? ''));
+ } else {
+ this.emit('keypress', null, key);
+ }
+ }
+}
+
+describe('useKeypress', () => {
+ let stdin: MockStdin;
+ const mockSetRawMode = vi.fn();
+ const onKeypress = vi.fn();
+ let originalNodeVersion: string;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ stdin = new MockStdin();
+ (useStdin as vi.Mock).mockReturnValue({
+ stdin,
+ setRawMode: mockSetRawMode,
+ });
+
+ originalNodeVersion = process.versions.node;
+ delete process.env['PASTE_WORKAROUND'];
+ });
+
+ afterEach(() => {
+ Object.defineProperty(process.versions, 'node', {
+ value: originalNodeVersion,
+ configurable: true,
+ });
+ });
+
+ const setNodeVersion = (version: string) => {
+ Object.defineProperty(process.versions, 'node', {
+ value: version,
+ configurable: true,
+ });
+ };
+
+ it('should not listen if isActive is false', () => {
+ renderHook(() => useKeypress(onKeypress, { isActive: false }));
+ act(() => stdin.pressKey({ name: 'a' }));
+ expect(onKeypress).not.toHaveBeenCalled();
+ });
+
+ it('should listen for keypress when active', () => {
+ renderHook(() => useKeypress(onKeypress, { isActive: true }));
+ const key = { name: 'a', sequence: 'a' };
+ act(() => stdin.pressKey(key));
+ expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key));
+ });
+
+ it('should set and release raw mode', () => {
+ const { unmount } = renderHook(() =>
+ useKeypress(onKeypress, { isActive: true }),
+ );
+ expect(mockSetRawMode).toHaveBeenCalledWith(true);
+ unmount();
+ expect(mockSetRawMode).toHaveBeenCalledWith(false);
+ });
+
+ it('should stop listening after being unmounted', () => {
+ const { unmount } = renderHook(() =>
+ useKeypress(onKeypress, { isActive: true }),
+ );
+ unmount();
+ act(() => stdin.pressKey({ name: 'a' }));
+ expect(onKeypress).not.toHaveBeenCalled();
+ });
+
+ it('should correctly identify alt+enter (meta key)', () => {
+ renderHook(() => useKeypress(onKeypress, { isActive: true }));
+ const key = { name: 'return', sequence: '\x1B\r' };
+ act(() => stdin.pressKey(key));
+ expect(onKeypress).toHaveBeenCalledWith(
+ expect.objectContaining({ ...key, meta: true, paste: false }),
+ );
+ });
+
+ describe.each([
+ {
+ description: 'Modern Node (>= v20)',
+ setup: () => setNodeVersion('20.0.0'),
+ isLegacy: false,
+ },
+ {
+ description: 'Legacy Node (< v20)',
+ setup: () => setNodeVersion('18.0.0'),
+ isLegacy: true,
+ },
+ {
+ description: 'Workaround Env Var',
+ setup: () => {
+ setNodeVersion('20.0.0');
+ process.env['PASTE_WORKAROUND'] = 'true';
+ },
+ isLegacy: true,
+ },
+ ])('Paste Handling in $description', ({ setup, isLegacy }) => {
+ beforeEach(() => {
+ setup();
+ stdin.setLegacy(isLegacy);
+ });
+
+ it('should process a paste as a single event', () => {
+ renderHook(() => useKeypress(onKeypress, { isActive: true }));
+ const pasteText = 'hello world';
+ act(() => stdin.paste(pasteText));
+
+ expect(onKeypress).toHaveBeenCalledTimes(1);
+ expect(onKeypress).toHaveBeenCalledWith({
+ name: '',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: true,
+ sequence: pasteText,
+ });
+ });
+
+ it('should handle keypress interspersed with pastes', () => {
+ renderHook(() => useKeypress(onKeypress, { isActive: true }));
+
+ const keyA = { name: 'a', sequence: 'a' };
+ act(() => stdin.pressKey(keyA));
+ expect(onKeypress).toHaveBeenCalledWith(
+ expect.objectContaining({ ...keyA, paste: false }),
+ );
+
+ const pasteText = 'pasted';
+ act(() => stdin.paste(pasteText));
+ expect(onKeypress).toHaveBeenCalledWith(
+ expect.objectContaining({ paste: true, sequence: pasteText }),
+ );
+
+ const keyB = { name: 'b', sequence: 'b' };
+ act(() => stdin.pressKey(keyB));
+ expect(onKeypress).toHaveBeenCalledWith(
+ expect.objectContaining({ ...keyB, paste: false }),
+ );
+
+ expect(onKeypress).toHaveBeenCalledTimes(3);
+ });
+
+ it('should emit partial paste content if unmounted mid-paste', () => {
+ const { unmount } = renderHook(() =>
+ useKeypress(onKeypress, { isActive: true }),
+ );
+ const pasteText = 'incomplete paste';
+
+ act(() => stdin.startPaste(pasteText));
+
+ // No event should be fired yet.
+ expect(onKeypress).not.toHaveBeenCalled();
+
+ // Unmounting should trigger the flush.
+ unmount();
+
+ expect(onKeypress).toHaveBeenCalledTimes(1);
+ expect(onKeypress).toHaveBeenCalledWith({
+ name: '',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: true,
+ sequence: pasteText,
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts
index a8adba8d..d3e3df5c 100644
--- a/packages/cli/src/ui/hooks/useKeypress.ts
+++ b/packages/cli/src/ui/hooks/useKeypress.ts
@@ -7,6 +7,7 @@
import { useEffect, useRef } from 'react';
import { useStdin } from 'ink';
import readline from 'readline';
+import { PassThrough } from 'stream';
export interface Key {
name: string;
@@ -48,7 +49,19 @@ export function useKeypress(
setRawMode(true);
- const rl = readline.createInterface({ input: stdin });
+ const keypressStream = new PassThrough();
+ let usePassthrough = false;
+ const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
+ if (
+ nodeMajorVersion < 20 ||
+ process.env['PASTE_WORKAROUND'] === '1' ||
+ process.env['PASTE_WORKAROUND'] === 'true'
+ ) {
+ // Prior to node 20, node's built-in readline does not support bracketed
+ // paste mode. We hack by detecting it with our own handler.
+ usePassthrough = true;
+ }
+
let isPaste = false;
let pasteBuffer = Buffer.alloc(0);
@@ -79,11 +92,78 @@ export function useKeypress(
}
};
- readline.emitKeypressEvents(stdin, rl);
- stdin.on('keypress', handleKeypress);
+ const handleRawKeypress = (data: Buffer) => {
+ const PASTE_MODE_PREFIX = Buffer.from('\x1B[200~');
+ const PASTE_MODE_SUFFIX = Buffer.from('\x1B[201~');
+
+ let pos = 0;
+ while (pos < data.length) {
+ const prefixPos = data.indexOf(PASTE_MODE_PREFIX, pos);
+ const suffixPos = data.indexOf(PASTE_MODE_SUFFIX, pos);
+
+ // Determine which marker comes first, if any.
+ const isPrefixNext =
+ prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos);
+ const isSuffixNext =
+ suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos);
+
+ let nextMarkerPos = -1;
+ let markerLength = 0;
+
+ if (isPrefixNext) {
+ nextMarkerPos = prefixPos;
+ } else if (isSuffixNext) {
+ nextMarkerPos = suffixPos;
+ }
+ markerLength = PASTE_MODE_SUFFIX.length;
+
+ if (nextMarkerPos === -1) {
+ keypressStream.write(data.slice(pos));
+ return;
+ }
+
+ const nextData = data.slice(pos, nextMarkerPos);
+ if (nextData.length > 0) {
+ keypressStream.write(nextData);
+ }
+ 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 (isSuffixNext) {
+ handleKeypress(undefined, createPasteKeyEvent('paste-end'));
+ }
+ pos = nextMarkerPos + markerLength;
+ }
+ };
+
+ let rl: readline.Interface;
+ if (usePassthrough) {
+ rl = readline.createInterface({ input: keypressStream });
+ readline.emitKeypressEvents(keypressStream, rl);
+ keypressStream.on('keypress', handleKeypress);
+ stdin.on('data', handleRawKeypress);
+ } else {
+ rl = readline.createInterface({ input: stdin });
+ readline.emitKeypressEvents(stdin, rl);
+ stdin.on('keypress', handleKeypress);
+ }
return () => {
- stdin.removeListener('keypress', handleKeypress);
+ if (usePassthrough) {
+ keypressStream.removeListener('keypress', handleKeypress);
+ stdin.removeListener('data', handleRawKeypress);
+ } else {
+ stdin.removeListener('keypress', handleKeypress);
+ }
rl.close();
setRawMode(false);