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/components/Footer.tsx | 21 +++ packages/cli/src/ui/components/Header.tsx | 38 ++++++ packages/cli/src/ui/components/HistoryDisplay.tsx | 39 ++++++ packages/cli/src/ui/components/InputPrompt.tsx | 39 ++++++ .../cli/src/ui/components/LoadingIndicator.tsx | 32 +++++ packages/cli/src/ui/components/Tips.tsx | 17 +++ .../src/ui/components/messages/DiffRenderer.tsx | 152 +++++++++++++++++++++ .../src/ui/components/messages/ErrorMessage.tsx | 24 ++++ .../src/ui/components/messages/GeminiMessage.tsx | 44 ++++++ .../cli/src/ui/components/messages/InfoMessage.tsx | 24 ++++ .../messages/ToolConfirmationMessage.tsx | 101 ++++++++++++++ .../ui/components/messages/ToolGroupMessage.tsx | 47 +++++++ .../cli/src/ui/components/messages/ToolMessage.tsx | 53 +++++++ .../cli/src/ui/components/messages/UserMessage.tsx | 24 ++++ 14 files changed, 655 insertions(+) create mode 100644 packages/cli/src/ui/components/Footer.tsx create mode 100644 packages/cli/src/ui/components/Header.tsx create mode 100644 packages/cli/src/ui/components/HistoryDisplay.tsx create mode 100644 packages/cli/src/ui/components/InputPrompt.tsx create mode 100644 packages/cli/src/ui/components/LoadingIndicator.tsx create mode 100644 packages/cli/src/ui/components/Tips.tsx create mode 100644 packages/cli/src/ui/components/messages/DiffRenderer.tsx create mode 100644 packages/cli/src/ui/components/messages/ErrorMessage.tsx create mode 100644 packages/cli/src/ui/components/messages/GeminiMessage.tsx create mode 100644 packages/cli/src/ui/components/messages/InfoMessage.tsx create mode 100644 packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx create mode 100644 packages/cli/src/ui/components/messages/ToolGroupMessage.tsx create mode 100644 packages/cli/src/ui/components/messages/ToolMessage.tsx create mode 100644 packages/cli/src/ui/components/messages/UserMessage.tsx (limited to 'packages/cli/src/ui/components') diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx new file mode 100644 index 00000000..06e6c681 --- /dev/null +++ b/packages/cli/src/ui/components/Footer.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Box, Text } from 'ink'; + +interface FooterProps { + queryLength: number; +} + +const Footer: React.FC = ({ queryLength }) => { + return ( + + + + {queryLength === 0 ? "? for shortcuts" : ""} + + + Gemini + + ); +}; + +export default Footer; \ No newline at end of file diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx new file mode 100644 index 00000000..c5a99a30 --- /dev/null +++ b/packages/cli/src/ui/components/Header.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { UI_WIDTH, BOX_PADDING_X } from '../constants.js'; +import { shortenPath } from '../../utils/paths.js'; + +interface HeaderProps { + cwd: string; +} + +const Header: React.FC = ({ cwd }) => { + return ( + <> + {/* Static Header Art */} + + {` + ______ ________ ____ ____ _____ ____ _____ _____ + .' ___ ||_ __ ||_ \\ / _||_ _||_ \\|_ _||_ _| +/ .' \\_| | |_ \\_| | \\/ | | | | \\ | | | | +| | ____ | _| _ | |\\ /| | | | | |\\ \\| | | | +\\ \`.___] |_| |__/ | _| |_\\/_| |_ _| |_ _| |_\\ |_ _| |_ + \`._____.'|________||_____||_____||_____||_____|\\____||_____|`} + + {/* CWD Display */} + + cwd: {shortenPath(cwd, /*maxLength*/ 70)} + + + ); +}; + +export default Header; \ No newline at end of file diff --git a/packages/cli/src/ui/components/HistoryDisplay.tsx b/packages/cli/src/ui/components/HistoryDisplay.tsx new file mode 100644 index 00000000..bacbb258 --- /dev/null +++ b/packages/cli/src/ui/components/HistoryDisplay.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Box } from 'ink'; +import type { HistoryItem } from '../types.js'; +import { UI_WIDTH } from '../constants.js'; +import UserMessage from './messages/UserMessage.js'; +import GeminiMessage from './messages/GeminiMessage.js'; +import InfoMessage from './messages/InfoMessage.js'; +import ErrorMessage from './messages/ErrorMessage.js'; +import ToolGroupMessage from './messages/ToolGroupMessage.js'; +import { PartListUnion } from '@google/genai'; + +interface HistoryDisplayProps { + history: HistoryItem[]; + onSubmit: (value: PartListUnion) => void; +} + +const HistoryDisplay: React.FC = ({ history, onSubmit }) => { + // No grouping logic needed here anymore + return ( + + {history.map((item) => ( + + {/* Render standard message types */} + {item.type === 'user' && } + {item.type === 'gemini' && } + {item.type === 'info' && } + {item.type === 'error' && } + + {/* Render the tool group component */} + {item.type === 'tool_group' && ( + + )} + + ))} + + ); +}; + +export default HistoryDisplay; \ No newline at end of file diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx new file mode 100644 index 00000000..92be10a4 --- /dev/null +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import TextInput from 'ink-text-input'; + +interface InputPromptProps { + query: string; + setQuery: (value: string) => void; + onSubmit: (value: string) => void; + isActive: boolean; +} + +const InputPrompt: React.FC = ({ + query, + setQuery, + onSubmit, +}) => { + return ( + + > + + + + + ); +}; + +export default InputPrompt; \ No newline at end of file diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx new file mode 100644 index 00000000..8a3f9b5e --- /dev/null +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; + +interface LoadingIndicatorProps { + isLoading: boolean; + currentLoadingPhrase: string; + elapsedTime: number; +} + +const LoadingIndicator: React.FC = ({ + isLoading, + currentLoadingPhrase, + elapsedTime, +}) => { + if (!isLoading) { + return null; // Don't render anything if not loading + } + + return ( + + + + + {currentLoadingPhrase} ({elapsedTime}s) + {/* Spacer */} + (ESC to cancel) + + ); +}; + +export default LoadingIndicator; \ No newline at end of file diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx new file mode 100644 index 00000000..5dbe60b2 --- /dev/null +++ b/packages/cli/src/ui/components/Tips.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { UI_WIDTH } from '../constants.js'; + +const Tips: React.FC = () => { + return ( + + Tips for getting started: + 1. /help for more information. + 2. /init to create a GEMINI.md for instructions & context. + 3. Ask coding questions, edit code or run commands. + 4. Be specific for the best results. + + ); +}; + +export default Tips; \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx new file mode 100644 index 00000000..5cae9004 --- /dev/null +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Box, Text } from 'ink' + +interface DiffLine { + type: 'add' | 'del' | 'context' | 'hunk' | 'other'; + oldLine?: number; + newLine?: number; + content: string; +} + +function parseDiffWithLineNumbers(diffContent: string): DiffLine[] { + const lines = diffContent.split('\n'); + const result: DiffLine[] = []; + let currentOldLine = 0; + let currentNewLine = 0; + let inHunk = false; + const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/; + + for (const line of lines) { + const hunkMatch = line.match(hunkHeaderRegex); + if (hunkMatch) { + currentOldLine = parseInt(hunkMatch[1], 10); + currentNewLine = parseInt(hunkMatch[2], 10); + inHunk = true; + result.push({ type: 'hunk', content: line }); + // We need to adjust the starting point because the first line number applies to the *first* actual line change/context, + // but we increment *before* pushing that line. So decrement here. + currentOldLine--; + currentNewLine--; + continue; + } + if (!inHunk) { + // Skip standard Git header lines more robustly + if (line.startsWith('--- ') || line.startsWith('+++ ') || line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('similarity index') || line.startsWith('rename from') || line.startsWith('rename to') || line.startsWith('new file mode') || line.startsWith('deleted file mode')) continue; + // If it's not a hunk or header, skip (or handle as 'other' if needed) + continue; + } + if (line.startsWith('+')) { + currentNewLine++; // Increment before pushing + result.push({ type: 'add', newLine: currentNewLine, content: line.substring(1) }); + } else if (line.startsWith('-')) { + currentOldLine++; // Increment before pushing + result.push({ type: 'del', oldLine: currentOldLine, content: line.substring(1) }); + } else if (line.startsWith(' ')) { + currentOldLine++; // Increment before pushing + currentNewLine++; + result.push({ type: 'context', oldLine: currentOldLine, newLine: currentNewLine, content: line.substring(1) }); + } else if (line.startsWith('\\')) { // Handle "\ No newline at end of file" + result.push({ type: 'other', content: line }); + } + } + return result; +} + + +interface DiffRendererProps { + diffContent: string; + filename?: string; + tabWidth?: number; +} + +const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization + +const DiffRenderer: React.FC = ({ diffContent, tabWidth = DEFAULT_TAB_WIDTH }) => { + if (!diffContent || typeof diffContent !== 'string') { + return No diff content.; + } + + const parsedLines = parseDiffWithLineNumbers(diffContent); + + // 1. Normalize whitespace (replace tabs with spaces) *before* further processing + const normalizedLines = parsedLines.map(line => ({ + ...line, + content: line.content.replace(/\t/g, ' '.repeat(tabWidth)) + })); + + // Filter out non-displayable lines (hunks, potentially 'other') using the normalized list + const displayableLines = normalizedLines.filter(l => l.type !== 'hunk' && l.type !== 'other'); + + + if (displayableLines.length === 0) { + return ( + + No changes detected. + + ); + } + + // Calculate the minimum indentation across all displayable lines + let baseIndentation = Infinity; // Start high to find the minimum + for (const line of displayableLines) { + // Only consider lines with actual content for indentation calculation + if (line.content.trim() === '') continue; + + const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char + const currentIndent = (firstCharIndex === -1) ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found + baseIndentation = Math.min(baseIndentation, currentIndent); + } + // If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0 + if (!isFinite(baseIndentation)) { + baseIndentation = 0; + } + // --- End Modification --- + + + return ( + + {/* Iterate over the lines that should be displayed (already normalized) */} + {displayableLines.map((line, index) => { + const key = `diff-line-${index}`; + let gutterNumStr = ''; + let color: string | undefined = undefined; + let prefixSymbol = ' '; + let dim = false; + + switch (line.type) { + case 'add': + gutterNumStr = (line.newLine ?? '').toString(); + color = 'green'; + prefixSymbol = '+'; + break; + case 'del': + gutterNumStr = (line.oldLine ?? '').toString(); + color = 'red'; + prefixSymbol = '-'; + break; + case 'context': + // Show new line number for context lines in gutter + gutterNumStr = (line.newLine ?? '').toString(); + dim = true; + prefixSymbol = ' '; + break; + } + + // Render the line content *after* stripping the calculated *minimum* baseIndentation. + // The line.content here is already the tab-normalized version. + const displayContent = line.content.substring(baseIndentation); + + return ( + // Using your original rendering structure + + {gutterNumStr} + {prefixSymbol} + {displayContent} + + ); + })} + + ); +}; + +export default DiffRenderer; \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.tsx new file mode 100644 index 00000000..7ed8f4f2 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ErrorMessage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Text, Box } from 'ink'; + +interface ErrorMessageProps { + text: string; +} + +const ErrorMessage: React.FC = ({ text }) => { + const prefix = '✕ '; + const prefixWidth = prefix.length; + + return ( + + + {prefix} + + + {text} + + + ); +}; + +export default ErrorMessage; \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx new file mode 100644 index 00000000..fe09eb33 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Text, Box } from 'ink'; +import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; + +interface GeminiMessageProps { + text: string; +} + +const GeminiMessage: React.FC = ({ text }) => { + const prefix = '✦ '; + const prefixWidth = prefix.length; + + // Handle potentially null or undefined text gracefully + const safeText = text || ''; + + // Use the static render method from the MarkdownRenderer class + // Pass safeText which is guaranteed to be a string + const renderedBlocks = MarkdownRenderer.render(safeText); + + // If the original text was actually empty/null, render the minimal state + if (!safeText && renderedBlocks.length === 0) { + return ( + + + {prefix} + + + + ); + } + + return ( + + + {prefix} + + + {renderedBlocks} + + + ); +}; + +export default GeminiMessage; \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx new file mode 100644 index 00000000..8f5841b2 --- /dev/null +++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Text, Box } from 'ink'; + +interface InfoMessageProps { + text: string; +} + +const InfoMessage: React.FC = ({ text }) => { + const prefix = 'ℹ '; + const prefixWidth = prefix.length; + + return ( + + + {prefix} + + + {text} + + + ); +}; + +export default InfoMessage; \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx new file mode 100644 index 00000000..a37d2f94 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Box, Text, useInput } from 'ink'; +import SelectInput from 'ink-select-input'; +import { ToolCallConfirmationDetails, ToolEditConfirmationDetails, ToolConfirmationOutcome, ToolExecuteConfirmationDetails } from '../../types.js'; // Adjust path as needed +import { PartListUnion } from '@google/genai'; +import DiffRenderer from './DiffRenderer.js'; +import { UI_WIDTH } from '../../constants.js'; + +export interface ToolConfirmationMessageProps { + confirmationDetails: ToolCallConfirmationDetails; + onSubmit: (value: PartListUnion) => void; +} + +function isEditDetails(props: ToolCallConfirmationDetails): props is ToolEditConfirmationDetails { + return (props as ToolEditConfirmationDetails).fileName !== undefined; +} + +interface InternalOption { + label: string; + value: ToolConfirmationOutcome; +} + +const ToolConfirmationMessage: React.FC = ({ confirmationDetails }) => { + const { onConfirm } = confirmationDetails; + + useInput((_, key) => { + if (key.escape) { + onConfirm(ToolConfirmationOutcome.Cancel); + } + }); + + const handleSelect = (item: InternalOption) => { + onConfirm(item.value); + }; + + let title: string; + let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here + let question: string; + const options: InternalOption[] = []; + + if (isEditDetails(confirmationDetails)) { + title = "Edit"; // Title for the outer box + + // Body content is now the DiffRenderer, passing filename to it + // The bordered box is removed from here and handled within DiffRenderer + bodyContent = ( + + ); + + question = `Apply this change?`; + options.push( + { label: '1. Yes, apply change', value: ToolConfirmationOutcome.ProceedOnce }, + { label: "2. Yes, always apply file edits", value: ToolConfirmationOutcome.ProceedAlways }, + { label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel } + ); + + } else { + const executionProps = confirmationDetails as ToolExecuteConfirmationDetails; + title = "Execute Command"; // Title for the outer box + + // For execution, we still need context display and description + const commandDisplay = {executionProps.command}; + + // Combine command and description into bodyContent for layout consistency + bodyContent = ( + + {commandDisplay} + + ); + + question = `Allow execution?`; + const alwaysLabel = `2. Yes, always allow '${executionProps.rootCommand}' commands`; + options.push( + { label: '1. Yes, allow once', value: ToolConfirmationOutcome.ProceedOnce }, + { label: alwaysLabel, value: ToolConfirmationOutcome.ProceedAlways }, + { label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel } + ); + } + + return ( + + {/* Body Content (Diff Renderer or Command Info) */} + {/* No separate context display here anymore for edits */} + + {bodyContent} + + + {/* Confirmation Question */} + + {question} + + + {/* Select Input for Options */} + + + + + ); +}; + +export default ToolConfirmationMessage; \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx new file mode 100644 index 00000000..6ef3c5fc --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Box } from 'ink'; +import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js'; +import ToolMessage from './ToolMessage.js'; +import { PartListUnion } from '@google/genai'; +import ToolConfirmationMessage from './ToolConfirmationMessage.js'; + +interface ToolGroupMessageProps { + toolCalls: IndividualToolCallDisplay[]; + onSubmit: (value: PartListUnion) => void; +} + +// Main component renders the border and maps the tools using ToolMessage +const ToolGroupMessage: React.FC = ({ toolCalls, onSubmit }) => { + const hasPending = toolCalls.some(t => t.status === ToolCallStatus.Pending); + const borderColor = hasPending ? "yellow" : "blue"; + + return ( + + {toolCalls.map((tool) => { + return ( + + + {tool.status === ToolCallStatus.Confirming && tool.confirmationDetails && ( + + )} + + ); + })} + {/* Optional: Add padding below the last item if needed, + though ToolMessage already has some vertical space implicitly */} + {/* {tools.length > 0 && } */} + + ); +}; + +export default ToolGroupMessage; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx new file mode 100644 index 00000000..38bc3de1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import { ToolCallStatus } from '../../types.js'; +import { ToolResultDisplay } from '../../../tools/ToolResult.js'; +import DiffRenderer from './DiffRenderer.js'; +import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; + +interface ToolMessageProps { + name: string; + description: string; + resultDisplay: ToolResultDisplay | undefined; + status: ToolCallStatus; +} + +const ToolMessage: React.FC = ({ name, description, resultDisplay, status }) => { + const statusIndicatorWidth = 3; + const hasResult = (status === ToolCallStatus.Invoked || status === ToolCallStatus.Canceled) && resultDisplay && resultDisplay.toString().trim().length > 0; + + return ( + + {/* Row for Status Indicator and Tool Info */} + + {/* Status Indicator */} + + {status === ToolCallStatus.Pending && } + {status === ToolCallStatus.Invoked && } + {status === ToolCallStatus.Confirming && ?} + {status === ToolCallStatus.Canceled && -} + + + + + {name} {description} + + + + + {hasResult && ( + + + + {/* Use default text color (white) or gray instead of dimColor */} + {typeof resultDisplay === 'string' && {MarkdownRenderer.render(resultDisplay)}} + {typeof resultDisplay === 'object' && } + + + )} + + ); +}; + +export default ToolMessage; diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx new file mode 100644 index 00000000..0dd451f6 --- /dev/null +++ b/packages/cli/src/ui/components/messages/UserMessage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Text, Box } from 'ink'; + +interface UserMessageProps { + text: string; +} + +const UserMessage: React.FC = ({ text }) => { + const prefix = '> '; + const prefixWidth = prefix.length; + + return ( + + + {prefix} + + + {text} + + + ); +}; + +export default UserMessage; \ No newline at end of file -- cgit v1.2.3