From aec6c0861e8244cdaefda14840844e922705c8fa Mon Sep 17 00:00:00 2001 From: DeWitt Clinton Date: Wed, 14 May 2025 17:33:37 -0700 Subject: [PATCH] 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"). --- packages/cli/src/ui/App.tsx | 7 +++ .../cli/src/ui/components/InputPrompt.tsx | 39 +++++++++++- .../ui/components/shared/multiline-editor.tsx | 26 +++++++- .../src/ui/components/shared/text-buffer.ts | 60 ++++++++++++++++++- 4 files changed, 127 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index e1ae8da3..e4cd5de9 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -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 && ( diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 1c3d2a07..b1e05554 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ navigateSuggestionUp, navigateSuggestionDown, resetCompletion, + setEditorState, + onClearScreen, }) => { const handleSubmit = useCallback( (submittedValue: string) => { @@ -106,7 +110,12 @@ export const InputPrompt: React.FC = ({ ); 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 = ({ 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 = ({ resetCompletion, activeSuggestionIndex, handleSubmit, + inputHistory, + setEditorState, + onClearScreen, ], ); diff --git a/packages/cli/src/ui/components/shared/multiline-editor.tsx b/packages/cli/src/ui/components/shared/multiline-editor.tsx index bd49efcb..e1e21fff 100644 --- a/packages/cli/src/ui/components/shared/multiline-editor.tsx +++ b/packages/cli/src/ui/components/shared/multiline-editor.tsx @@ -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; } diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index f2cb1ae2..661df70c 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -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. */