From add233c5043264d47ecc6d3339a383f41a241ae8 Mon Sep 17 00:00:00 2001 From: Taylor Mullen Date: Tue, 15 Apr 2025 21:41:08 -0700 Subject: Initial commit of Gemini Code CLI This commit introduces the initial codebase for the Gemini Code CLI, a command-line interface designed to facilitate interaction with the Gemini API for software engineering tasks. The code was migrated from a previous git repository as a single squashed commit. Core Features & Components: * **Gemini Integration:** Leverages the `@google/genai` SDK to interact with the Gemini models, supporting chat history, streaming responses, and function calling (tools). * **Terminal UI:** Built with Ink (React for CLIs) providing an interactive chat interface within the terminal, including input prompts, message display, loading indicators, and tool interaction elements. * **Tooling Framework:** Implements a robust tool system allowing Gemini to interact with the local environment. Includes tools for: * File system listing (`ls`) * File reading (`read-file`) * Content searching (`grep`) * File globbing (`glob`) * File editing (`edit`) * File writing (`write-file`) * Executing bash commands (`terminal`) * **State Management:** Handles the streaming state of Gemini responses and manages the conversation history. * **Configuration:** Parses command-line arguments (`yargs`) and loads environment variables (`dotenv`) for setup. * **Project Structure:** Organized into `core`, `ui`, `tools`, `config`, and `utils` directories using TypeScript. Includes basic build (`tsc`) and start scripts. This initial version establishes the foundation for a powerful CLI tool enabling developers to use Gemini for coding assistance directly in their terminal environment. --- Created by yours truly: __Gemini Code__ --- packages/cli/src/ui/hooks/useGeminiStream.ts | 142 +++++++++++++++++++++++ packages/cli/src/ui/hooks/useLoadingIndicator.ts | 53 +++++++++ 2 files changed, 195 insertions(+) create mode 100644 packages/cli/src/ui/hooks/useGeminiStream.ts create mode 100644 packages/cli/src/ui/hooks/useLoadingIndicator.ts (limited to 'packages/cli/src/ui/hooks') diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts new file mode 100644 index 00000000..71972fbe --- /dev/null +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -0,0 +1,142 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { useInput } from 'ink'; +import { GeminiClient } from '../../core/GeminiClient.js'; +import { type Chat, type PartListUnion } from '@google/genai'; +import { HistoryItem } from '../types.js'; +import { processGeminiStream } from '../../core/geminiStreamProcessor.js'; +import { StreamingState } from '../../core/StreamingState.js'; + +const addHistoryItem = ( + setHistory: React.Dispatch>, + itemData: Omit, + id: number +) => { + setHistory((prevHistory) => [ + ...prevHistory, + { ...itemData, id } as HistoryItem, + ]); +}; + +export const useGeminiStream = ( + setHistory: React.Dispatch>, +) => { + const [streamingState, setStreamingState] = useState(StreamingState.Idle); + const [initError, setInitError] = useState(null); + const abortControllerRef = useRef(null); + const currentToolGroupIdRef = useRef(null); + const chatSessionRef = useRef(null); + const geminiClientRef = useRef(null); + const messageIdCounterRef = useRef(0); + + // Initialize Client Effect (remains the same) + useEffect(() => { + setInitError(null); + if (!geminiClientRef.current) { + try { + geminiClientRef.current = new GeminiClient(); + } catch (error: any) { + setInitError(`Failed to initialize client: ${error.message || 'Unknown error'}`); + } + } + }, []); + + // Input Handling Effect (remains the same) + useInput((input, key) => { + if (streamingState === StreamingState.Responding && key.escape) { + abortControllerRef.current?.abort(); + } + }); + + // ID Generation Callback (remains the same) + const getNextMessageId = useCallback((baseTimestamp: number): number => { + messageIdCounterRef.current += 1; + return baseTimestamp + messageIdCounterRef.current; + }, []); + + // Submit Query Callback (updated to call processGeminiStream) + const submitQuery = useCallback(async (query: PartListUnion) => { + if (streamingState === StreamingState.Responding) { + // No-op if already going. + return; + } + + if (typeof query === 'string' && query.toString().trim().length === 0) { + return; + } + + const userMessageTimestamp = Date.now(); + const client = geminiClientRef.current; + if (!client) { + setInitError("Gemini client is not available."); + return; + } + + if (!chatSessionRef.current) { + chatSessionRef.current = await client.startChat(); + } + + // Reset state + setStreamingState(StreamingState.Responding); + setInitError(null); + currentToolGroupIdRef.current = null; + messageIdCounterRef.current = 0; + const chat = chatSessionRef.current; + + try { + // Add user message + if (typeof query === 'string') { + const trimmedQuery = query.toString(); + addHistoryItem(setHistory, { type: 'user', text: trimmedQuery }, userMessageTimestamp); + } else if ( + // HACK to detect errored function responses. + typeof query === 'object' && + query !== null && + !Array.isArray(query) && // Ensure it's a single Part object + 'functionResponse' in query && // Check if it's a function response Part + query.functionResponse?.response && // Check if response object exists + 'error' in query.functionResponse.response // Check specifically for the 'error' key + ) { + const history = chat.getHistory(); + history.push({ role: 'user', parts: [query] }); + return; + } + + // Prepare for streaming + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + // --- Delegate to Stream Processor --- + + const stream = client.sendMessageStream(chat, query, signal); + + const addHistoryItemFromStream = (itemData: Omit, id: number) => { + addHistoryItem(setHistory, itemData, id); + }; + const getStreamMessageId = () => getNextMessageId(userMessageTimestamp); + + // Call the renamed processor function + await processGeminiStream({ + stream, + signal, + setHistory, + submitQuery, + getNextMessageId: getStreamMessageId, + addHistoryItem: addHistoryItemFromStream, + currentToolGroupIdRef, + }); + } catch (error: any) { + // (Error handling for stream initiation remains the same) + console.error("Error initiating stream:", error); + if (error.name !== 'AbortError') { + // Use historyUpdater's function potentially? Or keep addHistoryItem here? + // Keeping addHistoryItem here for direct errors from this scope. + addHistoryItem(setHistory, { type: 'error', text: `[Error starting stream: ${error.message}]` }, getNextMessageId(userMessageTimestamp)); + } + } finally { + abortControllerRef.current = null; + setStreamingState(StreamingState.Idle); + } + }, [setStreamingState, setHistory, initError, getNextMessageId]); + + return { streamingState, submitQuery, initError }; +}; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts new file mode 100644 index 00000000..f1ab4552 --- /dev/null +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useRef } from 'react'; +import { WITTY_LOADING_PHRASES, PHRASE_CHANGE_INTERVAL_MS } from '../constants.js'; +import { StreamingState } from '../../core/StreamingState.js'; + +export const useLoadingIndicator = (streamingState: StreamingState) => { + const [elapsedTime, setElapsedTime] = useState(0); + const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(WITTY_LOADING_PHRASES[0]); + const timerRef = useRef(null); + const phraseIntervalRef = useRef(null); + const currentPhraseIndexRef = useRef(0); + + // Timer effect for elapsed time during loading + useEffect(() => { + if (streamingState === StreamingState.Responding) { + setElapsedTime(0); // Reset timer on new loading start + timerRef.current = setInterval(() => { + setElapsedTime((prevTime) => prevTime + 1); + }, 1000); + } else if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + // Cleanup on unmount or when isLoading changes + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, [streamingState]); + + // Effect for cycling through witty loading phrases + useEffect(() => { + if (streamingState === StreamingState.Responding) { + currentPhraseIndexRef.current = 0; + setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); + phraseIntervalRef.current = setInterval(() => { + currentPhraseIndexRef.current = (currentPhraseIndexRef.current + 1) % WITTY_LOADING_PHRASES.length; + setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[currentPhraseIndexRef.current]); + }, PHRASE_CHANGE_INTERVAL_MS); + } else if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + // Cleanup on unmount or when isLoading changes + return () => { + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + } + }; + }, [streamingState]); + + return { elapsedTime, currentLoadingPhrase }; +}; \ No newline at end of file -- cgit v1.2.3