diff options
| author | Taylor Mullen <[email protected]> | 2025-04-15 21:41:08 -0700 |
|---|---|---|
| committer | Taylor Mullen <[email protected]> | 2025-04-17 13:19:55 -0400 |
| commit | add233c5043264d47ecc6d3339a383f41a241ae8 (patch) | |
| tree | 3d80d412ed805007132cf44257bbd7667005dcd8 /packages/cli/src/ui/utils/MarkdownRenderer.tsx | |
Initial commit of Gemini Code CLI
This commit introduces the initial codebase for the Gemini Code CLI, a command-line interface designed to facilitate interaction with the Gemini API for software engineering tasks.
The code was migrated from a previous git repository as a single squashed commit.
Core Features & Components:
* **Gemini Integration:** Leverages the `@google/genai` SDK to interact with the Gemini models, supporting chat history, streaming responses, and function calling (tools).
* **Terminal UI:** Built with Ink (React for CLIs) providing an interactive chat interface within the terminal, including input prompts, message display, loading indicators, and tool interaction elements.
* **Tooling Framework:** Implements a robust tool system allowing Gemini to interact with the local environment. Includes tools for:
* File system listing (`ls`)
* File reading (`read-file`)
* Content searching (`grep`)
* File globbing (`glob`)
* File editing (`edit`)
* File writing (`write-file`)
* Executing bash commands (`terminal`)
* **State Management:** Handles the streaming state of Gemini responses and manages the conversation history.
* **Configuration:** Parses command-line arguments (`yargs`) and loads environment variables (`dotenv`) for setup.
* **Project Structure:** Organized into `core`, `ui`, `tools`, `config`, and `utils` directories using TypeScript. Includes basic build (`tsc`) and start scripts.
This initial version establishes the foundation for a powerful CLI tool enabling developers to use Gemini for coding assistance directly in their terminal environment.
---
Created by yours truly: __Gemini Code__
Diffstat (limited to 'packages/cli/src/ui/utils/MarkdownRenderer.tsx')
| -rw-r--r-- | packages/cli/src/ui/utils/MarkdownRenderer.tsx | 249 |
1 files changed, 249 insertions, 0 deletions
diff --git a/packages/cli/src/ui/utils/MarkdownRenderer.tsx b/packages/cli/src/ui/utils/MarkdownRenderer.tsx new file mode 100644 index 00000000..fc8c2b0c --- /dev/null +++ b/packages/cli/src/ui/utils/MarkdownRenderer.tsx @@ -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; + } +}
\ No newline at end of file |
