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__
This commit is contained in:
Taylor Mullen
2025-04-15 21:41:08 -07:00
commit add233c504
54 changed files with 7920 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import type { HistoryItem } from './types.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import Header from './components/Header.js';
import Tips from './components/Tips.js';
import HistoryDisplay from './components/HistoryDisplay.js';
import LoadingIndicator from './components/LoadingIndicator.js';
import InputPrompt from './components/InputPrompt.js';
import Footer from './components/Footer.js';
import { StreamingState } from '../core/StreamingState.js';
import { PartListUnion } from '@google/genai';
interface AppProps {
directory: string;
}
const App = ({ directory }: AppProps) => {
const [query, setQuery] = useState('');
const [history, setHistory] = useState<HistoryItem[]>([]);
const { streamingState, submitQuery, initError } = useGeminiStream(setHistory);
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(streamingState);
const handleInputSubmit = (value: PartListUnion) => {
submitQuery(value).then(() => {
setQuery('');
}).catch(() => {
setQuery('');
});
};
useEffect(() => {
if (initError && !history.some(item => item.type === 'error' && item.text?.includes(initError))) {
setHistory(prev => [
...prev,
{ id: Date.now(), type: 'error', text: `Initialization Error: ${initError}. Please check API key and configuration.` } as HistoryItem
]);
}
}, [initError, history]);
const isWaitingForToolConfirmation = history.some(item =>
item.type === 'tool_group' && item.tools.some(tool => tool.confirmationDetails !== undefined)
);
const isInputActive = streamingState === StreamingState.Idle && !initError;
return (
<Box flexDirection="column" padding={1} marginBottom={1} width="100%">
<Header cwd={directory} />
<Tips />
{initError && streamingState !== StreamingState.Responding && !isWaitingForToolConfirmation && (
<Box borderStyle="round" borderColor="red" paddingX={1} marginBottom={1}>
{history.find(item => item.type === 'error' && item.text?.includes(initError))?.text ? (
<Text color="red">{history.find(item => item.type === 'error' && item.text?.includes(initError))?.text}</Text>
) : (
<>
<Text color="red">Initialization Error: {initError}</Text>
<Text color="red"> Please check API key and configuration.</Text>
</>
)}
</Box>
)}
<Box flexDirection="column">
<HistoryDisplay history={history} onSubmit={handleInputSubmit} />
<LoadingIndicator
isLoading={streamingState === StreamingState.Responding}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
/>
</Box>
{!isWaitingForToolConfirmation && isInputActive && (
<InputPrompt
query={query}
setQuery={setQuery}
onSubmit={handleInputSubmit}
isActive={isInputActive}
/>
)}
<Footer queryLength={query.length} />
</Box>
);
};
export default App;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Box, Text } from 'ink';
interface FooterProps {
queryLength: number;
}
const Footer: React.FC<FooterProps> = ({ queryLength }) => {
return (
<Box marginTop={1} justifyContent="space-between">
<Box minWidth={15}>
<Text color="gray">
{queryLength === 0 ? "? for shortcuts" : ""}
</Text>
</Box>
<Text color="blue">Gemini</Text>
</Box>
);
};
export default Footer;

View File

@@ -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<HeaderProps> = ({ cwd }) => {
return (
<>
{/* Static Header Art */}
<Box marginBottom={1}>
<Text color="blue">{`
______ ________ ____ ____ _____ ____ _____ _____
.' ___ ||_ __ ||_ \\ / _||_ _||_ \\|_ _||_ _|
/ .' \\_| | |_ \\_| | \\/ | | | | \\ | | | |
| | ____ | _| _ | |\\ /| | | | | |\\ \\| | | |
\\ \`.___] |_| |__/ | _| |_\\/_| |_ _| |_ _| |_\\ |_ _| |_
\`._____.'|________||_____||_____||_____||_____|\\____||_____|`}</Text>
</Box>
{/* CWD Display */}
<Box
borderStyle="round"
borderColor="blue"
paddingX={BOX_PADDING_X}
flexDirection="column"
marginBottom={1}
width={UI_WIDTH}
>
<Box paddingLeft={2}><Text color="gray">cwd: {shortenPath(cwd, /*maxLength*/ 70)}</Text></Box>
</Box>
</>
);
};
export default Header;

View File

@@ -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<HistoryDisplayProps> = ({ history, onSubmit }) => {
// No grouping logic needed here anymore
return (
<Box flexDirection="column">
{history.map((item) => (
<Box key={item.id} marginBottom={1}>
{/* Render standard message types */}
{item.type === 'user' && <UserMessage text={item.text} />}
{item.type === 'gemini' && <GeminiMessage text={item.text} />}
{item.type === 'info' && <InfoMessage text={item.text} />}
{item.type === 'error' && <ErrorMessage text={item.text} />}
{/* Render the tool group component */}
{item.type === 'tool_group' && (
<ToolGroupMessage toolCalls={item.tools} onSubmit={onSubmit} />
)}
</Box>
))}
</Box>
);
};
export default HistoryDisplay;

View File

@@ -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<InputPromptProps> = ({
query,
setQuery,
onSubmit,
}) => {
return (
<Box
marginTop={1}
borderStyle="round"
borderColor={'white'}
paddingX={1}
>
<Text color={'white'}>&gt; </Text>
<Box flexGrow={1}>
<TextInput
value={query}
onChange={setQuery}
onSubmit={onSubmit}
showCursor={true}
focus={true}
placeholder={'Ask Gemini... (try "/init" or "/help")'}
/>
</Box>
</Box>
);
};
export default InputPrompt;

View File

@@ -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<LoadingIndicatorProps> = ({
isLoading,
currentLoadingPhrase,
elapsedTime,
}) => {
if (!isLoading) {
return null; // Don't render anything if not loading
}
return (
<Box marginTop={1} paddingLeft={0}>
<Box marginRight={1}>
<Spinner type="dots" />
</Box>
<Text color="cyan">{currentLoadingPhrase} ({elapsedTime}s)</Text>
<Box flexGrow={1}>{/* Spacer */}</Box>
<Text color="gray">(ESC to cancel)</Text>
</Box>
);
};
export default LoadingIndicator;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Box, Text } from 'ink';
import { UI_WIDTH } from '../constants.js';
const Tips: React.FC = () => {
return (
<Box flexDirection="column" marginBottom={1} width={UI_WIDTH}>
<Text>Tips for getting started:</Text>
<Text>1. <Text bold>/help</Text> for more information.</Text>
<Text>2. <Text bold>/init</Text> to create a GEMINI.md for instructions & context.</Text>
<Text>3. Ask coding questions, edit code or run commands.</Text>
<Text>4. Be specific for the best results.</Text>
</Box>
);
};
export default Tips;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,26 @@
const EstimatedArtWidth = 59;
const BoxBorderWidth = 1;
export const BOX_PADDING_X = 1;
// Calculate width based on art, padding, and border
export const UI_WIDTH = EstimatedArtWidth + (BOX_PADDING_X * 2) + (BoxBorderWidth * 2); // ~63
export const WITTY_LOADING_PHRASES = [
'Consulting the digital spirits...',
'Reticulating splines...',
'Warming up the AI hamsters...',
'Asking the magic conch shell...',
'Generating witty retort...',
'Polishing the algorithms...',
'Don\'t rush perfection (or my code)...',
'Brewing fresh bytes...',
'Counting electrons...',
'Engaging cognitive processors...',
'Checking for syntax errors in the universe...',
'One moment, optimizing humor...',
'Shuffling punchlines...',
'Untangling neural nets...',
'Compiling brilliance...',
];
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
export const STREAM_DEBOUNCE_MS = 100;

View File

@@ -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<React.SetStateAction<HistoryItem[]>>,
itemData: Omit<HistoryItem, 'id'>,
id: number
) => {
setHistory((prevHistory) => [
...prevHistory,
{ ...itemData, id } as HistoryItem,
]);
};
export const useGeminiStream = (
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
) => {
const [streamingState, setStreamingState] = useState<StreamingState>(StreamingState.Idle);
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const currentToolGroupIdRef = useRef<number | null>(null);
const chatSessionRef = useRef<Chat | null>(null);
const geminiClientRef = useRef<GeminiClient | null>(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<HistoryItem, 'id'>, 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 };
};

View File

@@ -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<NodeJS.Timeout | null>(null);
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const currentPhraseIndexRef = useRef<number>(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 };
};

View File

@@ -0,0 +1,62 @@
import { ToolResultDisplay } from "../tools/ToolResult.js";
export enum ToolCallStatus {
Pending,
Invoked,
Confirming,
Canceled,
}
export interface ToolCallEvent {
type: 'tool_call';
status: ToolCallStatus;
callId: string;
name: string;
args: Record<string, any>;
resultDisplay: ToolResultDisplay | undefined;
confirmationDetails: ToolCallConfirmationDetails | undefined;
}
export interface IndividualToolCallDisplay {
callId: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: ToolCallStatus;
confirmationDetails: ToolCallConfirmationDetails | undefined;
}
export interface HistoryItemBase {
id: number;
text?: string; // Text content for user/gemini/info/error messages
}
export type HistoryItem = HistoryItemBase & (
| { type: 'user'; text: string }
| { type: 'gemini'; text: string }
| { type: 'info'; text: string }
| { type: 'error'; text: string }
| { type: 'tool_group'; tools: IndividualToolCallDisplay[]; }
);
export interface ToolCallConfirmationDetails {
title: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
}
export interface ToolEditConfirmationDetails extends ToolCallConfirmationDetails {
fileName: string;
fileDiff: string;
}
export interface ToolExecuteConfirmationDetails extends ToolCallConfirmationDetails {
command: string;
rootCommand: string;
description: string;
}
export enum ToolConfirmationOutcome {
ProceedOnce,
ProceedAlways,
Cancel,
}

View File

@@ -0,0 +1,249 @@
import React from 'react';
import { Text, Box } from 'ink';
/**
* A utility class to render a subset of Markdown into Ink components.
* Handles H1-H4, Lists (ul/ol, no nesting), Code Blocks,
* and inline styles (bold, italic, strikethrough, code, links).
*/
export class MarkdownRenderer {
/**
* Renders INLINE markdown elements using an iterative approach.
* Supports: **bold**, *italic*, _italic_, ~~strike~~, [link](url), `code`, ``code``, <u>underline</u>
* @param text The string segment to parse for inline styles.
* @returns An array of React nodes (Text components or strings).
*/
private static _renderInline(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
// UPDATED Regex: Added <u>.*?<\/u> pattern
const inlineRegex = /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>)/g;
let match;
while ((match = inlineRegex.exec(text)) !== null) {
// 1. Add plain text before the match
if (match.index > lastIndex) {
nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex, match.index)}</Text>);
}
const fullMatch = match[0];
let renderedNode: React.ReactNode = null;
const key = `m-${match.index}`; // Base key for matched part
// 2. Determine type of match and render accordingly
try {
if (fullMatch.startsWith('**') && fullMatch.endsWith('**') && fullMatch.length > 4) {
renderedNode = <Text key={key} bold>{fullMatch.slice(2, -2)}</Text>;
} else if (((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && fullMatch.length > 2) {
renderedNode = <Text key={key} italic>{fullMatch.slice(1, -1)}</Text>;
} else if (fullMatch.startsWith('~~') && fullMatch.endsWith('~~') && fullMatch.length > 4) {
// Strikethrough as gray text
renderedNode = <Text key={key} strikethrough>{fullMatch.slice(2, -2)}</Text>;
} else if (fullMatch.startsWith('`') && fullMatch.endsWith('`') && fullMatch.length > 1) {
// Code: Try to match varying numbers of backticks
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
renderedNode = <Text key={key} color="yellow">{codeMatch[2]}</Text>;
} else { // Fallback for simple or non-matching cases
renderedNode = <Text key={key} color="yellow">{fullMatch.slice(1, -1)}</Text>;
}
} else if (fullMatch.startsWith('[') && fullMatch.includes('](') && fullMatch.endsWith(')')) {
// Link: Extract text and URL
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
const linkText = linkMatch[1];
const url = linkMatch[2];
// Render link text then URL slightly dimmed/colored
renderedNode = (
<Text key={key}>
{linkText}
<Text color="blue"> ({url})</Text>
</Text>
);
}
} else if (fullMatch.startsWith('<u>') && fullMatch.endsWith('</u>') && fullMatch.length > 6) {
// ***** NEW: Handle underline tag *****
// Use slice(3, -4) to remove <u> and </u>
renderedNode = <Text key={key} underline>{fullMatch.slice(3, -4)}</Text>;
}
} catch (e) {
// In case of regex or slicing errors, fallback to literal rendering
console.error("Error parsing inline markdown part:", fullMatch, e);
renderedNode = null; // Ensure fallback below is used
}
// 3. Add the rendered node or the literal text if parsing failed
nodes.push(renderedNode ?? <Text key={key}>{fullMatch}</Text>);
lastIndex = inlineRegex.lastIndex; // Move index past the current match
}
// 4. Add any remaining plain text after the last match
if (lastIndex < text.length) {
nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>);
}
// Filter out potential nulls if any error occurred without fallback
return nodes.filter(node => node !== null);
}
/**
* Helper to render a code block.
*/
private static _renderCodeBlock(key: string, content: string[], lang: string | null): React.ReactNode {
// Basic styling for code block
return (
<Box key={key} borderStyle="round" paddingX={1} borderColor="gray" flexDirection="column">
{lang && <Text dimColor> {lang}</Text>}
{/* Render each line preserving whitespace (within Text component) */}
{content.map((line, idx) => (
<Text key={idx}>{line}</Text>
))}
</Box>
);
}
/**
* Helper to render a list item (ordered or unordered).
*/
private static _renderListItem(key: string, text: string, type: 'ul' | 'ol', marker: string): React.ReactNode {
const renderedText = MarkdownRenderer._renderInline(text); // Allow inline styles in list items
const prefix = type === 'ol' ? `${marker} ` : `${marker} `; // e.g., "1. " or "* "
const prefixWidth = prefix.length;
return (
<Box key={key} paddingLeft={1} flexDirection="row">
<Box width={prefixWidth}>
<Text>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap">{renderedText}</Text>
</Box>
</Box>
);
}
/**
* Renders a full markdown string, handling block elements (headers, lists, code blocks)
* and applying inline styles. This is the main public static method.
* @param text The full markdown string to render.
* @returns An array of React nodes representing markdown blocks.
*/
public static render(text: string): React.ReactNode[] {
if (!text) return [];
const lines = text.split('\n');
// Regexes for block elements
const headerRegex = /^ *(#{1,4}) +(.*)/;
const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; // ```lang or ``` or ~~~
const ulItemRegex = /^ *([-*+]) +(.*)/; // Unordered list item, captures bullet and text
const olItemRegex = /^ *(\d+)\. +(.*)/; // Ordered list item, captures number and text
const hrRegex = /^ *([-*_] *){3,} *$/; // Horizontal rule
const contentBlocks: React.ReactNode[] = [];
// State for parsing across lines
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let codeBlockLang: string | null = null;
let codeBlockFence = ''; // Store the type of fence used (``` or ~~~)
let inListType: 'ul' | 'ol' | null = null; // Track current list type to group items
lines.forEach((line, index) => {
const key = `line-${index}`;
// --- State 1: Inside a Code Block ---
if (inCodeBlock) {
const fenceMatch = line.match(codeFenceRegex);
// Check for closing fence, matching the opening one and length
if (fenceMatch && fenceMatch[1].startsWith(codeBlockFence[0]) && fenceMatch[1].length >= codeBlockFence.length) {
// End of code block - render it
contentBlocks.push(MarkdownRenderer._renderCodeBlock(key, codeBlockContent, codeBlockLang));
// Reset state
inCodeBlock = false;
codeBlockContent = [];
codeBlockLang = null;
codeBlockFence = '';
inListType = null; // Ensure list context is reset
} else {
// Add line to current code block content
codeBlockContent.push(line);
}
return; // Process next line
}
// --- State 2: Not Inside a Code Block ---
// Check for block element starts in rough order of precedence/commonness
const codeFenceMatch = line.match(codeFenceRegex);
const headerMatch = line.match(headerRegex);
const ulMatch = line.match(ulItemRegex);
const olMatch = line.match(olItemRegex);
const hrMatch = line.match(hrRegex);
if (codeFenceMatch) {
inCodeBlock = true;
codeBlockFence = codeFenceMatch[1];
codeBlockLang = codeFenceMatch[2] || null;
inListType = null; // Starting code block breaks list
} else if (hrMatch) {
// Render Horizontal Rule (simple dashed line)
// Use box with height and border character, or just Text with dashes
contentBlocks.push(<Box key={key}><Text dimColor>---</Text></Box>);
inListType = null; // HR breaks list
} else if (headerMatch) {
const level = headerMatch[1].length;
const headerText = headerMatch[2];
const renderedHeaderText = MarkdownRenderer._renderInline(headerText);
let headerNode: React.ReactNode = null;
switch (level) { /* ... (header styling as before) ... */
case 1: headerNode = <Text bold color="cyan">{renderedHeaderText}</Text>; break;
case 2: headerNode = <Text bold color="blue">{renderedHeaderText}</Text>; break;
case 3: headerNode = <Text bold>{renderedHeaderText}</Text>; break;
case 4: headerNode = <Text italic color="gray">{renderedHeaderText}</Text>; break;
}
if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>);
inListType = null; // Header breaks list
} else if (ulMatch) {
const marker = ulMatch[1]; // *, -, or +
const itemText = ulMatch[2];
// If previous line was not UL, maybe add spacing? For now, just render item.
contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ul', marker));
inListType = 'ul'; // Set/maintain list context
} else if (olMatch) {
const marker = olMatch[1]; // The number
const itemText = olMatch[2];
contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ol', marker));
inListType = 'ol'; // Set/maintain list context
} else {
// --- Regular line (Paragraph or Empty line) ---
inListType = null; // Any non-list line breaks the list sequence
// Render line content if it's not blank, applying inline styles
const renderedLine = MarkdownRenderer._renderInline(line);
if (renderedLine.length > 0 || line.length > 0) { // Render lines with content or only whitespace
contentBlocks.push(
<Box key={key}>
<Text wrap="wrap">{renderedLine}</Text>
</Box>
);
} else if (line.trim().length === 0) { // Handle specifically empty lines
// Add minimal space for blank lines between paragraphs/blocks
if (contentBlocks.length > 0 && !inCodeBlock) { // Avoid adding space inside code block state (handled above)
const previousBlock = contentBlocks[contentBlocks.length - 1];
// Avoid adding multiple blank lines consecutively easily - check if previous was also blank?
// For now, add a minimal spacer for any blank line outside code blocks.
contentBlocks.push(<Box key={key} height={1} />);
}
}
}
});
// Handle unclosed code block at the end of the input
if (inCodeBlock) {
contentBlocks.push(MarkdownRenderer._renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang));
}
return contentBlocks;
}
}