From 4fbffdf617b2fb87c1b663391fbe488c5c81beb8 Mon Sep 17 00:00:00 2001 From: Billy Biggs Date: Fri, 27 Jun 2025 10:57:32 -0700 Subject: [PATCH] Handle stdin for prompts using readline for escape character parsing (#1972) --- packages/cli/src/ui/App.tsx | 2 + .../cli/src/ui/components/InputPrompt.tsx | 90 ++++++------ .../ui/components/shared/text-buffer.test.ts | 130 ++++++++++++++++-- .../src/ui/components/shared/text-buffer.ts | 75 +++++----- .../cli/src/ui/hooks/useBracketedPaste.ts | 37 +++++ packages/cli/src/ui/hooks/useKeypress.ts | 104 ++++++++++++++ 6 files changed, 352 insertions(+), 86 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useBracketedPaste.ts create mode 100644 packages/cli/src/ui/hooks/useKeypress.ts diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 02e5fdf0..f2347d35 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -63,6 +63,7 @@ import { useSessionStats, } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { useBracketedPaste } from './hooks/useBracketedPaste.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import * as fs from 'fs'; import { UpdateNotification } from './components/UpdateNotification.js'; @@ -86,6 +87,7 @@ export const AppWrapper = (props: AppProps) => ( ); const App = ({ config, settings, startupWarnings = [] }: AppProps) => { + useBracketedPaste(); const [updateMessage, setUpdateMessage] = useState(null); const { stdout } = useStdout(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 6721132d..d687cca9 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -5,7 +5,7 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { Text, Box, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; @@ -16,6 +16,7 @@ import stringWidth from 'string-width'; import process from 'node:process'; import { useShellHistory } from '../hooks/useShellHistory.js'; import { useCompletion } from '../hooks/useCompletion.js'; +import { useKeypress, Key } from '../hooks/useKeypress.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { SlashCommand } from '../hooks/slashCommandProcessor.js'; import { Config } from '@google/gemini-cli-core'; @@ -155,29 +156,29 @@ export const InputPrompt: React.FC = ({ ], ); - useInput( - (input, key) => { + const handleInput = useCallback( + (key: Key) => { if (!focus) { return; } const query = buffer.text; - if (input === '!' && query === '' && !completion.showSuggestions) { + if (key.sequence === '!' && query === '' && !completion.showSuggestions) { setShellModeActive(!shellModeActive); buffer.setText(''); // Clear the '!' from input return true; } if (completion.showSuggestions) { - if (key.upArrow) { + if (key.name === 'up') { completion.navigateUp(); return; } - if (key.downArrow) { + if (key.name === 'down') { completion.navigateDown(); return; } - if (key.tab) { + if (key.name === 'tab') { if (completion.suggestions.length > 0) { const targetIndex = completion.activeSuggestionIndex === -1 @@ -189,7 +190,7 @@ export const InputPrompt: React.FC = ({ } return; } - if (key.return) { + if (key.name === 'return') { if (completion.activeSuggestionIndex >= 0) { handleAutocomplete(completion.activeSuggestionIndex); } else if (query.trim()) { @@ -199,19 +200,19 @@ export const InputPrompt: React.FC = ({ } } else { // Keybindings when suggestions are not shown - if (key.ctrl && input === 'l') { + if (key.ctrl && key.name === 'l') { onClearScreen(); - return true; + return; } - if (key.ctrl && input === 'p') { + if (key.ctrl && key.name === 'p') { inputHistory.navigateUp(); - return true; + return; } - if (key.ctrl && input === 'n') { + if (key.ctrl && key.name === 'n') { inputHistory.navigateDown(); - return true; + return; } - if (key.escape) { + if (key.name === 'escape') { if (shellModeActive) { setShellModeActive(false); return; @@ -222,54 +223,55 @@ export const InputPrompt: React.FC = ({ } // Ctrl+A (Home) - if (key.ctrl && input === 'a') { + if (key.ctrl && key.name === 'a') { buffer.move('home'); buffer.moveToOffset(0); return; } // Ctrl+E (End) - if (key.ctrl && input === 'e') { + if (key.ctrl && key.name === 'e') { buffer.move('end'); buffer.moveToOffset(cpLen(buffer.text)); return; } // Ctrl+L (Clear Screen) - if (key.ctrl && input === 'l') { + if (key.ctrl && key.name === 'l') { onClearScreen(); return; } // Ctrl+P (History Up) - if (key.ctrl && input === 'p' && !completion.showSuggestions) { + if (key.ctrl && key.name === 'p' && !completion.showSuggestions) { inputHistory.navigateUp(); return; } // Ctrl+N (History Down) - if (key.ctrl && input === 'n' && !completion.showSuggestions) { + if (key.ctrl && key.name === 'n' && !completion.showSuggestions) { inputHistory.navigateDown(); return; } // Core text editing from MultilineTextEditor's useInput - if (key.ctrl && input === 'k') { + if (key.ctrl && key.name === 'k') { buffer.killLineRight(); return; } - if (key.ctrl && input === 'u') { + if (key.ctrl && key.name === 'u') { buffer.killLineLeft(); return; } const isCtrlX = - (key.ctrl && (input === 'x' || input === '\x18')) || input === '\x18'; + (key.ctrl && (key.name === 'x' || key.sequence === '\x18')) || + key.sequence === '\x18'; const isCtrlEFromEditor = - (key.ctrl && (input === 'e' || input === '\x05')) || - input === '\x05' || + (key.ctrl && (key.name === 'e' || key.sequence === '\x05')) || + key.sequence === '\x05' || (!key.ctrl && - input === 'e' && - input.length === 1 && - input.charCodeAt(0) === 5); + key.name === 'e' && + key.sequence.length === 1 && + key.sequence.charCodeAt(0) === 5); if (isCtrlX || isCtrlEFromEditor) { - if (isCtrlEFromEditor && !(key.ctrl && input === 'e')) { + if (isCtrlEFromEditor && !(key.ctrl && key.name === 'e')) { // Avoid double handling Ctrl+E buffer.openInExternalEditor(); return; @@ -284,16 +286,15 @@ export const InputPrompt: React.FC = ({ process.env['TEXTBUFFER_DEBUG'] === '1' || process.env['TEXTBUFFER_DEBUG'] === 'true' ) { - console.log('[InputPromptCombined] event', { input, key }); + console.log('[InputPromptCombined] event', { key }); } // Ctrl+Enter for newline, Enter for submit - if (key.return) { + if (key.name === 'return') { const [row, col] = buffer.cursor; const line = buffer.lines[row]; const charBefore = col > 0 ? cpSlice(line, col - 1, col) : ''; - - if (key.ctrl || charBefore === '\\') { + if (key.ctrl || key.meta || charBefore === '\\' || key.paste) { // Ctrl+Enter or escaped newline if (charBefore === '\\') { buffer.backspace(); @@ -309,7 +310,7 @@ export const InputPrompt: React.FC = ({ } // Standard arrow navigation within the buffer - if (key.upArrow && !completion.showSuggestions) { + if (key.name === 'up' && !completion.showSuggestions) { if (shellModeActive) { const prevCommand = shellHistory.getPreviousCommand(); if (prevCommand !== null) { @@ -328,7 +329,7 @@ export const InputPrompt: React.FC = ({ } return; } - if (key.downArrow && !completion.showSuggestions) { + if (key.name === 'down' && !completion.showSuggestions) { if (shellModeActive) { const nextCommand = shellHistory.getNextCommand(); if (nextCommand !== null) { @@ -349,13 +350,24 @@ export const InputPrompt: React.FC = ({ } // Fallback to buffer's default input handling - buffer.handleInput(input, key as Record); - }, - { - isActive: focus, + buffer.handleInput(key); }, + [ + focus, + buffer, + completion, + shellModeActive, + setShellModeActive, + onClearScreen, + inputHistory, + handleAutocomplete, + handleSubmitAndClear, + shellHistory, + ], ); + useKeypress(handleInput, { isActive: focus }); + const linesToRender = buffer.viewportVisualLines; const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = buffer.visualCursor; diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 218ed1c3..5ea52ba4 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -574,8 +574,24 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - act(() => result.current.handleInput('h', {})); - act(() => result.current.handleInput('i', {})); + act(() => + 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'); }); @@ -583,7 +599,15 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => 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(['', '']); }); @@ -596,7 +620,15 @@ describe('useTextBuffer', () => { }), ); 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(''); }); @@ -671,9 +703,25 @@ describe('useTextBuffer', () => { }), ); 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]); - 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]); }); @@ -683,7 +731,15 @@ describe('useTextBuffer', () => { ); const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m'; // 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'); }); @@ -691,7 +747,15 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => 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(['', '']); }); @@ -880,7 +944,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots useTextBuffer({ viewport, isValidPath: () => false }), ); 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'); }); @@ -889,7 +961,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots useTextBuffer({ viewport, isValidPath: () => false }), ); 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'); }); @@ -898,7 +978,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots useTextBuffer({ viewport, isValidPath: () => false }), ); 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'); }); @@ -907,7 +995,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots useTextBuffer({ viewport, isValidPath: () => false }), ); 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); }); @@ -916,7 +1012,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots useTextBuffer({ viewport, isValidPath: () => false }), ); 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'); }); }); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 82f10fa1..15fc6d3c 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1220,9 +1220,16 @@ export function useTextBuffer({ ); const handleInput = useCallback( - (input: string | undefined, key: Record): boolean => { + (key: { + name: string; + ctrl: boolean; + meta: boolean; + shift: boolean; + paste: boolean; + sequence: string; + }): boolean => { + const { sequence: input } = key; dbg('handleInput', { - input, key, cursor: [cursorRow, cursorCol], visualCursor, @@ -1231,50 +1238,46 @@ export function useTextBuffer({ const beforeLogicalCursor = [cursorRow, cursorCol]; const beforeVisualCursor = [...visualCursor]; - if (key['escape']) return false; + if (key.name === 'escape') return false; if ( - key['return'] || + key.name === 'return' || input === '\r' || input === '\n' || input === '\\\r' // VSCode terminal represents shift + enter this way ) newline(); - else if (key['leftArrow'] && !key['meta'] && !key['ctrl'] && !key['alt']) - move('left'); - else if (key['ctrl'] && input === 'b') move('left'); - else if (key['rightArrow'] && !key['meta'] && !key['ctrl'] && !key['alt']) - move('right'); - else if (key['ctrl'] && input === 'f') move('right'); - else if (key['upArrow']) move('up'); - else if (key['downArrow']) move('down'); - else if ((key['ctrl'] || key['alt']) && key['leftArrow']) - move('wordLeft'); - else if (key['meta'] && input === 'b') move('wordLeft'); - else if ((key['ctrl'] || key['alt']) && key['rightArrow']) + else if (key.name === 'left' && !key.meta && !key.ctrl) move('left'); + else if (key.ctrl && key.name === 'b') move('left'); + else if (key.name === 'right' && !key.meta && !key.ctrl) move('right'); + else if (key.ctrl && key.name === 'f') move('right'); + else if (key.name === 'up') move('up'); + else if (key.name === 'down') move('down'); + else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft'); + else if (key.meta && key.name === 'b') move('wordLeft'); + else if ((key.ctrl || key.meta) && key.name === 'right') move('wordRight'); - else if (key['meta'] && input === 'f') move('wordRight'); - else if (key['home']) move('home'); - else if (key['ctrl'] && input === 'a') move('home'); - else if (key['end']) move('end'); - else if (key['ctrl'] && input === 'e') move('end'); - else if (key['ctrl'] && input === 'w') deleteWordLeft(); + else if (key.meta && key.name === 'f') move('wordRight'); + else if (key.name === 'home') move('home'); + else if (key.ctrl && key.name === 'a') move('home'); + else if (key.name === 'end') move('end'); + else if (key.ctrl && key.name === 'e') move('end'); + else if (key.ctrl && key.name === 'w') deleteWordLeft(); else if ( - (key['meta'] || key['ctrl'] || key['alt']) && - (key['backspace'] || input === '\x7f') + (key.meta || key.ctrl) && + (key.name === 'backspace' || input === '\x7f') ) deleteWordLeft(); - else if ((key['meta'] || key['ctrl'] || key['alt']) && key['delete']) + else if ((key.meta || key.ctrl) && key.name === 'delete') deleteWordRight(); else if ( - key['backspace'] || + key.name === 'backspace' || input === '\x7f' || - (key['ctrl'] && input === 'h') || - (key['delete'] && !key['shift']) + (key.ctrl && key.name === 'h') ) backspace(); - else if (key['delete'] || (key['ctrl'] && input === 'd')) del(); - else if (input && !key['ctrl'] && !key['meta']) { + else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del(); + else if (input && !key.ctrl && !key.meta) { insert(input); } @@ -1483,10 +1486,14 @@ export interface TextBuffer { /** * High level "handleInput" – receives what Ink gives us. */ - handleInput: ( - input: string | undefined, - key: Record, - ) => boolean; + handleInput: (key: { + name: string; + ctrl: boolean; + meta: boolean; + shift: boolean; + paste: boolean; + sequence: string; + }) => boolean; /** * Opens the current buffer contents in the user's preferred terminal text * editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks diff --git a/packages/cli/src/ui/hooks/useBracketedPaste.ts b/packages/cli/src/ui/hooks/useBracketedPaste.ts new file mode 100644 index 00000000..ae58be3b --- /dev/null +++ b/packages/cli/src/ui/hooks/useBracketedPaste.ts @@ -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); + }; + }, []); +}; diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts new file mode 100644 index 00000000..a8adba8d --- /dev/null +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -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]); +}