Adding in a history buffer (#38)

Up and down arrows traverse the command history.
This commit is contained in:
Evan Senter
2025-04-19 14:31:59 +01:00
committed by GitHub
parent 2f5f6baf0f
commit 75ecb4a81f
4 changed files with 164 additions and 18 deletions

View File

@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import type { HistoryItem } from './types.js'; import type { HistoryItem } from './types.js';
import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistory } from './hooks/useInputHistory.js';
import { Header } from './components/Header.js'; import { Header } from './components/Header.js';
import { Tips } from './components/Tips.js'; import { Tips } from './components/Tips.js';
import { HistoryDisplay } from './components/HistoryDisplay.js'; import { HistoryDisplay } from './components/HistoryDisplay.js';
@@ -16,7 +17,6 @@ import { LoadingIndicator } from './components/LoadingIndicator.js';
import { InputPrompt } from './components/InputPrompt.js'; import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js'; import { Footer } from './components/Footer.js';
import { StreamingState } from '../core/gemini-stream.js'; import { StreamingState } from '../core/gemini-stream.js';
import { PartListUnion } from '@google/genai';
import { ITermDetectionWarning } from './utils/itermDetection.js'; import { ITermDetectionWarning } from './utils/itermDetection.js';
import { import {
useStartupWarnings, useStartupWarnings,
@@ -28,7 +28,6 @@ interface AppProps {
} }
export const App = ({ directory }: AppProps) => { export const App = ({ directory }: AppProps) => {
const [query, setQuery] = useState('');
const [history, setHistory] = useState<HistoryItem[]>([]); const [history, setHistory] = useState<HistoryItem[]>([]);
const [startupWarnings, setStartupWarnings] = useState<string[]>([]); const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
const { streamingState, submitQuery, initError } = const { streamingState, submitQuery, initError } =
@@ -39,22 +38,39 @@ export const App = ({ directory }: AppProps) => {
useStartupWarnings(setStartupWarnings); useStartupWarnings(setStartupWarnings);
useInitializationErrorEffect(initError, history, setHistory); useInitializationErrorEffect(initError, history, setHistory);
const handleInputSubmit = (value: PartListUnion) => { const userMessages = useMemo(
submitQuery(value) () =>
.then(() => { history
setQuery(''); .filter(
}) (item): item is HistoryItem & { type: 'user'; text: string } =>
.catch(() => { item.type === 'user' &&
setQuery(''); typeof item.text === 'string' &&
}); item.text.trim() !== '',
}; )
.map((item) => item.text),
[history],
);
const isWaitingForToolConfirmation = history.some( const isWaitingForToolConfirmation = history.some(
(item) => (item) =>
item.type === 'tool_group' && item.type === 'tool_group' &&
item.tools.some((tool) => tool.confirmationDetails !== undefined), item.tools.some((tool) => tool.confirmationDetails !== undefined),
); );
const isInputActive = streamingState === StreamingState.Idle && !initError; const isInputActive =
streamingState === StreamingState.Idle &&
!initError &&
!isWaitingForToolConfirmation;
const {
query,
setQuery,
handleSubmit: handleHistorySubmit,
inputKey,
} = useInputHistory({
userMessages,
onSubmit: submitQuery,
isActive: isInputActive,
});
return ( return (
<Box flexDirection="column" padding={1} marginBottom={1} width="100%"> <Box flexDirection="column" padding={1} marginBottom={1} width="100%">
@@ -111,7 +127,7 @@ export const App = ({ directory }: AppProps) => {
)} )}
<Box flexDirection="column"> <Box flexDirection="column">
<HistoryDisplay history={history} onSubmit={handleInputSubmit} /> <HistoryDisplay history={history} onSubmit={submitQuery} />
<LoadingIndicator <LoadingIndicator
isLoading={streamingState === StreamingState.Responding} isLoading={streamingState === StreamingState.Responding}
currentLoadingPhrase={currentLoadingPhrase} currentLoadingPhrase={currentLoadingPhrase}
@@ -119,12 +135,13 @@ export const App = ({ directory }: AppProps) => {
/> />
</Box> </Box>
{!isWaitingForToolConfirmation && isInputActive && ( {isInputActive && (
<InputPrompt <InputPrompt
query={query} query={query}
setQuery={setQuery} setQuery={setQuery}
onSubmit={handleInputSubmit} onSubmit={handleHistorySubmit}
isActive={isInputActive} isActive={isInputActive}
forceKey={inputKey}
/> />
)} )}

View File

@@ -14,12 +14,15 @@ interface InputPromptProps {
setQuery: (value: string) => void; setQuery: (value: string) => void;
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
isActive: boolean; isActive: boolean;
forceKey?: number;
} }
export const InputPrompt: React.FC<InputPromptProps> = ({ export const InputPrompt: React.FC<InputPromptProps> = ({
query, query,
setQuery, setQuery,
onSubmit, onSubmit,
isActive,
forceKey,
}) => { }) => {
const model = globalConfig.getModel(); const model = globalConfig.getModel();
@@ -28,11 +31,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text color={'white'}>&gt; </Text> <Text color={'white'}>&gt; </Text>
<Box flexGrow={1}> <Box flexGrow={1}>
<TextInput <TextInput
key={forceKey?.toString()}
value={query} value={query}
onChange={setQuery} onChange={setQuery}
onSubmit={onSubmit} onSubmit={onSubmit}
showCursor={true} showCursor={true}
focus={true} focus={isActive}
placeholder={`Ask Gemini (${model})... (try "/init" or "/help")`} placeholder={`Ask Gemini (${model})... (try "/init" or "/help")`}
/> />
</Box> </Box>

View File

@@ -112,7 +112,7 @@ export const useGeminiStream = (
const maybeCommand = trimmedQuery.split(/\s+/)[0]; const maybeCommand = trimmedQuery.split(/\s+/)[0];
if (allowlistedCommands.includes(maybeCommand)) { if (allowlistedCommands.includes(maybeCommand)) {
exec(trimmedQuery, (error, stdout, stderr) => { exec(trimmedQuery, (error, stdout) => {
const timestamp = getNextMessageId(userMessageTimestamp); const timestamp = getNextMessageId(userMessageTimestamp);
// TODO: handle stderr, error // TODO: handle stderr, error
addHistoryItem( addHistoryItem(

View File

@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { useInput } from 'ink';
// Props for the hook
interface UseInputHistoryProps {
userMessages: readonly string[]; // History of user messages
onSubmit: (value: string) => void; // Original submit function from App
isActive: boolean; // To enable/disable the useInput hook
}
// Return type of the hook
interface UseInputHistoryReturn {
query: string; // The current input query managed by the hook
setQuery: React.Dispatch<React.SetStateAction<string>>; // Setter for the query
handleSubmit: (value: string) => void; // Wrapped submit handler
inputKey: number; // Key to force input reset
}
export function useInputHistory({
userMessages,
onSubmit,
isActive,
}: UseInputHistoryProps): UseInputHistoryReturn {
const [query, setQuery] = useState(''); // Hook manages its own query state
const [historyIndex, setHistoryIndex] = useState<number>(-1); // -1 means current query
const [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
useState<string>('');
const [inputKey, setInputKey] = useState<number>(0); // Key for forcing input reset
// Function to reset navigation state, called on submit or manual reset
const resetHistoryNav = useCallback(() => {
setHistoryIndex(-1);
setOriginalQueryBeforeNav('');
}, []);
// Wrapper for the onSubmit prop to include resetting history navigation
const handleSubmit = useCallback(
(value: string) => {
const trimmedValue = value.trim();
if (trimmedValue) {
// Only submit non-empty values
onSubmit(trimmedValue); // Call the original submit function
}
setQuery(''); // Clear the input field managed by this hook
resetHistoryNav(); // Reset history state
// Don't increment inputKey here, only on nav changes
},
[onSubmit, resetHistoryNav],
);
useInput(
(input, key) => {
// Do nothing if the hook is not active
if (!isActive) {
return;
}
let didNavigate = false;
if (key.upArrow) {
if (userMessages.length === 0) return;
let nextIndex = historyIndex;
if (historyIndex === -1) {
// Starting navigation UP, save current input
setOriginalQueryBeforeNav(query);
nextIndex = 0; // Go to the most recent item (index 0 in reversed view)
} else if (historyIndex < userMessages.length - 1) {
// Continue navigating UP (towards older items)
nextIndex = historyIndex + 1;
} else {
return; // Already at the oldest item
}
if (nextIndex !== historyIndex) {
setHistoryIndex(nextIndex);
// History is ordered newest to oldest, so access from the end
const newValue = userMessages[userMessages.length - 1 - nextIndex];
setQuery(newValue);
setInputKey((k) => k + 1); // Increment key on navigation change
didNavigate = true;
}
} else if (key.downArrow) {
if (historyIndex === -1) return; // Already at the bottom (current input)
const nextIndex = historyIndex - 1; // Move towards more recent items / current input
setHistoryIndex(nextIndex);
if (nextIndex === -1) {
// Restore original query
setQuery(originalQueryBeforeNav);
} else {
// Set query based on reversed index
const newValue = userMessages[userMessages.length - 1 - nextIndex];
setQuery(newValue);
}
setInputKey((k) => k + 1); // Increment key on navigation change
didNavigate = true;
} else {
// If user types anything other than arrows while navigating, reset history navigation state
if (historyIndex !== -1 && !didNavigate) {
// Check if it's a key that modifies input content
if (input || key.backspace || key.delete) {
resetHistoryNav();
// The actual query state update for typing is handled by the component's onChange calling setQuery
}
}
}
},
{ isActive }, // Pass isActive to useInput
);
return {
query,
setQuery, // Return the hook's setQuery
handleSubmit, // Return the wrapped submit handler
inputKey, // Return the key
};
}