Multiline editor (#302)

Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
Jacob Richman 2025-05-13 11:24:04 -07:00 committed by GitHub
parent 8da7a71d9a
commit e2c3611c63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1501 additions and 113 deletions

View File

@ -13,7 +13,7 @@ import { useInputHistory } from './hooks/useInputHistory.js';
import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useThemeCommand } from './hooks/useThemeCommand.js';
import { Header } from './components/Header.js'; import { Header } from './components/Header.js';
import { LoadingIndicator } from './components/LoadingIndicator.js'; import { LoadingIndicator } from './components/LoadingIndicator.js';
import { InputPrompt } from './components/InputPrompt.js'; import { EditorState, InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js'; import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js'; import { ThemeDialog } from './components/ThemeDialog.js';
import { useStartupWarnings } from './hooks/useAppEffects.js'; import { useStartupWarnings } from './hooks/useAppEffects.js';
@ -97,8 +97,22 @@ export const App = ({ config, settings, cliVersion }: AppProps) => {
const isInputActive = streamingState === StreamingState.Idle && !initError; const isInputActive = streamingState === StreamingState.Idle && !initError;
// query and setQuery are now managed by useState here
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [editorState, setEditorState] = useState<EditorState>({
key: 0,
initialCursorOffset: undefined,
});
const onChangeAndMoveCursor = useCallback(
(value: string) => {
setQuery(value);
setEditorState((s) => ({
key: s.key + 1,
initialCursorOffset: value.length,
}));
},
[setQuery, setEditorState],
);
const completion = useCompletion( const completion = useCompletion(
query, query,
@ -107,20 +121,16 @@ export const App = ({ config, settings, cliVersion }: AppProps) => {
slashCommands, slashCommands,
); );
const { const inputHistory = useInputHistory({
handleSubmit: handleHistorySubmit,
inputKey,
setInputKey,
} = useInputHistory({
userMessages, userMessages,
onSubmit: (value) => { onSubmit: (value) => {
// Adapt onSubmit to use the lifted setQuery // Adapt onSubmit to use the lifted setQuery
handleFinalSubmit(value); handleFinalSubmit(value);
setQuery(''); // Clear query from the App's state onChangeAndMoveCursor('');
}, },
isActive: isInputActive && !completion.showSuggestions, isActive: isInputActive && !completion.showSuggestions,
query, currentQuery: query,
setQuery, onChangeAndMoveCursor,
}); });
// --- Render Logic --- // --- Render Logic ---
@ -223,15 +233,17 @@ export const App = ({ config, settings, cliVersion }: AppProps) => {
<InputPrompt <InputPrompt
query={query} query={query}
setQuery={setQuery} onChange={setQuery}
inputKey={inputKey} onChangeAndMoveCursor={onChangeAndMoveCursor}
setInputKey={setInputKey} editorState={editorState}
onSubmit={handleHistorySubmit} onSubmit={inputHistory.handleSubmit}
showSuggestions={completion.showSuggestions} showSuggestions={completion.showSuggestions}
suggestions={completion.suggestions} suggestions={completion.suggestions}
activeSuggestionIndex={completion.activeSuggestionIndex} activeSuggestionIndex={completion.activeSuggestionIndex}
navigateUp={completion.navigateUp} navigateHistoryUp={inputHistory.navigateUp}
navigateDown={completion.navigateDown} navigateHistoryDown={inputHistory.navigateDown}
navigateSuggestionUp={completion.navigateUp}
navigateSuggestionDown={completion.navigateDown}
resetCompletion={completion.resetCompletionState} resetCompletion={completion.resetCompletionState}
/> />
{completion.showSuggestions && ( {completion.showSuggestions && (

View File

@ -5,40 +5,47 @@
*/ */
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Text, Box, useInput, useFocus, Key } from 'ink'; import { Text, Box, Key } from 'ink';
import TextInput from 'ink-text-input';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { Suggestion } from './SuggestionsDisplay.js'; import { Suggestion } from './SuggestionsDisplay.js';
import { MultilineTextEditor } from './shared/multiline-editor.js';
interface InputPromptProps { interface InputPromptProps {
query: string; query: string;
setQuery: React.Dispatch<React.SetStateAction<string>>; onChange: (value: string) => void;
inputKey: number; onChangeAndMoveCursor: (value: string) => void;
setInputKey: React.Dispatch<React.SetStateAction<number>>; editorState: EditorState;
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
showSuggestions: boolean; showSuggestions: boolean;
suggestions: Suggestion[]; suggestions: Suggestion[];
activeSuggestionIndex: number; activeSuggestionIndex: number;
navigateUp: () => void;
navigateDown: () => void;
resetCompletion: () => void; resetCompletion: () => void;
navigateHistoryUp: () => void;
navigateHistoryDown: () => void;
navigateSuggestionUp: () => void;
navigateSuggestionDown: () => void;
}
export interface EditorState {
key: number;
initialCursorOffset?: number;
} }
export const InputPrompt: React.FC<InputPromptProps> = ({ export const InputPrompt: React.FC<InputPromptProps> = ({
query, query,
setQuery, onChange,
inputKey, onChangeAndMoveCursor,
setInputKey, editorState,
onSubmit, onSubmit,
showSuggestions, showSuggestions,
suggestions, suggestions,
activeSuggestionIndex, activeSuggestionIndex,
navigateUp, navigateHistoryUp,
navigateDown, navigateHistoryDown,
navigateSuggestionUp,
navigateSuggestionDown,
resetCompletion, resetCompletion,
}) => { }) => {
const { isFocused } = useFocus({ autoFocus: true });
const handleAutocomplete = useCallback( const handleAutocomplete = useCallback(
(indexToUse: number) => { (indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= suggestions.length) { if (indexToUse < 0 || indexToUse >= suggestions.length) {
@ -52,7 +59,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const slashIndex = query.indexOf('/'); const slashIndex = query.indexOf('/');
const base = query.substring(0, slashIndex + 1); const base = query.substring(0, slashIndex + 1);
const newValue = base + selectedSuggestion.value; const newValue = base + selectedSuggestion.value;
setQuery(newValue); onChangeAndMoveCursor(newValue);
} else { } else {
// Handle @ command completion // Handle @ command completion
const atIndex = query.lastIndexOf('@'); const atIndex = query.lastIndexOf('@');
@ -73,32 +80,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
const newValue = base + selectedSuggestion.value; const newValue = base + selectedSuggestion.value;
setQuery(newValue); onChangeAndMoveCursor(newValue);
} }
resetCompletion(); // Hide suggestions after selection resetCompletion(); // Hide suggestions after selection
setInputKey((k) => k + 1); // Increment key to force re-render and cursor reset
}, },
[query, setQuery, suggestions, resetCompletion, setInputKey], [query, suggestions, resetCompletion, onChangeAndMoveCursor],
); );
useInput( const inputPreprocessor = useCallback(
(input: string, key: Key) => { (input: string, key: Key) => {
if (!isFocused) {
return;
}
if (showSuggestions) { if (showSuggestions) {
if (key.upArrow) { if (key.upArrow) {
navigateUp(); navigateSuggestionUp();
return true;
} else if (key.downArrow) { } else if (key.downArrow) {
navigateDown(); navigateSuggestionDown();
return true;
} else if (key.tab) { } else if (key.tab) {
if (suggestions.length > 0) { if (suggestions.length > 0) {
const targetIndex = const targetIndex =
activeSuggestionIndex === -1 ? 0 : activeSuggestionIndex; activeSuggestionIndex === -1 ? 0 : activeSuggestionIndex;
if (targetIndex < suggestions.length) { if (targetIndex < suggestions.length) {
handleAutocomplete(targetIndex); handleAutocomplete(targetIndex);
return true;
} }
} }
} else if (key.return) { } else if (key.return) {
@ -109,34 +114,51 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit(query); onSubmit(query);
} }
} }
return true;
} else if (key.escape) { } else if (key.escape) {
resetCompletion(); resetCompletion();
return true;
} }
} }
// Enter key when suggestions are NOT showing is handled by TextInput's onSubmit prop below return false;
}, },
{ isActive: true }, [
handleAutocomplete,
navigateSuggestionDown,
navigateSuggestionUp,
query,
suggestions,
showSuggestions,
resetCompletion,
activeSuggestionIndex,
onSubmit,
],
); );
return ( return (
<Box borderStyle="round" borderColor={Colors.AccentBlue} paddingX={1}> <Box borderStyle="round" borderColor={Colors.AccentBlue} paddingX={1}>
<Text color={Colors.AccentPurple}>&gt; </Text> <Text color={Colors.AccentPurple}>&gt; </Text>
<Box flexGrow={1}> <Box flexGrow={1}>
<TextInput <MultilineTextEditor
key={inputKey.toString()} key={editorState.key.toString()}
value={query} initialCursorOffset={editorState.initialCursorOffset}
onChange={setQuery} initialText={query}
onChange={onChange}
placeholder="Enter your message or use tools (e.g., @src/file.txt)..." placeholder="Enter your message or use tools (e.g., @src/file.txt)..."
/* Account for width used by the box and &gt; */
navigateUp={navigateHistoryUp}
navigateDown={navigateHistoryDown}
inputPreprocessor={inputPreprocessor}
widthUsedByParent={3}
widthFraction={0.9}
onSubmit={() => { onSubmit={() => {
// This onSubmit is for the TextInput component itself. // This onSubmit is for the TextInput component itself.
// It should only fire if suggestions are NOT showing, // It should only fire if suggestions are NOT showing,
// as useInput handles Enter when suggestions are visible. // as inputPreprocessor handles Enter when suggestions are visible.
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
if (!showSuggestions && trimmedQuery) { if (!showSuggestions && trimmedQuery) {
onSubmit(trimmedQuery); onSubmit(trimmedQuery);
} }
// If suggestions ARE showing, useInput's Enter handler
// would have already dealt with it (either completing or submitting).
}} }}
/> />
</Box> </Box>

View File

@ -0,0 +1,276 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { TextBuffer } from './text-buffer.js';
import chalk from 'chalk';
import { Box, Text, useInput, useStdin, Key } from 'ink';
import React, { useState, useCallback } from 'react';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Colors } from '../../colors.js';
export interface MultilineTextEditorProps {
// Initial contents.
readonly initialText?: string;
// Placeholder text.
readonly placeholder?: string;
// Visible width.
readonly width?: number;
// Visible height.
readonly height?: number;
// Called when the user submits (plain <Enter> key).
readonly onSubmit?: (text: string) => void;
// Capture keyboard input.
readonly focus?: boolean;
// Called when the internal text buffer updates.
readonly onChange?: (text: string) => void;
// Called when the user attempts to navigate past the start of the editor
// with the up arrow.
readonly navigateUp?: () => void;
// Called when the user attempts to navigate past the end of the editor
// with the down arrow.
readonly navigateDown?: () => void;
// 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;
// Optional initial cursor position (character offset)
readonly initialCursorOffset?: number;
readonly widthUsedByParent: number;
readonly widthFraction?: number;
}
export const MultilineTextEditor = ({
initialText = '',
placeholder = '',
width,
height = 10,
onSubmit,
focus = true,
onChange,
initialCursorOffset,
widthUsedByParent,
widthFraction = 1,
navigateUp,
navigateDown,
inputPreprocessor,
}: MultilineTextEditorProps): React.ReactElement => {
const [buffer, setBuffer] = useState(
() => new TextBuffer(initialText, initialCursorOffset),
);
const terminalSize = useTerminalSize();
const effectiveWidth = Math.max(
20,
width ??
Math.round(terminalSize.columns * widthFraction) - widthUsedByParent,
);
const { stdin, setRawMode } = useStdin();
// TODO(jacobr): make TextBuffer immutable rather than this hack to act
// like it is immutable.
const updateBufferState = useCallback(
(mutator: (currentBuffer: TextBuffer) => void) => {
setBuffer((currentBuffer) => {
mutator(currentBuffer);
// Create a new instance from the mutated buffer to trigger re-render
return TextBuffer.fromBuffer(currentBuffer);
});
},
[],
);
const openExternalEditor = useCallback(async () => {
const wasRaw = stdin?.isRaw ?? false;
try {
setRawMode?.(false);
// openInExternalEditor mutates the buffer instance
await buffer.openInExternalEditor();
} catch (err) {
console.error('[MultilineTextEditor] external editor error', err);
} finally {
if (wasRaw) {
setRawMode?.(true);
}
// Update state with the mutated buffer to trigger re-render
setBuffer(TextBuffer.fromBuffer(buffer));
}
}, [buffer, stdin, setRawMode, setBuffer]);
useInput(
(input, key) => {
if (!focus) {
return;
}
if (inputPreprocessor?.(input, key) === true) {
return;
}
const isCtrlX =
(key.ctrl && (input === 'x' || input === '\x18')) || input === '\x18';
const isCtrlE =
(key.ctrl && (input === 'e' || input === '\x05')) ||
input === '\x05' ||
(!key.ctrl &&
input === 'e' &&
input.length === 1 &&
input.charCodeAt(0) === 5);
if (isCtrlX || isCtrlE) {
openExternalEditor();
return;
}
if (
process.env['TEXTBUFFER_DEBUG'] === '1' ||
process.env['TEXTBUFFER_DEBUG'] === 'true'
) {
console.log('[MultilineTextEditor] event', { input, key });
}
let bufferMutated = false;
if (input.startsWith('[') && input.endsWith('u')) {
const m = input.match(/^\[([0-9]+);([0-9]+)u$/);
if (m && m[1] === '13') {
const mod = Number(m[2]);
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
if (hasCtrl) {
if (onSubmit) {
onSubmit(buffer.getText());
}
} else {
buffer.newline();
bufferMutated = true;
}
if (bufferMutated) {
updateBufferState((_) => {}); // Trigger re-render if mutated
}
return;
}
}
if (input.startsWith('[27;') && input.endsWith('~')) {
const m = input.match(/^\[27;([0-9]+);13~$/);
if (m) {
const mod = Number(m[1]);
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
if (hasCtrl) {
if (onSubmit) {
onSubmit(buffer.getText());
}
} else {
buffer.newline();
bufferMutated = true;
}
if (bufferMutated) {
updateBufferState((_) => {}); // Trigger re-render if mutated
}
return;
}
}
if (input === '\n') {
buffer.newline();
updateBufferState((_) => {});
return;
}
if (input === '\r') {
if (onSubmit) {
onSubmit(buffer.getText());
}
return;
}
if (key.upArrow) {
if (buffer.getCursor()[0] === 0 && navigateUp) {
navigateUp();
return;
}
}
if (key.downArrow) {
if (
buffer.getCursor()[0] === buffer.getText().split('\n').length - 1 &&
navigateDown
) {
navigateDown();
return;
}
}
const modifiedByHandleInput = buffer.handleInput(
input,
key as Record<string, boolean>,
{ height, width: effectiveWidth },
);
if (modifiedByHandleInput) {
updateBufferState((_) => {});
}
const newText = buffer.getText();
if (onChange) {
onChange(newText);
}
},
{ isActive: focus },
);
const visibleLines = buffer.getVisibleLines({
height,
width: effectiveWidth,
});
const [cursorRow, cursorCol] = buffer.getCursor();
const scrollRow = buffer.getScrollRow();
const scrollCol = buffer.getScrollCol();
return (
<Box flexDirection="column">
{buffer.getText().length === 0 && placeholder ? (
<Text color={Colors.SubtleComment}>{placeholder}</Text>
) : (
visibleLines.map((lineText, idx) => {
const absoluteRow = scrollRow + idx;
let display = lineText.slice(scrollCol, scrollCol + effectiveWidth);
if (display.length < effectiveWidth) {
display = display.padEnd(effectiveWidth, ' ');
}
if (absoluteRow === cursorRow) {
const relativeCol = cursorCol - scrollCol;
const highlightCol = relativeCol;
if (highlightCol >= 0 && highlightCol < effectiveWidth) {
const charToHighlight = display[highlightCol] || ' ';
const highlighted = chalk.inverse(charToHighlight);
display =
display.slice(0, highlightCol) +
highlighted +
display.slice(highlightCol + 1);
} else if (relativeCol === effectiveWidth) {
display =
display.slice(0, effectiveWidth - 1) + chalk.inverse(' ');
}
}
return <Text key={idx}>{display}</Text>;
})
)}
</Box>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -4,36 +4,32 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useCallback, useState } from 'react'; import { useState, useCallback } from 'react';
import { useInput } from 'ink';
interface UseInputHistoryProps { interface UseInputHistoryProps {
userMessages: readonly string[]; userMessages: readonly string[];
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
isActive: boolean; isActive: boolean;
query: string; currentQuery: string; // Renamed from query to avoid confusion
setQuery: React.Dispatch<React.SetStateAction<string>>; onChangeAndMoveCursor: (value: string) => void;
} }
interface UseInputHistoryReturn { interface UseInputHistoryReturn {
query: string;
setQuery: React.Dispatch<React.SetStateAction<string>>;
handleSubmit: (value: string) => void; handleSubmit: (value: string) => void;
inputKey: number; navigateUp: () => boolean;
setInputKey: React.Dispatch<React.SetStateAction<number>>; navigateDown: () => boolean;
} }
export function useInputHistory({ export function useInputHistory({
userMessages, userMessages,
onSubmit, onSubmit,
isActive, isActive,
query, currentQuery,
setQuery, onChangeAndMoveCursor: setQueryAndMoveCursor,
}: UseInputHistoryProps): UseInputHistoryReturn { }: UseInputHistoryProps): UseInputHistoryReturn {
const [historyIndex, setHistoryIndex] = useState<number>(-1); const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [originalQueryBeforeNav, setOriginalQueryBeforeNav] = const [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
useState<string>(''); useState<string>('');
const [inputKey, setInputKey] = useState<number>(0);
const resetHistoryNav = useCallback(() => { const resetHistoryNav = useCallback(() => {
setHistoryIndex(-1); setHistoryIndex(-1);
@ -44,71 +40,72 @@ export function useInputHistory({
(value: string) => { (value: string) => {
const trimmedValue = value.trim(); const trimmedValue = value.trim();
if (trimmedValue) { if (trimmedValue) {
onSubmit(trimmedValue); // This will call handleFinalSubmit, which then calls setQuery('') from App.tsx onSubmit(trimmedValue); // Parent handles clearing the query
} }
resetHistoryNav(); resetHistoryNav();
}, },
[onSubmit, resetHistoryNav], [onSubmit, resetHistoryNav],
); );
useInput( const navigateUp = useCallback(() => {
(input, key) => { if (!isActive) return false;
if (!isActive) { if (userMessages.length === 0) return false;
return;
}
let didNavigate = false;
if (key.upArrow) {
if (userMessages.length === 0) return;
let nextIndex = historyIndex; let nextIndex = historyIndex;
if (historyIndex === -1) { if (historyIndex === -1) {
setOriginalQueryBeforeNav(query); // Store the current query from the parent before navigating
setOriginalQueryBeforeNav(currentQuery);
nextIndex = 0; nextIndex = 0;
} else if (historyIndex < userMessages.length - 1) { } else if (historyIndex < userMessages.length - 1) {
nextIndex = historyIndex + 1; nextIndex = historyIndex + 1;
} else { } else {
return; return false; // Already at the oldest message
} }
if (nextIndex !== historyIndex) { if (nextIndex !== historyIndex) {
setHistoryIndex(nextIndex); setHistoryIndex(nextIndex);
const newValue = userMessages[userMessages.length - 1 - nextIndex]; const newValue = userMessages[userMessages.length - 1 - nextIndex];
setQuery(newValue); setQueryAndMoveCursor(newValue); // Call the prop passed from parent
setInputKey((k) => k + 1); return true;
didNavigate = true;
} }
} else if (key.downArrow) { return false;
if (historyIndex === -1) return; }, [
historyIndex,
setHistoryIndex,
setQueryAndMoveCursor,
userMessages,
isActive,
currentQuery, // Use currentQuery from props
setOriginalQueryBeforeNav,
]);
const navigateDown = useCallback(() => {
if (!isActive) return false;
if (historyIndex === -1) return false; // Not currently navigating history
const nextIndex = historyIndex - 1; const nextIndex = historyIndex - 1;
setHistoryIndex(nextIndex); setHistoryIndex(nextIndex);
if (nextIndex === -1) { if (nextIndex === -1) {
setQuery(originalQueryBeforeNav); // Reached the end of history navigation, restore original query
setQueryAndMoveCursor(originalQueryBeforeNav);
} else { } else {
const newValue = userMessages[userMessages.length - 1 - nextIndex]; const newValue = userMessages[userMessages.length - 1 - nextIndex];
setQuery(newValue); setQueryAndMoveCursor(newValue);
} }
setInputKey((k) => k + 1); return true;
didNavigate = true; }, [
} else { historyIndex,
if (historyIndex !== -1 && !didNavigate) { setHistoryIndex,
if (input || key.backspace || key.delete) { originalQueryBeforeNav,
resetHistoryNav(); setQueryAndMoveCursor,
} userMessages,
} isActive,
} ]);
},
{ isActive },
);
return { return {
query,
setQuery,
handleSubmit, handleSubmit,
inputKey, navigateUp,
setInputKey, navigateDown,
}; };
} }

View File

@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useState } from 'react';
const TERMINAL_PADDING_X = 8;
export function useTerminalSize(): { columns: number; rows: number } {
const [size, setSize] = useState({
columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
rows: process.stdout.rows || 20,
});
useEffect(() => {
function updateSize() {
setSize({
columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
rows: process.stdout.rows || 20,
});
}
process.stdout.on('resize', updateSize);
return () => {
process.stdout.off('resize', updateSize);
};
}, []);
return size;
}