diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 4bf7123e..8b219778 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -5,7 +5,7 @@ */ import React, { useState, useMemo, useCallback } from 'react'; -import { Box, Static, Text } from 'ink'; +import { Box, Static, Text, useStdout } from 'ink'; import { StreamingState, type HistoryItem } from './types.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; @@ -23,6 +23,9 @@ import { Intro } from './components/Intro.js'; import { Tips } from './components/Tips.js'; import { ConsoleOutput } from './components/ConsolePatcher.js'; import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; +import { useCompletion } from './hooks/useCompletion.js'; +import { SuggestionsDisplay } from './components/SuggestionsDisplay.js'; +import { isAtCommand } from './utils/commandUtils.js'; interface AppProps { config: Config; @@ -78,17 +81,35 @@ export const App = ({ config, cliVersion }: AppProps) => { const isInputActive = streamingState === StreamingState.Idle && !initError; - const { query, handleSubmit: handleHistorySubmit } = useInputHistory({ + const { + query, + setQuery, + handleSubmit: handleHistorySubmit, + inputKey, + setInputKey, + } = useInputHistory({ userMessages, onSubmit: handleFinalSubmit, isActive: isInputActive, }); + const completion = useCompletion( + query, + config.getTargetDir(), + isInputActive && isAtCommand(query), + ); + // --- Render Logic --- const { staticallyRenderedHistoryItems, updatableHistoryItems } = getHistoryRenderSlices(history); + // Get terminal width + const { stdout } = useStdout(); + const terminalWidth = stdout?.columns ?? 80; + // Calculate width for suggestions, leave some padding + const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); + return ( {/* @@ -174,7 +195,30 @@ export const App = ({ config, cliVersion }: AppProps) => { - + + {completion.showSuggestions && ( + + + + )} )} diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 40956647..67727fd2 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -4,28 +4,113 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { Text, Box, useInput, useFocus } from 'ink'; +import React, { useCallback } from 'react'; +import { Text, Box, useInput, useFocus, Key } from 'ink'; import TextInput from 'ink-text-input'; import { Colors } from '../colors.js'; interface InputPromptProps { + query: string; + setQuery: React.Dispatch>; + inputKey: number; + setInputKey: React.Dispatch>; onSubmit: (value: string) => void; + showSuggestions: boolean; + suggestions: string[]; + activeSuggestionIndex: number; + navigateUp: () => void; + navigateDown: () => void; + resetCompletion: () => void; } -export const InputPrompt: React.FC = ({ onSubmit }) => { - const [value, setValue] = React.useState(''); - +export const InputPrompt: React.FC = ({ + query, + setQuery, + inputKey, + setInputKey, + onSubmit, + showSuggestions, + suggestions, + activeSuggestionIndex, + navigateUp, + navigateDown, + resetCompletion, +}) => { const { isFocused } = useFocus({ autoFocus: true }); + const handleAutocomplete = useCallback(() => { + if ( + activeSuggestionIndex < 0 || + activeSuggestionIndex >= suggestions.length + ) { + return; + } + const selectedSuggestion = suggestions[activeSuggestionIndex]; + const atIndex = query.lastIndexOf('@'); + if (atIndex === -1) return; + + // Find the part of the query after the '@' + const pathPart = query.substring(atIndex + 1); + // Find the last slash within that part + const lastSlashIndexInPath = pathPart.lastIndexOf('/'); + + let base = ''; + if (lastSlashIndexInPath === -1) { + // No slash after '@', replace everything after '@' + base = query.substring(0, atIndex + 1); + } else { + // Slash found, keep everything up to and including the last slash + base = query.substring(0, atIndex + 1 + lastSlashIndexInPath + 1); + } + + const newValue = base + selectedSuggestion; + setQuery(newValue); + resetCompletion(); // Hide suggestions after selection + setInputKey((k) => k + 1); // Increment key to force re-render and cursor reset + }, [ + query, + setQuery, + suggestions, + activeSuggestionIndex, + resetCompletion, + setInputKey, + ]); + useInput( - (input, key) => { - if (key.return) { - if (value.trim()) { - onSubmit(value); - setValue(''); + (input: string, key: Key) => { + let handled = false; + + if (showSuggestions) { + if (key.upArrow) { + navigateUp(); + handled = true; + } else if (key.downArrow) { + navigateDown(); + handled = true; + } else if ((key.tab || key.return) && activeSuggestionIndex >= 0) { + handleAutocomplete(); + handled = true; + } else if (key.escape) { + resetCompletion(); + handled = true; } } + + // Only submit on Enter if it wasn't handled above + if (!handled && key.return) { + if (query.trim()) { + onSubmit(query); + } + handled = true; + } + + if ( + handled && + showSuggestions && + (key.upArrow || key.downArrow || key.tab || key.escape || key.return) + ) { + // No explicit preventDefault needed, handled flag stops further processing + } }, { isActive: isFocused }, ); @@ -35,11 +120,12 @@ export const InputPrompt: React.FC = ({ onSubmit }) => { > { - /* Empty to prevent double submission */ + /* onSubmit is handled by useInput hook above */ }} /> diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index a075157d..4b583d6c 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -12,7 +12,6 @@ import { ToolCallStatus, } from '../types.js'; -// Helper function to add history items const addHistoryItem = ( setHistory: React.Dispatch>, itemData: Omit, @@ -25,7 +24,7 @@ const addHistoryItem = ( }; interface HandleAtCommandParams { - query: string; // Raw user input, potentially containing '@' + query: string; config: Config; setHistory: React.Dispatch>; setDebugMessage: React.Dispatch>; @@ -34,8 +33,8 @@ interface HandleAtCommandParams { } interface HandleAtCommandResult { - processedQuery: PartListUnion | null; // Query for Gemini (null on error/no-proceed) - shouldProceed: boolean; // Whether the main hook should continue processing + processedQuery: PartListUnion | null; + shouldProceed: boolean; } /** @@ -57,9 +56,7 @@ export async function handleAtCommand({ }: HandleAtCommandParams): Promise { const trimmedQuery = query.trim(); - // Regex to find the first occurrence of @ followed by non-whitespace chars - // It captures the text before, the @path itself (including @), and the text after. - const atCommandRegex = /^(.*?)(@\S+)(.*)$/s; // s flag for dot to match newline + const atCommandRegex = /^(.*?)(@\S+)(.*)$/s; const match = trimmedQuery.match(atCommandRegex); if (!match) { @@ -75,20 +72,18 @@ export async function handleAtCommand({ } const textBefore = match[1].trim(); - const atPath = match[2]; // Includes the '@' + const atPath = match[2]; const textAfter = match[3].trim(); - const pathPart = atPath.substring(1); // Remove the leading '@' + const pathPart = atPath.substring(1); - // Add user message for the full original @ command addHistoryItem( setHistory, - { type: 'user', text: query }, // Use original full query for history + { type: 'user', text: query }, userMessageTimestamp, ); if (!pathPart) { - // Handle case where it's just "@" or "@ " - treat as error/don't proceed const errorTimestamp = getNextMessageId(userMessageTimestamp); addHistoryItem( setHistory, @@ -108,18 +103,18 @@ export async function handleAtCommand({ { type: 'error', text: 'Error: read_many_files tool not found.' }, errorTimestamp, ); - return { processedQuery: null, shouldProceed: false }; // Don't proceed if tool is missing + return { processedQuery: null, shouldProceed: false }; } // --- Path Handling for @ command --- - let pathSpec = pathPart; // Use the extracted path part + let pathSpec = pathPart; // Basic check: If no extension or ends with '/', assume directory and add globstar. if (!pathPart.includes('.') || pathPart.endsWith('/')) { pathSpec = pathPart.endsWith('/') ? `${pathPart}**` : `${pathPart}/**`; } const toolArgs = { paths: [pathSpec] }; const contentLabel = - pathSpec === pathPart ? pathPart : `directory ${pathPart}`; // Adjust label + pathSpec === pathPart ? pathPart : `directory ${pathPart}`; // --- End Path Handling --- let toolCallDisplay: IndividualToolCallDisplay; @@ -129,7 +124,6 @@ export async function handleAtCommand({ const result = await readManyFilesTool.execute(toolArgs); const fileContent = result.llmContent || ''; - // Construct success UI toolCallDisplay = { callId: `client-read-${userMessageTimestamp}`, name: readManyFilesTool.displayName, @@ -153,7 +147,6 @@ export async function handleAtCommand({ const processedQuery: PartListUnion = processedQueryParts; - // Add the tool group UI const toolGroupId = getNextMessageId(userMessageTimestamp); addHistoryItem( setHistory, @@ -164,7 +157,7 @@ export async function handleAtCommand({ toolGroupId, ); - return { processedQuery, shouldProceed: true }; // Proceed to Gemini + return { processedQuery, shouldProceed: true }; } catch (error) { // Construct error UI toolCallDisplay = { @@ -176,7 +169,6 @@ export async function handleAtCommand({ confirmationDetails: undefined, }; - // Add the tool group UI and signal not to proceed const toolGroupId = getNextMessageId(userMessageTimestamp); addHistoryItem( setHistory, @@ -187,6 +179,6 @@ export async function handleAtCommand({ toolGroupId, ); - return { processedQuery: null, shouldProceed: false }; // Don't proceed on error + return { processedQuery: null, shouldProceed: false }; } } diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 92841028..36aed0d1 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -8,8 +8,7 @@ import { useState, useEffect, useCallback } from 'react'; import * as fs from 'fs/promises'; import * as path from 'path'; import { isNodeError } from '@gemini-code/server'; - -const MAX_SUGGESTIONS_TO_SHOW = 8; +import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js'; export interface UseCompletionReturn { suggestions: string[]; @@ -45,51 +44,64 @@ export function useCompletion( setIsLoadingSuggestions(false); }, []); - // --- Navigation Logic --- const navigateUp = useCallback(() => { if (suggestions.length === 0) return; - setActiveSuggestionIndex((prevIndex) => { - const newIndex = prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 1; + setActiveSuggestionIndex((prevActiveIndex) => { + // Calculate new active index, handling wrap-around + const newActiveIndex = + prevActiveIndex <= 0 ? suggestions.length - 1 : prevActiveIndex - 1; - // Adjust visible window if needed (scrolling up) - if (newIndex < visibleStartIndex) { - setVisibleStartIndex(newIndex); - } else if ( - newIndex === suggestions.length - 1 && - suggestions.length > MAX_SUGGESTIONS_TO_SHOW - ) { - // Handle wrapping from first to last item - setVisibleStartIndex( - Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW), - ); - } + // Adjust scroll position based on the new active index + setVisibleStartIndex((prevVisibleStart) => { + // Case 1: Wrapped around to the last item + if ( + newActiveIndex === suggestions.length - 1 && + suggestions.length > MAX_SUGGESTIONS_TO_SHOW + ) { + return Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW); + } + // Case 2: Scrolled above the current visible window + if (newActiveIndex < prevVisibleStart) { + return newActiveIndex; + } + // Otherwise, keep the current scroll position + return prevVisibleStart; + }); - return newIndex; + return newActiveIndex; }); - }, [suggestions.length, visibleStartIndex]); + }, [suggestions.length]); const navigateDown = useCallback(() => { if (suggestions.length === 0) return; - setActiveSuggestionIndex((prevIndex) => { - const newIndex = prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1; + setActiveSuggestionIndex((prevActiveIndex) => { + // Calculate new active index, handling wrap-around + const newActiveIndex = + prevActiveIndex >= suggestions.length - 1 ? 0 : prevActiveIndex + 1; - // Adjust visible window if needed (scrolling down) - if (newIndex >= visibleStartIndex + MAX_SUGGESTIONS_TO_SHOW) { - setVisibleStartIndex(visibleStartIndex + 1); - } else if ( - newIndex === 0 && - suggestions.length > MAX_SUGGESTIONS_TO_SHOW - ) { - // Handle wrapping from last to first item - setVisibleStartIndex(0); - } + // Adjust scroll position based on the new active index + setVisibleStartIndex((prevVisibleStart) => { + // Case 1: Wrapped around to the first item + if ( + newActiveIndex === 0 && + suggestions.length > MAX_SUGGESTIONS_TO_SHOW + ) { + return 0; + } + // Case 2: Scrolled below the current visible window + const visibleEndIndex = prevVisibleStart + MAX_SUGGESTIONS_TO_SHOW; + if (newActiveIndex >= visibleEndIndex) { + return newActiveIndex - MAX_SUGGESTIONS_TO_SHOW + 1; + } + // Otherwise, keep the current scroll position + return prevVisibleStart; + }); - return newIndex; + return newActiveIndex; }); - }, [suggestions.length, visibleStartIndex]); - // --- End Navigation Logic --- + }, [suggestions.length]); useEffect(() => { if (!isActive) { @@ -137,8 +149,8 @@ export function useCompletion( if (isMounted) { setSuggestions(filteredSuggestions); setShowSuggestions(filteredSuggestions.length > 0); - setActiveSuggestionIndex(-1); // Reset selection on new suggestions - setVisibleStartIndex(0); // Reset scroll on new suggestions + setActiveSuggestionIndex(-1); + setVisibleStartIndex(0); } } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { @@ -162,13 +174,11 @@ export function useCompletion( } }; - // Debounce the fetch slightly const debounceTimeout = setTimeout(fetchSuggestions, 100); return () => { isMounted = false; clearTimeout(debounceTimeout); - // Don't reset loading state here, let the next effect handle it or resetCompletionState }; }, [query, cwd, isActive, resetCompletionState]); diff --git a/packages/cli/src/ui/hooks/useInputHistory.ts b/packages/cli/src/ui/hooks/useInputHistory.ts index 9a6aaacb..21d7b9bf 100644 --- a/packages/cli/src/ui/hooks/useInputHistory.ts +++ b/packages/cli/src/ui/hooks/useInputHistory.ts @@ -7,19 +7,18 @@ 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 + userMessages: readonly string[]; + onSubmit: (value: string) => void; + isActive: boolean; } -// Return type of the hook interface UseInputHistoryReturn { - query: string; // The current input query managed by the hook - setQuery: React.Dispatch>; // Setter for the query - handleSubmit: (value: string) => void; // Wrapped submit handler - inputKey: number; // Key to force input reset + query: string; + setQuery: React.Dispatch>; + handleSubmit: (value: string) => void; + inputKey: number; + setInputKey: React.Dispatch>; } export function useInputHistory({ @@ -27,36 +26,31 @@ export function useInputHistory({ onSubmit, isActive, }: UseInputHistoryProps): UseInputHistoryReturn { - const [query, setQuery] = useState(''); // Hook manages its own query state - const [historyIndex, setHistoryIndex] = useState(-1); // -1 means current query + const [query, setQuery] = useState(''); + const [historyIndex, setHistoryIndex] = useState(-1); const [originalQueryBeforeNav, setOriginalQueryBeforeNav] = useState(''); - const [inputKey, setInputKey] = useState(0); // Key for forcing input reset + const [inputKey, setInputKey] = useState(0); - // 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 + onSubmit(trimmedValue); } - setQuery(''); // Clear the input field managed by this hook - resetHistoryNav(); // Reset history state - // Don't increment inputKey here, only on nav changes + setQuery(''); + resetHistoryNav(); }, [onSubmit, resetHistoryNav], ); useInput( (input, key) => { - // Do nothing if the hook is not active if (!isActive) { return; } @@ -68,58 +62,51 @@ export function useInputHistory({ 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) + nextIndex = 0; } else if (historyIndex < userMessages.length - 1) { - // Continue navigating UP (towards older items) nextIndex = historyIndex + 1; } else { - return; // Already at the oldest item + return; } 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 + setInputKey((k) => k + 1); didNavigate = true; } } else if (key.downArrow) { - if (historyIndex === -1) return; // Already at the bottom (current input) + if (historyIndex === -1) return; - const nextIndex = historyIndex - 1; // Move towards more recent items / current input + const nextIndex = historyIndex - 1; 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 + setInputKey((k) => k + 1); 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 + { isActive }, ); return { query, - setQuery, // Return the hook's setQuery - handleSubmit, // Return the wrapped submit handler - inputKey, // Return the key + setQuery, + handleSubmit, + inputKey, + setInputKey, }; }