diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx new file mode 100644 index 00000000..a9d24003 --- /dev/null +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text } from 'ink'; + +interface SuggestionsDisplayProps { + suggestions: string[]; + activeIndex: number; + isLoading: boolean; + width: number; + scrollOffset: number; +} + +export const MAX_SUGGESTIONS_TO_SHOW = 8; + +export function SuggestionsDisplay({ + suggestions, + activeIndex, + isLoading, + width, + scrollOffset, +}: SuggestionsDisplayProps) { + if (isLoading) { + return ( + + Loading suggestions... + + ); + } + + if (suggestions.length === 0) { + return null; // Don't render anything if there are no suggestions + } + + // Calculate the visible slice based on scrollOffset + const startIndex = scrollOffset; + const endIndex = Math.min( + scrollOffset + MAX_SUGGESTIONS_TO_SHOW, + suggestions.length, + ); + const visibleSuggestions = suggestions.slice(startIndex, endIndex); + + return ( + + {scrollOffset > 0 && } + + {visibleSuggestions.map((suggestion, index) => { + const originalIndex = startIndex + index; + const isActive = originalIndex === activeIndex; + return ( + + {suggestion} + + ); + })} + {endIndex < suggestions.length && } + {suggestions.length > MAX_SUGGESTIONS_TO_SHOW && ( + + ({activeIndex + 1}/{suggestions.length}) + + )} + + ); +} diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts new file mode 100644 index 00000000..314c969d --- /dev/null +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PartListUnion } from '@google/genai'; +import { Config, getErrorMessage } from '@gemini-code/server'; +import { + HistoryItem, + IndividualToolCallDisplay, + ToolCallStatus, +} from '../types.js'; + +// Helper function to add history items (could be moved to a shared util if needed elsewhere) +const addHistoryItem = ( + setHistory: React.Dispatch>, + itemData: Omit, + id: number, +) => { + setHistory((prevHistory) => [ + ...prevHistory, + { ...itemData, id } as HistoryItem, + ]); +}; + +interface HandleAtCommandParams { + query: string; // Raw user input + config: Config; + setHistory: React.Dispatch>; + setDebugMessage: React.Dispatch>; + getNextMessageId: (baseTimestamp: number) => number; + userMessageTimestamp: number; +} + +interface HandleAtCommandResult { + processedQuery: PartListUnion; // Query to potentially send to Gemini + shouldProceed: boolean; // Whether the main hook should continue processing +} + +/** + * Processes user input that might start with the '@' command to read files/directories. + * If it's an '@' command, it attempts to read the specified path, updates the UI + * with the tool call status, and prepares the query to be sent to the LLM. + * + * @returns An object containing the potentially modified query and a flag + * indicating if the main hook should proceed with the Gemini API call. + */ +export async function handleAtCommand({ + query, + config, + setHistory, + setDebugMessage, + getNextMessageId, + userMessageTimestamp, +}: HandleAtCommandParams): Promise { + const trimmedQuery = query.trim(); + + if (!trimmedQuery.startsWith('@')) { + // Not an '@' command, proceed as normal + // Add the user message here before returning + addHistoryItem( + setHistory, + { type: 'user', text: query }, + userMessageTimestamp, + ); + // Use property shorthand for processedQuery + return { processedQuery: query, shouldProceed: true }; + } + + // --- It is an '@' command --- + const filePath = trimmedQuery.substring(1); + + if (!filePath) { + // Handle case where it's just "@" - treat as normal input + addHistoryItem( + setHistory, + { type: 'user', text: query }, + userMessageTimestamp, + ); + // Use property shorthand for processedQuery + return { processedQuery: query, shouldProceed: true }; // Send the "@" to the model + } + + const toolRegistry = config.getToolRegistry(); + const readManyFilesTool = toolRegistry.getTool('read_many_files'); + + // Add user message first, so it appears before potential errors/tool UI + addHistoryItem( + setHistory, + { type: 'user', text: query }, + userMessageTimestamp, + ); + + if (!readManyFilesTool) { + const errorTimestamp = getNextMessageId(userMessageTimestamp); + addHistoryItem( + setHistory, + { type: 'error', text: 'Error: read_many_files tool not found.' }, + errorTimestamp, + ); + // Use property shorthand for processedQuery + return { processedQuery: query, shouldProceed: false }; // Don't proceed if tool is missing + } + + // --- Path Handling for @ command --- + let pathSpec = filePath; + // Basic check: If no extension or ends with '/', assume directory and add globstar. + if (!filePath.includes('.') || filePath.endsWith('/')) { + pathSpec = filePath.endsWith('/') ? `${filePath}**` : `${filePath}/**`; + } + const toolArgs = { paths: [pathSpec] }; + const contentLabel = + pathSpec === filePath ? filePath : `directory ${filePath}`; // Adjust label + // --- End Path Handling --- + + let toolCallDisplay: IndividualToolCallDisplay; + let processedQuery: PartListUnion = query; // Default to original query + + try { + setDebugMessage(`Reading via @ command: ${pathSpec}`); + const result = await readManyFilesTool.execute(toolArgs); + const fileContent = result.llmContent || ''; + + // Construct success UI + toolCallDisplay = { + callId: `client-read-${userMessageTimestamp}`, + name: readManyFilesTool.displayName, + description: readManyFilesTool.getDescription(toolArgs), + status: ToolCallStatus.Success, + resultDisplay: result.returnDisplay, + confirmationDetails: undefined, + }; + + // Prepend file content to the query sent to the model + processedQuery = [ + { + text: `--- Content from: ${contentLabel} --- +${fileContent} +--- End Content ---`, + }, + // TODO: Handle cases like "@README.md explain this" by appending the rest of the query + ]; + + // Add the tool group UI + const toolGroupId = getNextMessageId(userMessageTimestamp); + addHistoryItem( + setHistory, + { type: 'tool_group', tools: [toolCallDisplay] } as Omit< + HistoryItem, + 'id' + >, + toolGroupId, + ); + + // Use property shorthand for processedQuery + return { processedQuery, shouldProceed: true }; // Proceed to Gemini + } catch (error) { + // Construct error UI + toolCallDisplay = { + callId: `client-read-${userMessageTimestamp}`, + name: readManyFilesTool.displayName, + description: readManyFilesTool.getDescription(toolArgs), + status: ToolCallStatus.Error, + resultDisplay: `Error reading ${contentLabel}: ${getErrorMessage(error)}`, + confirmationDetails: undefined, + }; + + // Add the tool group UI and signal not to proceed + const toolGroupId = getNextMessageId(userMessageTimestamp); + addHistoryItem( + setHistory, + { type: 'tool_group', tools: [toolCallDisplay] } as Omit< + HistoryItem, + 'id' + >, + toolGroupId, + ); + + // Use property shorthand for processedQuery + return { processedQuery: query, shouldProceed: false }; // Don't proceed on error + } +} diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts new file mode 100644 index 00000000..92841028 --- /dev/null +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +export interface UseCompletionReturn { + suggestions: string[]; + activeSuggestionIndex: number; + visibleStartIndex: number; + showSuggestions: boolean; + isLoadingSuggestions: boolean; + setActiveSuggestionIndex: React.Dispatch>; + setShowSuggestions: React.Dispatch>; + resetCompletionState: () => void; + navigateUp: () => void; + navigateDown: () => void; +} + +export function useCompletion( + query: string, + cwd: string, + isActive: boolean, +): UseCompletionReturn { + const [suggestions, setSuggestions] = useState([]); + const [activeSuggestionIndex, setActiveSuggestionIndex] = + useState(-1); + const [visibleStartIndex, setVisibleStartIndex] = useState(0); + const [showSuggestions, setShowSuggestions] = useState(false); + const [isLoadingSuggestions, setIsLoadingSuggestions] = + useState(false); + + const resetCompletionState = useCallback(() => { + setSuggestions([]); + setActiveSuggestionIndex(-1); + setVisibleStartIndex(0); + setShowSuggestions(false); + setIsLoadingSuggestions(false); + }, []); + + // --- Navigation Logic --- + const navigateUp = useCallback(() => { + if (suggestions.length === 0) return; + + setActiveSuggestionIndex((prevIndex) => { + const newIndex = prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 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), + ); + } + + return newIndex; + }); + }, [suggestions.length, visibleStartIndex]); + + const navigateDown = useCallback(() => { + if (suggestions.length === 0) return; + + setActiveSuggestionIndex((prevIndex) => { + const newIndex = prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 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); + } + + return newIndex; + }); + }, [suggestions.length, visibleStartIndex]); + // --- End Navigation Logic --- + + useEffect(() => { + if (!isActive) { + resetCompletionState(); + return; + } + + const atIndex = query.lastIndexOf('@'); + if (atIndex === -1) { + resetCompletionState(); + return; + } + + const partialPath = query.substring(atIndex + 1); + const lastSlashIndex = partialPath.lastIndexOf('/'); + const baseDirRelative = + lastSlashIndex === -1 + ? '.' + : partialPath.substring(0, lastSlashIndex + 1); + const prefix = + lastSlashIndex === -1 + ? partialPath + : partialPath.substring(lastSlashIndex + 1); + const baseDirAbsolute = path.resolve(cwd, baseDirRelative); + + let isMounted = true; + const fetchSuggestions = async () => { + setIsLoadingSuggestions(true); + try { + const entries = await fs.readdir(baseDirAbsolute, { + withFileTypes: true, + }); + const filteredSuggestions = entries + .filter((entry) => entry.name.startsWith(prefix)) + .map((entry) => (entry.isDirectory() ? entry.name + '/' : entry.name)) + .sort((a, b) => { + // Sort directories first, then alphabetically + const aIsDir = a.endsWith('/'); + const bIsDir = b.endsWith('/'); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + return a.localeCompare(b); + }); + + if (isMounted) { + setSuggestions(filteredSuggestions); + setShowSuggestions(filteredSuggestions.length > 0); + setActiveSuggestionIndex(-1); // Reset selection on new suggestions + setVisibleStartIndex(0); // Reset scroll on new suggestions + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + // Directory doesn't exist, likely mid-typing, clear suggestions + if (isMounted) { + setSuggestions([]); + setShowSuggestions(false); + } + } else { + console.error( + `Error fetching completion suggestions for ${baseDirAbsolute}:`, + error, + ); + if (isMounted) { + resetCompletionState(); + } + } + } + if (isMounted) { + setIsLoadingSuggestions(false); + } + }; + + // 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]); + + return { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + setActiveSuggestionIndex, + setShowSuggestions, + resetCompletionState, + navigateUp, + navigateDown, + }; +} diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts new file mode 100644 index 00000000..8c7934dc --- /dev/null +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Checks if a query string potentially represents an '@' command. + * It triggers if the query starts with '@' or contains '@' preceded by whitespace + * and followed by a non-whitespace character. + * + * @param query The input query string. + * @returns True if the query looks like an '@' command, false otherwise. + */ +export const isPotentiallyAtCommand = (query: string): boolean => + // Check if starts with @ OR has a space, then @, then a non-space character. + query.startsWith('@') || /\s@\S/.test(query);