Handle stdin for prompts using readline for escape character parsing (#1972)
This commit is contained in:
parent
5fd6664c4b
commit
4fbffdf617
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
};
|
|
@ -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]);
|
||||||
|
}
|
Loading…
Reference in New Issue