diff options
| author | Jacob Richman <[email protected]> | 2025-05-20 16:50:32 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-05-20 16:50:32 -0700 |
| commit | 02ab0c234cc8c430bfb1c24f810cd410133a3297 (patch) | |
| tree | fa0afcd718b5029f870914fbe096dfd289fbb585 /packages/cli/src/ui/components/InputPrompt.tsx | |
| parent | 937f4736513fd25d4aa1ad8d368c736b00a16b6f (diff) | |
Merge InputPrompt and multiline-editor and move autocomplete logic directly into InputPrompt (#453)
Diffstat (limited to 'packages/cli/src/ui/components/InputPrompt.tsx')
| -rw-r--r-- | packages/cli/src/ui/components/InputPrompt.tsx | 434 |
1 files changed, 299 insertions, 135 deletions
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 38af2a8c..26c9d14f 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -5,147 +5,174 @@ */ import React, { useCallback } from 'react'; -import { Text, Box, Key } from 'ink'; +import { Text, Box, useInput, useStdin } from 'ink'; import { Colors } from '../colors.js'; -import { Suggestion } from './SuggestionsDisplay.js'; -import { MultilineTextEditor } from './shared/multiline-editor.js'; +import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; +import { useTextBuffer, cpSlice, cpLen } from './shared/text-buffer.js'; +import chalk from 'chalk'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import stringWidth from 'string-width'; +import process from 'node:process'; +import { useCompletion } from '../hooks/useCompletion.js'; +import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; +import { SlashCommand } from '../hooks/slashCommandProcessor.js'; +import { Config } from '@gemini-code/server'; interface InputPromptProps { - query: string; - onChange: (value: string) => void; - onChangeAndMoveCursor: (value: string) => void; - editorState: EditorState; onSubmit: (value: string) => void; - showSuggestions: boolean; - suggestions: Suggestion[]; - activeSuggestionIndex: number; - resetCompletion: () => void; userMessages: readonly string[]; - navigateSuggestionUp: () => void; - navigateSuggestionDown: () => void; onClearScreen: () => void; + config: Config; // Added config for useCompletion + slashCommands: SlashCommand[]; // Added slashCommands for useCompletion + placeholder?: string; + height?: number; // Visible height of the editor area + focus?: boolean; + widthFraction: number; shellModeActive: boolean; setShellModeActive: (value: boolean) => void; } -export interface EditorState { - key: number; - initialCursorOffset?: number; -} - export const InputPrompt: React.FC<InputPromptProps> = ({ - query, - onChange, - onChangeAndMoveCursor, - editorState, onSubmit, - showSuggestions, - suggestions, - activeSuggestionIndex, userMessages, - navigateSuggestionUp, - navigateSuggestionDown, - resetCompletion, onClearScreen, + config, + slashCommands, + placeholder = 'Enter your message or use tools (e.g., @src/file.txt)...', + height = 10, + focus = true, + widthFraction, shellModeActive, setShellModeActive, }) => { - const handleSubmit = useCallback( + const terminalSize = useTerminalSize(); + const padding = 3; + const effectiveWidth = Math.max( + 20, + Math.round(terminalSize.columns * widthFraction) - padding, + ); + const suggestionsWidth = Math.max(60, Math.floor(terminalSize.columns * 0.8)); + + const { stdin, setRawMode } = useStdin(); + + const buffer = useTextBuffer({ + initialText: '', + viewport: { height, width: effectiveWidth }, + stdin, + setRawMode, + }); + + const completion = useCompletion( + buffer.text, + config.getTargetDir(), + isAtCommand(buffer.text) || isSlashCommand(buffer.text), + slashCommands, + ); + + const resetCompletionState = completion.resetCompletionState; + + const handleSubmitAndClear = useCallback( (submittedValue: string) => { onSubmit(submittedValue); - onChangeAndMoveCursor(''); // Clear query after submit + buffer.setText(''); + resetCompletionState(); }, - [onSubmit, onChangeAndMoveCursor], + [onSubmit, buffer, resetCompletionState], + ); + + const onChangeAndMoveCursor = useCallback( + (newValue: string) => { + buffer.setText(newValue); + buffer.move('end'); + }, + [buffer], ); const inputHistory = useInputHistory({ userMessages, - onSubmit: handleSubmit, - isActive: !showSuggestions, // Input history is active when suggestions are not shown - currentQuery: query, + onSubmit: handleSubmitAndClear, + isActive: !completion.showSuggestions, + currentQuery: buffer.text, onChangeAndMoveCursor, }); + const completionSuggestions = completion.suggestions; const handleAutocomplete = useCallback( (indexToUse: number) => { - if (indexToUse < 0 || indexToUse >= suggestions.length) { + if (indexToUse < 0 || indexToUse >= completionSuggestions.length) { return; } - const selectedSuggestion = suggestions[indexToUse]; - const trimmedQuery = query.trimStart(); + const query = buffer.text; + const selectedSuggestion = completionSuggestions[indexToUse]; - if (trimmedQuery.startsWith('/')) { - // Handle / command completion + if (query.trimStart().startsWith('/')) { const slashIndex = query.indexOf('/'); const base = query.substring(0, slashIndex + 1); const newValue = base + selectedSuggestion.value; - onChangeAndMoveCursor(newValue); - onSubmit(newValue); // Execute the command - onChangeAndMoveCursor(''); // Clear query after submit + buffer.setText(newValue); + handleSubmitAndClear(newValue); } else { - // Handle @ command completion const atIndex = query.lastIndexOf('@'); if (atIndex === -1) return; - - // Find the part of the query after the '@' const pathPart = query.substring(atIndex + 1); - // Find the last slash within that part const lastSlashIndexInPath = pathPart.lastIndexOf('/'); - - let base = ''; - if (lastSlashIndexInPath === -1) { - // No slash after '@', replace everything after '@' - base = query.substring(0, atIndex + 1); - } else { - // Slash found, keep everything up to and including the last slash - base = query.substring(0, atIndex + 1 + lastSlashIndexInPath + 1); + let autoCompleteStartIndex = atIndex + 1; + if (lastSlashIndexInPath !== -1) { + autoCompleteStartIndex += lastSlashIndexInPath + 1; } - - const newValue = base + selectedSuggestion.value; - onChangeAndMoveCursor(newValue); + buffer.replaceRangeByOffset( + autoCompleteStartIndex, + buffer.text.length, + selectedSuggestion.value, + ); } - - resetCompletion(); // Hide suggestions after selection + resetCompletionState(); }, - [query, suggestions, resetCompletion, onChangeAndMoveCursor, onSubmit], + [resetCompletionState, handleSubmitAndClear, buffer, completionSuggestions], ); - const inputPreprocessor = useCallback( - (input: string, key: Key) => { - if (input === '!' && query === '' && !showSuggestions) { + useInput( + (input, key) => { + if (!focus) { + return; + } + const query = buffer.text; + + if (input === '!' && query === '' && !completion.showSuggestions) { setShellModeActive(!shellModeActive); - onChangeAndMoveCursor(''); // Clear the '!' from input + buffer.setText(''); // Clear the '!' from input return true; } - if (showSuggestions) { + + if (completion.showSuggestions) { if (key.upArrow) { - navigateSuggestionUp(); - return true; - } else if (key.downArrow) { - navigateSuggestionDown(); - return true; - } else if (key.tab) { - if (suggestions.length > 0) { + completion.navigateUp(); + return; + } + if (key.downArrow) { + completion.navigateDown(); + return; + } + if (key.tab) { + if (completion.suggestions.length > 0) { const targetIndex = - activeSuggestionIndex === -1 ? 0 : activeSuggestionIndex; - if (targetIndex < suggestions.length) { + completion.activeSuggestionIndex === -1 + ? 0 + : completion.activeSuggestionIndex; + if (targetIndex < completion.suggestions.length) { handleAutocomplete(targetIndex); - return true; } } - } else if (key.return) { - if (activeSuggestionIndex >= 0) { - handleAutocomplete(activeSuggestionIndex); - } else { - if (query.trim()) { - handleSubmit(query); - } + return; + } + if (key.return) { + if (completion.activeSuggestionIndex >= 0) { + handleAutocomplete(completion.activeSuggestionIndex); + } else if (query.trim()) { + handleSubmitAndClear(query); } - return true; - } else if (key.escape) { - resetCompletion(); - return true; + return; } } else { // Keybindings when suggestions are not shown @@ -161,60 +188,197 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ inputHistory.navigateDown(); return true; } + if (key.escape) { + completion.resetCompletionState(); + return; + } + } + + // Ctrl+A (Home) + if (key.ctrl && input === 'a') { + buffer.move('home'); + buffer.moveToOffset(0); + return; + } + // Ctrl+E (End) + if (key.ctrl && input === 'e') { + buffer.move('end'); + buffer.moveToOffset(cpLen(buffer.text)); + return; + } + // Ctrl+L (Clear Screen) + if (key.ctrl && input === 'l') { + onClearScreen(); + return; + } + // Ctrl+P (History Up) + if (key.ctrl && input === 'p' && !completion.showSuggestions) { + inputHistory.navigateUp(); + return; + } + // Ctrl+N (History Down) + if (key.ctrl && input === 'n' && !completion.showSuggestions) { + inputHistory.navigateDown(); + return; + } + + // Core text editing from MultilineTextEditor's useInput + if (key.ctrl && input === 'k') { + buffer.killLineRight(); + return; + } + if (key.ctrl && input === 'u') { + buffer.killLineLeft(); + return; + } + const isCtrlX = + (key.ctrl && (input === 'x' || input === '\x18')) || input === '\x18'; + const isCtrlEFromEditor = + (key.ctrl && (input === 'e' || input === '\x05')) || + input === '\x05' || + (!key.ctrl && + input === 'e' && + input.length === 1 && + input.charCodeAt(0) === 5); + + if (isCtrlX || isCtrlEFromEditor) { + if (isCtrlEFromEditor && !(key.ctrl && input === 'e')) { + // Avoid double handling Ctrl+E + buffer.openInExternalEditor(); + return; + } + if (isCtrlX) { + buffer.openInExternalEditor(); + return; + } + } + + if ( + process.env['TEXTBUFFER_DEBUG'] === '1' || + process.env['TEXTBUFFER_DEBUG'] === 'true' + ) { + console.log('[InputPromptCombined] event', { input, key }); + } + + // Ctrl+Enter for newline, Enter for submit + if (key.return) { + if (key.ctrl) { + // Ctrl+Enter for newline + buffer.newline(); + } else { + // Enter for submit + if (query.trim()) { + handleSubmitAndClear(query); + } + } + return; + } + + // Standard arrow navigation within the buffer + if (key.upArrow && !completion.showSuggestions) { + if ( + buffer.visualCursor[0] === 0 && + buffer.visualScrollRow === 0 && + inputHistory.navigateUp + ) { + inputHistory.navigateUp(); + } else { + buffer.move('up'); + } + return; } - return false; + if (key.downArrow && !completion.showSuggestions) { + if ( + buffer.visualCursor[0] === buffer.allVisualLines.length - 1 && + inputHistory.navigateDown + ) { + inputHistory.navigateDown(); + } else { + buffer.move('down'); + } + return; + } + + // Fallback to buffer's default input handling + buffer.handleInput(input, key as Record<string, boolean>); + }, + { + isActive: focus, }, - [ - handleAutocomplete, - navigateSuggestionDown, - navigateSuggestionUp, - query, - suggestions, - showSuggestions, - resetCompletion, - activeSuggestionIndex, - handleSubmit, - inputHistory, - onClearScreen, - shellModeActive, - setShellModeActive, - onChangeAndMoveCursor, - ], ); + const linesToRender = buffer.viewportVisualLines; + const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = + buffer.visualCursor; + const scrollVisualRow = buffer.visualScrollRow; + return ( - <Box - borderStyle="round" - borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue} - paddingX={1} - > - <Text color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}> - {shellModeActive ? '! ' : '> '} - </Text> - <Box flexGrow={1}> - <MultilineTextEditor - key={editorState.key.toString()} - initialCursorOffset={editorState.initialCursorOffset} - initialText={query} - onChange={onChange} - placeholder="Type your message or @path/to/file" - /* Account for width used by the box and > */ - navigateUp={inputHistory.navigateUp} - navigateDown={inputHistory.navigateDown} - inputPreprocessor={inputPreprocessor} - widthUsedByParent={3} - widthFraction={0.9} - onSubmit={() => { - // This onSubmit is for the TextInput component itself. - // It should only fire if suggestions are NOT showing, - // as inputPreprocessor handles Enter when suggestions are visible. - const trimmedQuery = query.trim(); - if (!showSuggestions && trimmedQuery) { - handleSubmit(trimmedQuery); - } - }} - /> + <> + <Box + borderStyle="round" + borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue} + paddingX={1} + > + <Text + color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple} + > + {shellModeActive ? '! ' : '> '} + </Text> + <Box flexGrow={1} flexDirection="column"> + {buffer.text.length === 0 && placeholder ? ( + <Text color={Colors.SubtleComment}>{placeholder}</Text> + ) : ( + linesToRender.map((lineText, visualIdxInRenderedSet) => { + const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; + let display = cpSlice(lineText, 0, effectiveWidth); + const currentVisualWidth = stringWidth(display); + if (currentVisualWidth < effectiveWidth) { + display = + display + ' '.repeat(effectiveWidth - currentVisualWidth); + } + + if (visualIdxInRenderedSet === cursorVisualRow) { + const relativeVisualColForHighlight = cursorVisualColAbsolute; + if (relativeVisualColForHighlight >= 0) { + if (relativeVisualColForHighlight < cpLen(display)) { + const charToHighlight = + cpSlice( + display, + relativeVisualColForHighlight, + relativeVisualColForHighlight + 1, + ) || ' '; + const highlighted = chalk.inverse(charToHighlight); + display = + cpSlice(display, 0, relativeVisualColForHighlight) + + highlighted + + cpSlice(display, relativeVisualColForHighlight + 1); + } else if ( + relativeVisualColForHighlight === cpLen(display) && + cpLen(display) === effectiveWidth + ) { + display = display + chalk.inverse(' '); + } + } + } + return ( + <Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text> + ); + }) + )} + </Box> </Box> - </Box> + {completion.showSuggestions && ( + <Box> + <SuggestionsDisplay + suggestions={completion.suggestions} + activeIndex={completion.activeSuggestionIndex} + isLoading={completion.isLoadingSuggestions} + width={suggestionsWidth} + scrollOffset={completion.visibleStartIndex} + userInput={buffer.text} + /> + </Box> + )} + </> ); }; |
