mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
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:
90
packages/cli/src/ui/App.tsx
Normal file
90
packages/cli/src/ui/App.tsx
Normal 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;
|
||||
21
packages/cli/src/ui/components/Footer.tsx
Normal file
21
packages/cli/src/ui/components/Footer.tsx
Normal 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;
|
||||
38
packages/cli/src/ui/components/Header.tsx
Normal file
38
packages/cli/src/ui/components/Header.tsx
Normal 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;
|
||||
39
packages/cli/src/ui/components/HistoryDisplay.tsx
Normal file
39
packages/cli/src/ui/components/HistoryDisplay.tsx
Normal 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;
|
||||
39
packages/cli/src/ui/components/InputPrompt.tsx
Normal file
39
packages/cli/src/ui/components/InputPrompt.tsx
Normal 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'}>> </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;
|
||||
32
packages/cli/src/ui/components/LoadingIndicator.tsx
Normal file
32
packages/cli/src/ui/components/LoadingIndicator.tsx
Normal 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;
|
||||
17
packages/cli/src/ui/components/Tips.tsx
Normal file
17
packages/cli/src/ui/components/Tips.tsx
Normal 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;
|
||||
152
packages/cli/src/ui/components/messages/DiffRenderer.tsx
Normal file
152
packages/cli/src/ui/components/messages/DiffRenderer.tsx
Normal 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;
|
||||
24
packages/cli/src/ui/components/messages/ErrorMessage.tsx
Normal file
24
packages/cli/src/ui/components/messages/ErrorMessage.tsx
Normal 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;
|
||||
44
packages/cli/src/ui/components/messages/GeminiMessage.tsx
Normal file
44
packages/cli/src/ui/components/messages/GeminiMessage.tsx
Normal 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;
|
||||
24
packages/cli/src/ui/components/messages/InfoMessage.tsx
Normal file
24
packages/cli/src/ui/components/messages/InfoMessage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
47
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Normal file
47
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Normal 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;
|
||||
53
packages/cli/src/ui/components/messages/ToolMessage.tsx
Normal file
53
packages/cli/src/ui/components/messages/ToolMessage.tsx
Normal 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;
|
||||
24
packages/cli/src/ui/components/messages/UserMessage.tsx
Normal file
24
packages/cli/src/ui/components/messages/UserMessage.tsx
Normal 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;
|
||||
26
packages/cli/src/ui/constants.ts
Normal file
26
packages/cli/src/ui/constants.ts
Normal 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;
|
||||
142
packages/cli/src/ui/hooks/useGeminiStream.ts
Normal file
142
packages/cli/src/ui/hooks/useGeminiStream.ts
Normal 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 };
|
||||
};
|
||||
53
packages/cli/src/ui/hooks/useLoadingIndicator.ts
Normal file
53
packages/cli/src/ui/hooks/useLoadingIndicator.ts
Normal 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 };
|
||||
};
|
||||
62
packages/cli/src/ui/types.ts
Normal file
62
packages/cli/src/ui/types.ts
Normal 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,
|
||||
}
|
||||
249
packages/cli/src/ui/utils/MarkdownRenderer.tsx
Normal file
249
packages/cli/src/ui/utils/MarkdownRenderer.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user