summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/messages
diff options
context:
space:
mode:
authorTaylor Mullen <[email protected]>2025-04-15 21:41:08 -0700
committerTaylor Mullen <[email protected]>2025-04-17 13:19:55 -0400
commitadd233c5043264d47ecc6d3339a383f41a241ae8 (patch)
tree3d80d412ed805007132cf44257bbd7667005dcd8 /packages/cli/src/ui/components/messages
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__
Diffstat (limited to 'packages/cli/src/ui/components/messages')
-rw-r--r--packages/cli/src/ui/components/messages/DiffRenderer.tsx152
-rw-r--r--packages/cli/src/ui/components/messages/ErrorMessage.tsx24
-rw-r--r--packages/cli/src/ui/components/messages/GeminiMessage.tsx44
-rw-r--r--packages/cli/src/ui/components/messages/InfoMessage.tsx24
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx101
-rw-r--r--packages/cli/src/ui/components/messages/ToolGroupMessage.tsx47
-rw-r--r--packages/cli/src/ui/components/messages/ToolMessage.tsx53
-rw-r--r--packages/cli/src/ui/components/messages/UserMessage.tsx24
8 files changed, 469 insertions, 0 deletions
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<DiffRendererProps> = ({ diffContent, tabWidth = DEFAULT_TAB_WIDTH }) => {
+ if (!diffContent || typeof diffContent !== 'string') {
+ return <Text color="yellow">No diff content.</Text>;
+ }
+
+ 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 (
+ <Box borderStyle="round" borderColor="gray" padding={1}>
+ <Text dimColor>No changes detected.</Text>
+ </Box>
+ );
+ }
+
+ // 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 (
+ <Box borderStyle="round" borderColor="gray" flexDirection="column">
+ {/* 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
+ <Box key={key} flexDirection="row">
+ <Text color="gray">{gutterNumStr} </Text>
+ <Text color={color} dimColor={dim}>{prefixSymbol} </Text>
+ <Text color={color} dimColor={dim} wrap="wrap">{displayContent}</Text>
+ </Box>
+ );
+ })}
+ </Box>
+ );
+};
+
+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<ErrorMessageProps> = ({ text }) => {
+ const prefix = '✕ ';
+ const prefixWidth = prefix.length;
+
+ return (
+ <Box flexDirection="row">
+ <Box width={prefixWidth}>
+ <Text color="red">{prefix}</Text>
+ </Box>
+ <Box flexGrow={1}>
+ <Text wrap="wrap" color="red">{text}</Text>
+ </Box>
+ </Box>
+ );
+};
+
+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<GeminiMessageProps> = ({ 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 (
+ <Box flexDirection="row">
+ <Box width={prefixWidth}>
+ <Text color="blue">{prefix}</Text>
+ </Box>
+ <Box flexGrow={1}></Box>
+ </Box>
+ );
+ }
+
+ return (
+ <Box flexDirection="row">
+ <Box width={prefixWidth}>
+ <Text color="blue">{prefix}</Text>
+ </Box>
+ <Box flexGrow={1} flexDirection="column">
+ {renderedBlocks}
+ </Box>
+ </Box>
+ );
+};
+
+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<InfoMessageProps> = ({ text }) => {
+ const prefix = 'ℹ ';
+ const prefixWidth = prefix.length;
+
+ return (
+ <Box flexDirection="row">
+ <Box width={prefixWidth}>
+ <Text color="yellow">{prefix}</Text>
+ </Box>
+ <Box flexGrow={1}>
+ <Text wrap="wrap" color="yellow">{text}</Text>
+ </Box>
+ </Box>
+ );
+};
+
+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<ToolConfirmationMessageProps> = ({ 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 = (
+ <DiffRenderer diffContent={confirmationDetails.fileDiff} />
+ );
+
+ 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 = <Text color="cyan">{executionProps.command}</Text>;
+
+ // Combine command and description into bodyContent for layout consistency
+ bodyContent = (
+ <Box flexDirection="column">
+ <Box paddingX={1} marginLeft={1}>{commandDisplay}</Box>
+ </Box>
+ );
+
+ 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 (
+ <Box flexDirection="column" padding={1} minWidth={UI_WIDTH}>
+ {/* Body Content (Diff Renderer or Command Info) */}
+ {/* No separate context display here anymore for edits */}
+ <Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
+ {bodyContent}
+ </Box>
+
+ {/* Confirmation Question */}
+ <Box marginBottom={1} flexShrink={0}>
+ <Text>{question}</Text>
+ </Box>
+
+ {/* Select Input for Options */}
+ <Box flexShrink={0}>
+ <SelectInput items={options} onSelect={handleSelect} />
+ </Box>
+ </Box>
+ );
+};
+
+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<ToolGroupMessageProps> = ({ toolCalls, onSubmit }) => {
+ const hasPending = toolCalls.some(t => t.status === ToolCallStatus.Pending);
+ const borderColor = hasPending ? "yellow" : "blue";
+
+ return (
+ <Box
+ flexDirection="column"
+ borderStyle="round"
+ borderColor={borderColor}
+ >
+ {toolCalls.map((tool) => {
+ return (
+ <React.Fragment key={tool.callId}>
+ <ToolMessage
+ key={tool.callId} // Use callId as the key
+ name={tool.name}
+ description={tool.description}
+ resultDisplay={tool.resultDisplay}
+ status={tool.status}
+ />
+ {tool.status === ToolCallStatus.Confirming && tool.confirmationDetails && (
+ <ToolConfirmationMessage confirmationDetails={tool.confirmationDetails} onSubmit={onSubmit}></ToolConfirmationMessage>
+ )}
+ </React.Fragment>
+ );
+ })}
+ {/* Optional: Add padding below the last item if needed,
+ though ToolMessage already has some vertical space implicitly */}
+ {/* {tools.length > 0 && <Box height={1} />} */}
+ </Box>
+ );
+};
+
+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<ToolMessageProps> = ({ name, description, resultDisplay, status }) => {
+ const statusIndicatorWidth = 3;
+ const hasResult = (status === ToolCallStatus.Invoked || status === ToolCallStatus.Canceled) && resultDisplay && resultDisplay.toString().trim().length > 0;
+
+ return (
+ <Box paddingX={1} paddingY={0} flexDirection="column">
+ {/* Row for Status Indicator and Tool Info */}
+ <Box minHeight={1}>
+ {/* Status Indicator */}
+ <Box minWidth={statusIndicatorWidth}>
+ {status === ToolCallStatus.Pending && <Spinner type="dots" />}
+ {status === ToolCallStatus.Invoked && <Text color="green">✔</Text>}
+ {status === ToolCallStatus.Confirming && <Text color="blue">?</Text>}
+ {status === ToolCallStatus.Canceled && <Text color="red" bold>-</Text>}
+
+ </Box>
+ <Box>
+ <Text color="blue" wrap="truncate-end" strikethrough={status === ToolCallStatus.Canceled}>
+ <Text bold>{name}</Text> <Text color="gray">{description}</Text>
+ </Text>
+ </Box>
+ </Box>
+
+ {hasResult && (
+ <Box paddingLeft={statusIndicatorWidth}>
+ <Box flexShrink={1} flexDirection="row">
+ <Text color="gray">↳ </Text>
+ {/* Use default text color (white) or gray instead of dimColor */}
+ {typeof resultDisplay === 'string' && <Box flexDirection='column'>{MarkdownRenderer.render(resultDisplay)}</Box>}
+ {typeof resultDisplay === 'object' && <DiffRenderer diffContent={resultDisplay.fileDiff} />}
+ </Box>
+ </Box>
+ )}
+ </Box>
+ );
+};
+
+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<UserMessageProps> = ({ text }) => {
+ const prefix = '> ';
+ const prefixWidth = prefix.length;
+
+ return (
+ <Box flexDirection="row">
+ <Box width={prefixWidth}>
+ <Text color="gray">{prefix}</Text>
+ </Box>
+ <Box flexGrow={1}>
+ <Text wrap="wrap">{text}</Text>
+ </Box>
+ </Box>
+ );
+};
+
+export default UserMessage; \ No newline at end of file