diff options
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 49 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/InputPrompt.tsx | 6 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useGeminiStream.ts | 2 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useInputHistory.ts | 125 |
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'}>> </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 + }; +} |
