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
6 changed files with 352 additions and 86 deletions

View File

@@ -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');
});
});

View File

@@ -1220,9 +1220,16 @@ export function useTextBuffer({
);
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', {
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<string, boolean>,
) => 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