Adding in a history buffer (#38)
Up and down arrows traverse the command history.
This commit is contained in:
parent
2f5f6baf0f
commit
75ecb4a81f
|
@ -4,11 +4,12 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { HistoryItem } from './types.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useInputHistory } from './hooks/useInputHistory.js';
|
||||
import { Header } from './components/Header.js';
|
||||
import { Tips } from './components/Tips.js';
|
||||
import { HistoryDisplay } from './components/HistoryDisplay.js';
|
||||
|
@ -16,7 +17,6 @@ import { LoadingIndicator } from './components/LoadingIndicator.js';
|
|||
import { InputPrompt } from './components/InputPrompt.js';
|
||||
import { Footer } from './components/Footer.js';
|
||||
import { StreamingState } from '../core/gemini-stream.js';
|
||||
import { PartListUnion } from '@google/genai';
|
||||
import { ITermDetectionWarning } from './utils/itermDetection.js';
|
||||
import {
|
||||
useStartupWarnings,
|
||||
|
@ -28,7 +28,6 @@ interface AppProps {
|
|||
}
|
||||
|
||||
export const App = ({ directory }: AppProps) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||
const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
|
||||
const { streamingState, submitQuery, initError } =
|
||||
|
@ -39,22 +38,39 @@ export const App = ({ directory }: AppProps) => {
|
|||
useStartupWarnings(setStartupWarnings);
|
||||
useInitializationErrorEffect(initError, history, setHistory);
|
||||
|
||||
const handleInputSubmit = (value: PartListUnion) => {
|
||||
submitQuery(value)
|
||||
.then(() => {
|
||||
setQuery('');
|
||||
})
|
||||
.catch(() => {
|
||||
setQuery('');
|
||||
});
|
||||
};
|
||||
const userMessages = useMemo(
|
||||
() =>
|
||||
history
|
||||
.filter(
|
||||
(item): item is HistoryItem & { type: 'user'; text: string } =>
|
||||
item.type === 'user' &&
|
||||
typeof item.text === 'string' &&
|
||||
item.text.trim() !== '',
|
||||
)
|
||||
.map((item) => item.text),
|
||||
[history],
|
||||
);
|
||||
|
||||
const isWaitingForToolConfirmation = history.some(
|
||||
(item) =>
|
||||
item.type === 'tool_group' &&
|
||||
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 (
|
||||
<Box flexDirection="column" padding={1} marginBottom={1} width="100%">
|
||||
|
@ -111,7 +127,7 @@ export const App = ({ directory }: AppProps) => {
|
|||
)}
|
||||
|
||||
<Box flexDirection="column">
|
||||
<HistoryDisplay history={history} onSubmit={handleInputSubmit} />
|
||||
<HistoryDisplay history={history} onSubmit={submitQuery} />
|
||||
<LoadingIndicator
|
||||
isLoading={streamingState === StreamingState.Responding}
|
||||
currentLoadingPhrase={currentLoadingPhrase}
|
||||
|
@ -119,12 +135,13 @@ export const App = ({ directory }: AppProps) => {
|
|||
/>
|
||||
</Box>
|
||||
|
||||
{!isWaitingForToolConfirmation && isInputActive && (
|
||||
{isInputActive && (
|
||||
<InputPrompt
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
onSubmit={handleInputSubmit}
|
||||
onSubmit={handleHistorySubmit}
|
||||
isActive={isInputActive}
|
||||
forceKey={inputKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -14,12 +14,15 @@ interface InputPromptProps {
|
|||
setQuery: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
isActive: boolean;
|
||||
forceKey?: number;
|
||||
}
|
||||
|
||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
query,
|
||||
setQuery,
|
||||
onSubmit,
|
||||
isActive,
|
||||
forceKey,
|
||||
}) => {
|
||||
const model = globalConfig.getModel();
|
||||
|
||||
|
@ -28,11 +31,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
<Text color={'white'}>> </Text>
|
||||
<Box flexGrow={1}>
|
||||
<TextInput
|
||||
key={forceKey?.toString()}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onSubmit={onSubmit}
|
||||
showCursor={true}
|
||||
focus={true}
|
||||
focus={isActive}
|
||||
placeholder={`Ask Gemini (${model})... (try "/init" or "/help")`}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
@ -112,7 +112,7 @@ export const useGeminiStream = (
|
|||
|
||||
const maybeCommand = trimmedQuery.split(/\s+/)[0];
|
||||
if (allowlistedCommands.includes(maybeCommand)) {
|
||||
exec(trimmedQuery, (error, stdout, stderr) => {
|
||||
exec(trimmedQuery, (error, stdout) => {
|
||||
const timestamp = getNextMessageId(userMessageTimestamp);
|
||||
// TODO: handle stderr, error
|
||||
addHistoryItem(
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue