Handle stdin for prompts using readline for escape character parsing (#1972)

This commit is contained in:
Billy Biggs 2025-06-27 10:57:32 -07:00 committed by GitHub
parent 5fd6664c4b
commit 4fbffdf617
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 352 additions and 86 deletions

View File

@ -63,6 +63,7 @@ import {
useSessionStats, useSessionStats,
} from './contexts/SessionContext.js'; } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
import { useTextBuffer } from './components/shared/text-buffer.js'; import { useTextBuffer } from './components/shared/text-buffer.js';
import * as fs from 'fs'; import * as fs from 'fs';
import { UpdateNotification } from './components/UpdateNotification.js'; import { UpdateNotification } from './components/UpdateNotification.js';
@ -86,6 +87,7 @@ export const AppWrapper = (props: AppProps) => (
); );
const App = ({ config, settings, startupWarnings = [] }: AppProps) => { const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
useBracketedPaste();
const [updateMessage, setUpdateMessage] = useState<string | null>(null); const [updateMessage, setUpdateMessage] = useState<string | null>(null);
const { stdout } = useStdout(); const { stdout } = useStdout();

View File

@ -5,7 +5,7 @@
*/ */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Text, Box, useInput } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js'; import { useInputHistory } from '../hooks/useInputHistory.js';
@ -16,6 +16,7 @@ import stringWidth from 'string-width';
import process from 'node:process'; import process from 'node:process';
import { useShellHistory } from '../hooks/useShellHistory.js'; import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js'; import { useCompletion } from '../hooks/useCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { SlashCommand } from '../hooks/slashCommandProcessor.js'; import { SlashCommand } from '../hooks/slashCommandProcessor.js';
import { Config } from '@google/gemini-cli-core'; import { Config } from '@google/gemini-cli-core';
@ -155,29 +156,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
], ],
); );
useInput( const handleInput = useCallback(
(input, key) => { (key: Key) => {
if (!focus) { if (!focus) {
return; return;
} }
const query = buffer.text; const query = buffer.text;
if (input === '!' && query === '' && !completion.showSuggestions) { if (key.sequence === '!' && query === '' && !completion.showSuggestions) {
setShellModeActive(!shellModeActive); setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input buffer.setText(''); // Clear the '!' from input
return true; return true;
} }
if (completion.showSuggestions) { if (completion.showSuggestions) {
if (key.upArrow) { if (key.name === 'up') {
completion.navigateUp(); completion.navigateUp();
return; return;
} }
if (key.downArrow) { if (key.name === 'down') {
completion.navigateDown(); completion.navigateDown();
return; return;
} }
if (key.tab) { if (key.name === 'tab') {
if (completion.suggestions.length > 0) { if (completion.suggestions.length > 0) {
const targetIndex = const targetIndex =
completion.activeSuggestionIndex === -1 completion.activeSuggestionIndex === -1
@ -189,7 +190,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
return; return;
} }
if (key.return) { if (key.name === 'return') {
if (completion.activeSuggestionIndex >= 0) { if (completion.activeSuggestionIndex >= 0) {
handleAutocomplete(completion.activeSuggestionIndex); handleAutocomplete(completion.activeSuggestionIndex);
} else if (query.trim()) { } else if (query.trim()) {
@ -199,19 +200,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
} else { } else {
// Keybindings when suggestions are not shown // Keybindings when suggestions are not shown
if (key.ctrl && input === 'l') { if (key.ctrl && key.name === 'l') {
onClearScreen(); onClearScreen();
return true; return;
} }
if (key.ctrl && input === 'p') { if (key.ctrl && key.name === 'p') {
inputHistory.navigateUp(); inputHistory.navigateUp();
return true; return;
} }
if (key.ctrl && input === 'n') { if (key.ctrl && key.name === 'n') {
inputHistory.navigateDown(); inputHistory.navigateDown();
return true; return;
} }
if (key.escape) { if (key.name === 'escape') {
if (shellModeActive) { if (shellModeActive) {
setShellModeActive(false); setShellModeActive(false);
return; return;
@ -222,54 +223,55 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
// Ctrl+A (Home) // Ctrl+A (Home)
if (key.ctrl && input === 'a') { if (key.ctrl && key.name === 'a') {
buffer.move('home'); buffer.move('home');
buffer.moveToOffset(0); buffer.moveToOffset(0);
return; return;
} }
// Ctrl+E (End) // Ctrl+E (End)
if (key.ctrl && input === 'e') { if (key.ctrl && key.name === 'e') {
buffer.move('end'); buffer.move('end');
buffer.moveToOffset(cpLen(buffer.text)); buffer.moveToOffset(cpLen(buffer.text));
return; return;
} }
// Ctrl+L (Clear Screen) // Ctrl+L (Clear Screen)
if (key.ctrl && input === 'l') { if (key.ctrl && key.name === 'l') {
onClearScreen(); onClearScreen();
return; return;
} }
// Ctrl+P (History Up) // Ctrl+P (History Up)
if (key.ctrl && input === 'p' && !completion.showSuggestions) { if (key.ctrl && key.name === 'p' && !completion.showSuggestions) {
inputHistory.navigateUp(); inputHistory.navigateUp();
return; return;
} }
// Ctrl+N (History Down) // Ctrl+N (History Down)
if (key.ctrl && input === 'n' && !completion.showSuggestions) { if (key.ctrl && key.name === 'n' && !completion.showSuggestions) {
inputHistory.navigateDown(); inputHistory.navigateDown();
return; return;
} }
// Core text editing from MultilineTextEditor's useInput // Core text editing from MultilineTextEditor's useInput
if (key.ctrl && input === 'k') { if (key.ctrl && key.name === 'k') {
buffer.killLineRight(); buffer.killLineRight();
return; return;
} }
if (key.ctrl && input === 'u') { if (key.ctrl && key.name === 'u') {
buffer.killLineLeft(); buffer.killLineLeft();
return; return;
} }
const isCtrlX = const isCtrlX =
(key.ctrl && (input === 'x' || input === '\x18')) || input === '\x18'; (key.ctrl && (key.name === 'x' || key.sequence === '\x18')) ||
key.sequence === '\x18';
const isCtrlEFromEditor = const isCtrlEFromEditor =
(key.ctrl && (input === 'e' || input === '\x05')) || (key.ctrl && (key.name === 'e' || key.sequence === '\x05')) ||
input === '\x05' || key.sequence === '\x05' ||
(!key.ctrl && (!key.ctrl &&
input === 'e' && key.name === 'e' &&
input.length === 1 && key.sequence.length === 1 &&
input.charCodeAt(0) === 5); key.sequence.charCodeAt(0) === 5);
if (isCtrlX || isCtrlEFromEditor) { if (isCtrlX || isCtrlEFromEditor) {
if (isCtrlEFromEditor && !(key.ctrl && input === 'e')) { if (isCtrlEFromEditor && !(key.ctrl && key.name === 'e')) {
// Avoid double handling Ctrl+E // Avoid double handling Ctrl+E
buffer.openInExternalEditor(); buffer.openInExternalEditor();
return; return;
@ -284,16 +286,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
process.env['TEXTBUFFER_DEBUG'] === '1' || process.env['TEXTBUFFER_DEBUG'] === '1' ||
process.env['TEXTBUFFER_DEBUG'] === 'true' process.env['TEXTBUFFER_DEBUG'] === 'true'
) { ) {
console.log('[InputPromptCombined] event', { input, key }); console.log('[InputPromptCombined] event', { key });
} }
// Ctrl+Enter for newline, Enter for submit // Ctrl+Enter for newline, Enter for submit
if (key.return) { if (key.name === 'return') {
const [row, col] = buffer.cursor; const [row, col] = buffer.cursor;
const line = buffer.lines[row]; const line = buffer.lines[row];
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : ''; const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
if (key.ctrl || key.meta || charBefore === '\\' || key.paste) {
if (key.ctrl || charBefore === '\\') {
// Ctrl+Enter or escaped newline // Ctrl+Enter or escaped newline
if (charBefore === '\\') { if (charBefore === '\\') {
buffer.backspace(); buffer.backspace();
@ -309,7 +310,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
// Standard arrow navigation within the buffer // Standard arrow navigation within the buffer
if (key.upArrow && !completion.showSuggestions) { if (key.name === 'up' && !completion.showSuggestions) {
if (shellModeActive) { if (shellModeActive) {
const prevCommand = shellHistory.getPreviousCommand(); const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) { if (prevCommand !== null) {
@ -328,7 +329,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
return; return;
} }
if (key.downArrow && !completion.showSuggestions) { if (key.name === 'down' && !completion.showSuggestions) {
if (shellModeActive) { if (shellModeActive) {
const nextCommand = shellHistory.getNextCommand(); const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) { if (nextCommand !== null) {
@ -349,13 +350,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
// Fallback to buffer's default input handling // Fallback to buffer's default input handling
buffer.handleInput(input, key as Record<string, boolean>); buffer.handleInput(key);
},
{
isActive: focus,
}, },
[
focus,
buffer,
completion,
shellModeActive,
setShellModeActive,
onClearScreen,
inputHistory,
handleAutocomplete,
handleSubmitAndClear,
shellHistory,
],
); );
useKeypress(handleInput, { isActive: focus });
const linesToRender = buffer.viewportVisualLines; const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor; buffer.visualCursor;

View File

@ -574,8 +574,24 @@ describe('useTextBuffer', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
act(() => result.current.handleInput('h', {})); act(() =>
act(() => result.current.handleInput('i', {})); result.current.handleInput({
name: 'h',
ctrl: false,
meta: false,
shift: false,
sequence: 'h',
}),
);
act(() =>
result.current.handleInput({
name: 'i',
ctrl: false,
meta: false,
shift: false,
sequence: 'i',
}),
);
expect(getBufferState(result).text).toBe('hi'); expect(getBufferState(result).text).toBe('hi');
}); });
@ -583,7 +599,15 @@ describe('useTextBuffer', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
act(() => result.current.handleInput(undefined, { return: true })); act(() =>
result.current.handleInput({
name: 'return',
ctrl: false,
meta: false,
shift: false,
sequence: '\r',
}),
);
expect(getBufferState(result).lines).toEqual(['', '']); expect(getBufferState(result).lines).toEqual(['', '']);
}); });
@ -596,7 +620,15 @@ describe('useTextBuffer', () => {
}), }),
); );
act(() => result.current.move('end')); act(() => result.current.move('end'));
act(() => result.current.handleInput(undefined, { backspace: true })); act(() =>
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
sequence: '\x7f',
}),
);
expect(getBufferState(result).text).toBe(''); expect(getBufferState(result).text).toBe('');
}); });
@ -671,9 +703,25 @@ describe('useTextBuffer', () => {
}), }),
); );
act(() => result.current.move('end')); // cursor [0,2] act(() => result.current.move('end')); // cursor [0,2]
act(() => result.current.handleInput(undefined, { leftArrow: true })); // cursor [0,1] act(() =>
result.current.handleInput({
name: 'left',
ctrl: false,
meta: false,
shift: false,
sequence: '\x1b[D',
}),
); // cursor [0,1]
expect(getBufferState(result).cursor).toEqual([0, 1]); expect(getBufferState(result).cursor).toEqual([0, 1]);
act(() => result.current.handleInput(undefined, { rightArrow: true })); // cursor [0,2] act(() =>
result.current.handleInput({
name: 'right',
ctrl: false,
meta: false,
shift: false,
sequence: '\x1b[C',
}),
); // cursor [0,2]
expect(getBufferState(result).cursor).toEqual([0, 2]); expect(getBufferState(result).cursor).toEqual([0, 2]);
}); });
@ -683,7 +731,15 @@ describe('useTextBuffer', () => {
); );
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m'; const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
// Simulate pasting by calling handleInput with a string longer than 1 char // Simulate pasting by calling handleInput with a string longer than 1 char
act(() => result.current.handleInput(textWithAnsi, {})); act(() =>
result.current.handleInput({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: textWithAnsi,
}),
);
expect(getBufferState(result).text).toBe('Hello World'); expect(getBufferState(result).text).toBe('Hello World');
}); });
@ -691,7 +747,15 @@ describe('useTextBuffer', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
act(() => result.current.handleInput('\r', {})); // Simulates Shift+Enter in VSCode terminal act(() =>
result.current.handleInput({
name: 'return',
ctrl: false,
meta: false,
shift: true,
sequence: '\r',
}),
); // Simulates Shift+Enter in VSCode terminal
expect(getBufferState(result).lines).toEqual(['', '']); expect(getBufferState(result).lines).toEqual(['', '']);
}); });
@ -880,7 +944,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
const textWithAnsi = '\x1B[31mHello\x1B[0m'; const textWithAnsi = '\x1B[31mHello\x1B[0m';
act(() => result.current.handleInput(textWithAnsi, {})); act(() =>
result.current.handleInput({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: textWithAnsi,
}),
);
expect(getBufferState(result).text).toBe('Hello'); expect(getBufferState(result).text).toBe('Hello');
}); });
@ -889,7 +961,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
const textWithControlChars = 'H\x07e\x08l\x0Bl\x0Co'; // BELL, BACKSPACE, VT, FF const textWithControlChars = 'H\x07e\x08l\x0Bl\x0Co'; // BELL, BACKSPACE, VT, FF
act(() => result.current.handleInput(textWithControlChars, {})); act(() =>
result.current.handleInput({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: textWithControlChars,
}),
);
expect(getBufferState(result).text).toBe('Hello'); expect(getBufferState(result).text).toBe('Hello');
}); });
@ -898,7 +978,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
const textWithMixed = '\u001B[4mH\u001B[0mello'; const textWithMixed = '\u001B[4mH\u001B[0mello';
act(() => result.current.handleInput(textWithMixed, {})); act(() =>
result.current.handleInput({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: textWithMixed,
}),
);
expect(getBufferState(result).text).toBe('Hello'); expect(getBufferState(result).text).toBe('Hello');
}); });
@ -907,7 +995,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
const validText = 'Hello World\nThis is a test.'; const validText = 'Hello World\nThis is a test.';
act(() => result.current.handleInput(validText, {})); act(() =>
result.current.handleInput({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: validText,
}),
);
expect(getBufferState(result).text).toBe(validText); expect(getBufferState(result).text).toBe(validText);
}); });
@ -916,7 +1012,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({ viewport, isValidPath: () => false }), useTextBuffer({ viewport, isValidPath: () => false }),
); );
const pastedText = '\u001B[4mPasted\u001B[4m Text'; const pastedText = '\u001B[4mPasted\u001B[4m Text';
act(() => result.current.handleInput(pastedText, {})); act(() =>
result.current.handleInput({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: pastedText,
}),
);
expect(getBufferState(result).text).toBe('Pasted Text'); expect(getBufferState(result).text).toBe('Pasted Text');
}); });
}); });

View File

@ -1220,9 +1220,16 @@ export function useTextBuffer({
); );
const handleInput = useCallback( const handleInput = useCallback(
(input: string | undefined, key: Record<string, boolean>): boolean => { (key: {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
}): boolean => {
const { sequence: input } = key;
dbg('handleInput', { dbg('handleInput', {
input,
key, key,
cursor: [cursorRow, cursorCol], cursor: [cursorRow, cursorCol],
visualCursor, visualCursor,
@ -1231,50 +1238,46 @@ export function useTextBuffer({
const beforeLogicalCursor = [cursorRow, cursorCol]; const beforeLogicalCursor = [cursorRow, cursorCol];
const beforeVisualCursor = [...visualCursor]; const beforeVisualCursor = [...visualCursor];
if (key['escape']) return false; if (key.name === 'escape') return false;
if ( if (
key['return'] || key.name === 'return' ||
input === '\r' || input === '\r' ||
input === '\n' || input === '\n' ||
input === '\\\r' // VSCode terminal represents shift + enter this way input === '\\\r' // VSCode terminal represents shift + enter this way
) )
newline(); newline();
else if (key['leftArrow'] && !key['meta'] && !key['ctrl'] && !key['alt']) else if (key.name === 'left' && !key.meta && !key.ctrl) move('left');
move('left'); else if (key.ctrl && key.name === 'b') move('left');
else if (key['ctrl'] && input === 'b') move('left'); else if (key.name === 'right' && !key.meta && !key.ctrl) move('right');
else if (key['rightArrow'] && !key['meta'] && !key['ctrl'] && !key['alt']) else if (key.ctrl && key.name === 'f') move('right');
move('right'); else if (key.name === 'up') move('up');
else if (key['ctrl'] && input === 'f') move('right'); else if (key.name === 'down') move('down');
else if (key['upArrow']) move('up'); else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft');
else if (key['downArrow']) move('down'); else if (key.meta && key.name === 'b') move('wordLeft');
else if ((key['ctrl'] || key['alt']) && key['leftArrow']) else if ((key.ctrl || key.meta) && key.name === 'right')
move('wordLeft');
else if (key['meta'] && input === 'b') move('wordLeft');
else if ((key['ctrl'] || key['alt']) && key['rightArrow'])
move('wordRight'); move('wordRight');
else if (key['meta'] && input === 'f') move('wordRight'); else if (key.meta && key.name === 'f') move('wordRight');
else if (key['home']) move('home'); else if (key.name === 'home') move('home');
else if (key['ctrl'] && input === 'a') move('home'); else if (key.ctrl && key.name === 'a') move('home');
else if (key['end']) move('end'); else if (key.name === 'end') move('end');
else if (key['ctrl'] && input === 'e') move('end'); else if (key.ctrl && key.name === 'e') move('end');
else if (key['ctrl'] && input === 'w') deleteWordLeft(); else if (key.ctrl && key.name === 'w') deleteWordLeft();
else if ( else if (
(key['meta'] || key['ctrl'] || key['alt']) && (key.meta || key.ctrl) &&
(key['backspace'] || input === '\x7f') (key.name === 'backspace' || input === '\x7f')
) )
deleteWordLeft(); deleteWordLeft();
else if ((key['meta'] || key['ctrl'] || key['alt']) && key['delete']) else if ((key.meta || key.ctrl) && key.name === 'delete')
deleteWordRight(); deleteWordRight();
else if ( else if (
key['backspace'] || key.name === 'backspace' ||
input === '\x7f' || input === '\x7f' ||
(key['ctrl'] && input === 'h') || (key.ctrl && key.name === 'h')
(key['delete'] && !key['shift'])
) )
backspace(); backspace();
else if (key['delete'] || (key['ctrl'] && input === 'd')) del(); else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del();
else if (input && !key['ctrl'] && !key['meta']) { else if (input && !key.ctrl && !key.meta) {
insert(input); insert(input);
} }
@ -1483,10 +1486,14 @@ export interface TextBuffer {
/** /**
* High level "handleInput" receives what Ink gives us. * High level "handleInput" receives what Ink gives us.
*/ */
handleInput: ( handleInput: (key: {
input: string | undefined, name: string;
key: Record<string, boolean>, ctrl: boolean;
) => boolean; meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
}) => boolean;
/** /**
* Opens the current buffer contents in the user's preferred terminal text * Opens the current buffer contents in the user's preferred terminal text
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks * editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks

View File

@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect } from 'react';
const ENABLE_BRACKETED_PASTE = '\x1b[?2004h';
const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
/**
* Enables and disables bracketed paste mode in the terminal.
*
* This hook ensures that bracketed paste mode is enabled when the component
* mounts and disabled when it unmounts or when the process exits.
*/
export const useBracketedPaste = () => {
const cleanup = () => {
process.stdout.write(DISABLE_BRACKETED_PASTE);
};
useEffect(() => {
process.stdout.write(ENABLE_BRACKETED_PASTE);
process.on('exit', cleanup);
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
return () => {
cleanup();
process.removeListener('exit', cleanup);
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
};
}, []);
};

View File

@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef } from 'react';
import { useStdin } from 'ink';
import readline from 'readline';
export interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
}
/**
* A hook that listens for keypress events from stdin, providing a
* key object that mirrors the one from Node's `readline` module,
* adding a 'paste' flag for characters input as part of a bracketed
* paste (when enabled).
*
* Pastes are currently sent as a single key event where the full paste
* is in the sequence field.
*
* @param onKeypress - The callback function to execute on each keypress.
* @param options - Options to control the hook's behavior.
* @param options.isActive - Whether the hook should be actively listening for input.
*/
export function useKeypress(
onKeypress: (key: Key) => void,
{ isActive }: { isActive: boolean },
) {
const { stdin, setRawMode } = useStdin();
const onKeypressRef = useRef(onKeypress);
useEffect(() => {
onKeypressRef.current = onKeypress;
}, [onKeypress]);
useEffect(() => {
if (!isActive || !stdin.isTTY) {
return;
}
setRawMode(true);
const rl = readline.createInterface({ input: stdin });
let isPaste = false;
let pasteBuffer = Buffer.alloc(0);
const handleKeypress = (_: unknown, key: Key) => {
if (key.name === 'paste-start') {
isPaste = true;
} else if (key.name === 'paste-end') {
isPaste = false;
onKeypressRef.current({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: pasteBuffer.toString(),
});
pasteBuffer = Buffer.alloc(0);
} else {
if (isPaste) {
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
} else {
// Handle special keys
if (key.name === 'return' && key.sequence === '\x1B\r') {
key.meta = true;
}
onKeypressRef.current({ ...key, paste: isPaste });
}
}
};
readline.emitKeypressEvents(stdin, rl);
stdin.on('keypress', handleKeypress);
return () => {
stdin.removeListener('keypress', handleKeypress);
rl.close();
setRawMode(false);
// If we are in the middle of a paste, send what we have.
if (isPaste) {
onKeypressRef.current({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: pasteBuffer.toString(),
});
pasteBuffer = Buffer.alloc(0);
}
};
}, [isActive, stdin, setRawMode]);
}