summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/InputPrompt.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components/InputPrompt.tsx')
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx434
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 &gt; */
- 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>
+ )}
+ </>
);
};