Multiline editor (#302)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
parent
8da7a71d9a
commit
e2c3611c63
|
@ -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 && (
|
||||||
|
|
|
@ -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}>> </Text>
|
<Text color={Colors.AccentPurple}>> </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 > */
|
||||||
|
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>
|
||||||
|
|
|
@ -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
|
@ -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;
|
let nextIndex = historyIndex;
|
||||||
|
if (historyIndex === -1) {
|
||||||
|
// Store the current query from the parent before navigating
|
||||||
|
setOriginalQueryBeforeNav(currentQuery);
|
||||||
|
nextIndex = 0;
|
||||||
|
} else if (historyIndex < userMessages.length - 1) {
|
||||||
|
nextIndex = historyIndex + 1;
|
||||||
|
} else {
|
||||||
|
return false; // Already at the oldest message
|
||||||
|
}
|
||||||
|
|
||||||
if (key.upArrow) {
|
if (nextIndex !== historyIndex) {
|
||||||
if (userMessages.length === 0) return;
|
setHistoryIndex(nextIndex);
|
||||||
|
const newValue = userMessages[userMessages.length - 1 - nextIndex];
|
||||||
|
setQueryAndMoveCursor(newValue); // Call the prop passed from parent
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [
|
||||||
|
historyIndex,
|
||||||
|
setHistoryIndex,
|
||||||
|
setQueryAndMoveCursor,
|
||||||
|
userMessages,
|
||||||
|
isActive,
|
||||||
|
currentQuery, // Use currentQuery from props
|
||||||
|
setOriginalQueryBeforeNav,
|
||||||
|
]);
|
||||||
|
|
||||||
let nextIndex = historyIndex;
|
const navigateDown = useCallback(() => {
|
||||||
if (historyIndex === -1) {
|
if (!isActive) return false;
|
||||||
setOriginalQueryBeforeNav(query);
|
if (historyIndex === -1) return false; // Not currently navigating history
|
||||||
nextIndex = 0;
|
|
||||||
} else if (historyIndex < userMessages.length - 1) {
|
|
||||||
nextIndex = historyIndex + 1;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextIndex !== historyIndex) {
|
const nextIndex = historyIndex - 1;
|
||||||
setHistoryIndex(nextIndex);
|
setHistoryIndex(nextIndex);
|
||||||
const newValue = userMessages[userMessages.length - 1 - nextIndex];
|
|
||||||
setQuery(newValue);
|
|
||||||
setInputKey((k) => k + 1);
|
|
||||||
didNavigate = true;
|
|
||||||
}
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
if (historyIndex === -1) return;
|
|
||||||
|
|
||||||
const nextIndex = historyIndex - 1;
|
if (nextIndex === -1) {
|
||||||
setHistoryIndex(nextIndex);
|
// Reached the end of history navigation, restore original query
|
||||||
|
setQueryAndMoveCursor(originalQueryBeforeNav);
|
||||||
if (nextIndex === -1) {
|
} else {
|
||||||
setQuery(originalQueryBeforeNav);
|
const newValue = userMessages[userMessages.length - 1 - nextIndex];
|
||||||
} else {
|
setQueryAndMoveCursor(newValue);
|
||||||
const newValue = userMessages[userMessages.length - 1 - nextIndex];
|
}
|
||||||
setQuery(newValue);
|
return true;
|
||||||
}
|
}, [
|
||||||
setInputKey((k) => k + 1);
|
historyIndex,
|
||||||
didNavigate = true;
|
setHistoryIndex,
|
||||||
} else {
|
originalQueryBeforeNav,
|
||||||
if (historyIndex !== -1 && !didNavigate) {
|
setQueryAndMoveCursor,
|
||||||
if (input || key.backspace || key.delete) {
|
userMessages,
|
||||||
resetHistoryNav();
|
isActive,
|
||||||
}
|
]);
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive },
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
|
||||||
setQuery,
|
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
inputKey,
|
navigateUp,
|
||||||
setInputKey,
|
navigateDown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
Loading…
Reference in New Issue