Add readline-like keybindings to the input prompts. (#354)

New keybindings in the main input prompt (when auto-suggestions are not active):

  - `Ctrl+L`: Clears the entire screen.
  - `Ctrl+A`: Moves the cursor to the beginning of the current input line.
  - `Ctrl+E`: Moves the cursor to the end of the current input line.
  - `Ctrl+P`: Navigates to the previous command in the input history.
  - `Ctrl+N`: Navigates to the next command in the input history.

In the multiline text editor (e.g., when editing a previous message):
   - `Ctrl+K`: Deletes text from the current cursor position to the end of the line ("kill line right").
This commit is contained in:
DeWitt Clinton 2025-05-14 17:33:37 -07:00 committed by GitHub
parent ff36c93733
commit aec6c0861e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 127 additions and 5 deletions

View File

@ -180,6 +180,11 @@ export const App = ({
[setQuery, setEditorState],
);
const handleClearScreen = useCallback(() => {
clearItems();
refreshStatic();
}, [clearItems, refreshStatic]);
const completion = useCompletion(
query,
config.getTargetDir(),
@ -305,6 +310,8 @@ export const App = ({
navigateSuggestionUp={completion.navigateUp}
navigateSuggestionDown={completion.navigateDown}
resetCompletion={completion.resetCompletionState}
setEditorState={setEditorState}
onClearScreen={handleClearScreen} // Added onClearScreen prop
/>
{completion.showSuggestions && (
<Box>

View File

@ -24,6 +24,8 @@ interface InputPromptProps {
userMessages: readonly string[];
navigateSuggestionUp: () => void;
navigateSuggestionDown: () => void;
setEditorState: (updater: (prevState: EditorState) => EditorState) => void;
onClearScreen: () => void;
}
export interface EditorState {
@ -44,6 +46,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
navigateSuggestionUp,
navigateSuggestionDown,
resetCompletion,
setEditorState,
onClearScreen,
}) => {
const handleSubmit = useCallback(
(submittedValue: string) => {
@ -106,7 +110,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
);
const inputPreprocessor = useCallback(
(input: string, key: Key) => {
(
input: string,
key: Key,
_currentText?: string,
_cursorOffset?: number,
) => {
if (showSuggestions) {
if (key.upArrow) {
navigateSuggestionUp();
@ -136,6 +145,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletion();
return true;
}
} else {
// Keybindings when suggestions are not shown
if (key.ctrl && input === 'a') {
setEditorState((s) => ({ key: s.key + 1, initialCursorOffset: 0 }));
return true;
}
if (key.ctrl && input === 'e') {
setEditorState((s) => ({
key: s.key + 1,
initialCursorOffset: query.length,
}));
return true;
}
if (key.ctrl && input === 'l') {
onClearScreen();
return true;
}
if (key.ctrl && input === 'p') {
inputHistory.navigateUp();
return true;
}
if (key.ctrl && input === 'n') {
inputHistory.navigateDown();
return true;
}
}
return false;
},
@ -149,6 +183,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletion,
activeSuggestionIndex,
handleSubmit,
inputHistory,
setEditorState,
onClearScreen,
],
);

View File

@ -43,7 +43,12 @@ export interface MultilineTextEditorProps {
// Called on all key events to allow the caller. Returns true if the
// event was handled and should not be passed to the editor.
readonly inputPreprocessor?: (input: string, key: Key) => boolean;
readonly inputPreprocessor?: (
input: string,
key: Key,
currentText: string,
cursorOffset: number,
) => boolean;
// Optional initial cursor position (character offset)
readonly initialCursorOffset?: number;
@ -92,7 +97,24 @@ export const MultilineTextEditor = ({
return;
}
if (inputPreprocessor?.(input, key) === true) {
// Calculate cursorOffset for inputPreprocessor
let charOffset = 0;
for (let i = 0; i < buffer.cursor[0]; i++) {
charOffset += buffer.lines[i].length + 1; // +1 for newline
}
charOffset += buffer.cursor[1];
if (inputPreprocessor?.(input, key, buffer.text, charOffset) === true) {
return;
}
if (key.ctrl && input === 'k') {
buffer.killLineRight();
return;
}
if (key.ctrl && input === 'u') {
buffer.killLineLeft();
return;
}

View File

@ -372,9 +372,9 @@ export function useTextBuffer({
pushUndo,
cursorRow,
cursorCol,
lines,
currentLine,
currentLineLen,
lines.length,
setPreferredCol,
]);
@ -521,12 +521,58 @@ export function useTextBuffer({
pushUndo,
cursorRow,
cursorCol,
lines,
currentLine,
del,
lines.length,
setPreferredCol,
]);
const killLineRight = useCallback((): void => {
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
// Cursor is before the end of the line's content, delete text to the right
pushUndo();
setLines((prevLines) => {
const newLines = [...prevLines];
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
return newLines;
});
// Cursor position and preferredCol do not change in this case
} else if (
cursorCol === currentLineLen(cursorRow) &&
cursorRow < lines.length - 1
) {
// Cursor is at the end of the line's content (or line is empty),
// and it's not the last line. Delete the newline.
// `del()` handles pushUndo and setPreferredCol.
del();
}
// If cursor is at the end of the line and it's the last line, do nothing.
}, [
pushUndo,
cursorRow,
cursorCol,
currentLine,
currentLineLen,
lines.length,
del,
]);
const killLineLeft = useCallback((): void => {
const lineContent = currentLine(cursorRow);
// Only act if the cursor is not at the beginning of the line
if (cursorCol > 0) {
pushUndo();
setLines((prevLines) => {
const newLines = [...prevLines];
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
return newLines;
});
setCursorCol(0);
setPreferredCol(null);
}
}, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
const move = useCallback(
(dir: Direction): void => {
const before = [cursorRow, cursorCol];
@ -772,6 +818,8 @@ export function useTextBuffer({
replaceRange,
deleteWordLeft,
deleteWordRight,
killLineRight,
killLineLeft,
handleInput,
openInExternalEditor,
@ -872,6 +920,14 @@ export interface TextBuffer {
* follows the caret and the next contiguous run of word characters.
*/
deleteWordRight: () => void;
/**
* Deletes text from the cursor to the end of the current line.
*/
killLineRight: () => void;
/**
* Deletes text from the start of the current line to the cursor.
*/
killLineLeft: () => void;
/**
* High level "handleInput" receives what Ink gives us.
*/