summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorEvan Senter <[email protected]>2025-04-19 14:31:59 +0100
committerGitHub <[email protected]>2025-04-19 14:31:59 +0100
commit75ecb4a81fa76aa00374601b2c0bbe9d657b4aa7 (patch)
tree5793e545a45073801a1c817530ff095769e1399c /packages/cli/src
parent2f5f6baf0f4c9c1133b0271fcb3b9e89402b97a1 (diff)
Adding in a history buffer (#38)
Up and down arrows traverse the command history.
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/App.tsx49
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx6
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts2
-rw-r--r--packages/cli/src/ui/hooks/useInputHistory.ts125
4 files changed, 164 insertions, 18 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 860663ce..5b4890e3 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -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}
/>
)}
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 3b6b10b1..b5d0b2b5 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -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'}>&gt; </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>
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index dc108701..8cbb5f51 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -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(
diff --git a/packages/cli/src/ui/hooks/useInputHistory.ts b/packages/cli/src/ui/hooks/useInputHistory.ts
new file mode 100644
index 00000000..9a6aaacb
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useInputHistory.ts
@@ -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
+ };
+}