Download packages/cli/src/ui/utils/MarkdownDisplay.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 13.4 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/utils/MarkdownDisplay.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/utils/MarkdownDisplay.tsx
-
curl -L -o MarkdownDisplay.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/utils/MarkdownDisplay.tsx
13.4 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import React from 'react'; | |
| import { Text, Box } from 'ink'; | |
| import { theme } from '../semantic-colors.js'; | |
| import { colorizeCode } from './CodeColorizer.js'; | |
| import { TableRenderer } from './TableRenderer.js'; | |
| import { RenderInline } from './InlineMarkdownRenderer.js'; | |
| import { useSettings } from '../contexts/SettingsContext.js'; | |
| import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; | |
| interface MarkdownDisplayProps { | |
| text: string; | |
| isPending: boolean; | |
| availableTerminalHeight?: number; | |
| terminalWidth: number; | |
| renderMarkdown?: boolean; | |
| } | |
| // Constants for Markdown parsing and rendering | |
| const EMPTY_LINE_HEIGHT = 1; | |
| const CODE_BLOCK_PREFIX_PADDING = 1; | |
| const LIST_ITEM_PREFIX_PADDING = 1; | |
| const LIST_ITEM_TEXT_FLEX_GROW = 1; | |
| const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({ | |
| text, | |
| isPending, | |
| availableTerminalHeight, | |
| terminalWidth, | |
| renderMarkdown = true, | |
| }) => { | |
| const settings = useSettings(); | |
| const isAlternateBuffer = useAlternateBuffer(); | |
| const responseColor = theme.text.response ?? theme.text.primary; | |
| if (!text) return <></>; | |
| // Raw markdown mode - display syntax-highlighted markdown without rendering | |
| if (!renderMarkdown) { | |
| // Hide line numbers in raw markdown mode as they are confusing due to chunked output | |
| const colorizedMarkdown = colorizeCode({ | |
| code: text, | |
| language: 'markdown', | |
| availableHeight: isAlternateBuffer ? undefined : availableTerminalHeight, | |
| maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING, | |
| settings, | |
| hideLineNumbers: true, | |
| }); | |
| return ( | |
| <Box paddingLeft={CODE_BLOCK_PREFIX_PADDING} flexDirection="column"> | |
| {colorizedMarkdown} | |
| </Box> | |
| ); | |
| } | |
| const lines = text.split(/\r?\n/); | |
| const headerRegex = /^ *(#{1,4}) +(.*)/; | |
| const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/; | |
| const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; | |
| const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; | |
| const hrRegex = /^ *([-*_] *){3,} *$/; | |
| const tableRowRegex = /^\s*\|(.+)\|\s*$/; | |
| const tableSeparatorRegex = /^\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)+\|?\s*$/; | |
| const contentBlocks: React.ReactNode[] = []; | |
| let inCodeBlock = false; | |
| let lastLineEmpty = true; | |
| let codeBlockContent: string[] = []; | |
| let codeBlockLang: string | null = null; | |
| let codeBlockFence = ''; | |
| let inTable = false; | |
| let tableRows: string[][] = []; | |
| let tableHeaders: string[] = []; | |
| function addContentBlock(block: React.ReactNode) { | |
| if (block) { | |
| contentBlocks.push(block); | |
| lastLineEmpty = false; | |
| } | |
| } | |
| lines.forEach((line, index) => { | |
| const key = `line-${index}`; | |
| if (inCodeBlock) { | |
| const fenceMatch = line.match(codeFenceRegex); | |
| if ( | |
| fenceMatch && | |
| fenceMatch[1].startsWith(codeBlockFence[0]) && | |
| fenceMatch[1].length >= codeBlockFence.length | |
| ) { | |
| addContentBlock( | |
| <RenderCodeBlock | |
| key={key} | |
| content={codeBlockContent} | |
| lang={codeBlockLang} | |
| isPending={isPending} | |
| availableTerminalHeight={ | |
| isAlternateBuffer ? undefined : availableTerminalHeight | |
| } | |
| terminalWidth={terminalWidth} | |
| />, | |
| ); | |
| inCodeBlock = false; | |
| codeBlockContent = []; | |
| codeBlockLang = null; | |
| codeBlockFence = ''; | |
| } else { | |
| codeBlockContent.push(line); | |
| } | |
| return; | |
| } | |
| 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); | |
| const tableRowMatch = line.match(tableRowRegex); | |
| const tableSeparatorMatch = line.match(tableSeparatorRegex); | |
| if (codeFenceMatch) { | |
| inCodeBlock = true; | |
| codeBlockFence = codeFenceMatch[1]; | |
| codeBlockLang = codeFenceMatch[2] || null; | |
| } else if (tableRowMatch && !inTable) { | |
| // Potential table start - check if next line is separator | |
| if ( | |
| index + 1 < lines.length && | |
| lines[index + 1].match(tableSeparatorRegex) | |
| ) { | |
| inTable = true; | |
| tableHeaders = tableRowMatch[1].split('|').map((cell) => cell.trim()); | |
| tableRows = []; | |
| } else { | |
| // Not a table, treat as regular text | |
| addContentBlock( | |
| <Box key={key}> | |
| <Text wrap="wrap" color={responseColor}> | |
| <RenderInline text={line} defaultColor={responseColor} /> | |
| </Text> | |
| </Box>, | |
| ); | |
| } | |
| } else if (inTable && tableSeparatorMatch) { | |
| // Skip separator line - already handled | |
| } else if (inTable && tableRowMatch) { | |
| // Add table row | |
| const cells = tableRowMatch[1].split('|').map((cell) => cell.trim()); | |
| // Ensure row has same column count as headers | |
| while (cells.length < tableHeaders.length) { | |
| cells.push(''); | |
| } | |
| if (cells.length > tableHeaders.length) { | |
| cells.length = tableHeaders.length; | |
| } | |
| tableRows.push(cells); | |
| } else if (inTable && !tableRowMatch) { | |
| // End of table | |
| if (tableHeaders.length > 0 && tableRows.length > 0) { | |
| addContentBlock( | |
| <RenderTable | |
| key={`table-${contentBlocks.length}`} | |
| headers={tableHeaders} | |
| rows={tableRows} | |
| terminalWidth={terminalWidth} | |
| />, | |
| ); | |
| } | |
| inTable = false; | |
| tableRows = []; | |
| tableHeaders = []; | |
| // Process current line as normal | |
| if (line.trim().length > 0) { | |
| addContentBlock( | |
| <Box key={key}> | |
| <Text wrap="wrap" color={responseColor}> | |
| <RenderInline text={line} defaultColor={responseColor} /> | |
| </Text> | |
| </Box>, | |
| ); | |
| } | |
| } else if (hrMatch) { | |
| addContentBlock( | |
| <Box key={key}> | |
| <Text dimColor>---</Text> | |
| </Box>, | |
| ); | |
| } else if (headerMatch) { | |
| const level = headerMatch[1].length; | |
| const headerText = headerMatch[2]; | |
| let headerNode: React.ReactNode = null; | |
| switch (level) { | |
| case 1: | |
| headerNode = ( | |
| <Text bold color={theme.text.link}> | |
| <RenderInline text={headerText} defaultColor={theme.text.link} /> | |
| </Text> | |
| ); | |
| break; | |
| case 2: | |
| headerNode = ( | |
| <Text bold color={theme.text.link}> | |
| <RenderInline text={headerText} defaultColor={theme.text.link} /> | |
| </Text> | |
| ); | |
| break; | |
| case 3: | |
| headerNode = ( | |
| <Text bold color={responseColor}> | |
| <RenderInline text={headerText} defaultColor={responseColor} /> | |
| </Text> | |
| ); | |
| break; | |
| case 4: | |
| headerNode = ( | |
| <Text italic color={theme.text.secondary}> | |
| <RenderInline | |
| text={headerText} | |
| defaultColor={theme.text.secondary} | |
| /> | |
| </Text> | |
| ); | |
| break; | |
| default: | |
| headerNode = ( | |
| <Text color={responseColor}> | |
| <RenderInline text={headerText} defaultColor={responseColor} /> | |
| </Text> | |
| ); | |
| break; | |
| } | |
| if (headerNode) addContentBlock(<Box key={key}>{headerNode}</Box>); | |
| } else if (ulMatch) { | |
| const leadingWhitespace = ulMatch[1]; | |
| const marker = ulMatch[2]; | |
| const itemText = ulMatch[3]; | |
| addContentBlock( | |
| <RenderListItem | |
| key={key} | |
| itemText={itemText} | |
| type="ul" | |
| marker={marker} | |
| leadingWhitespace={leadingWhitespace} | |
| />, | |
| ); | |
| } else if (olMatch) { | |
| const leadingWhitespace = olMatch[1]; | |
| const marker = olMatch[2]; | |
| const itemText = olMatch[3]; | |
| addContentBlock( | |
| <RenderListItem | |
| key={key} | |
| itemText={itemText} | |
| type="ol" | |
| marker={marker} | |
| leadingWhitespace={leadingWhitespace} | |
| />, | |
| ); | |
| } else { | |
| if (line.trim().length === 0 && !inCodeBlock) { | |
| if (!lastLineEmpty) { | |
| contentBlocks.push( | |
| <Box key={`spacer-${index}`} height={EMPTY_LINE_HEIGHT} />, | |
| ); | |
| lastLineEmpty = true; | |
| } | |
| } else { | |
| addContentBlock( | |
| <Box key={key}> | |
| <Text wrap="wrap" color={responseColor}> | |
| <RenderInline text={line} defaultColor={responseColor} /> | |
| </Text> | |
| </Box>, | |
| ); | |
| } | |
| } | |
| }); | |
| if (inCodeBlock) { | |
| addContentBlock( | |
| <RenderCodeBlock | |
| key="line-eof" | |
| content={codeBlockContent} | |
| lang={codeBlockLang} | |
| isPending={isPending} | |
| availableTerminalHeight={ | |
| isAlternateBuffer ? undefined : availableTerminalHeight | |
| } | |
| terminalWidth={terminalWidth} | |
| />, | |
| ); | |
| } | |
| // Handle table at end of content | |
| if (inTable && tableHeaders.length > 0 && tableRows.length > 0) { | |
| addContentBlock( | |
| <RenderTable | |
| key={`table-${contentBlocks.length}`} | |
| headers={tableHeaders} | |
| rows={tableRows} | |
| terminalWidth={terminalWidth} | |
| />, | |
| ); | |
| } | |
| return <>{contentBlocks}</>; | |
| }; | |
| // Helper functions (adapted from static methods of MarkdownRenderer) | |
| interface RenderCodeBlockProps { | |
| content: string[]; | |
| lang: string | null; | |
| isPending: boolean; | |
| availableTerminalHeight?: number; | |
| terminalWidth: number; | |
| } | |
| const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({ | |
| content, | |
| lang, | |
| isPending, | |
| availableTerminalHeight, | |
| terminalWidth, | |
| }) => { | |
| const settings = useSettings(); | |
| const isAlternateBuffer = useAlternateBuffer(); | |
| const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message | |
| const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding | |
| // When not in alternate buffer mode we need to be careful that we don't | |
| // trigger flicker when the pending code is too long to fit in the terminal | |
| if ( | |
| !isAlternateBuffer && | |
| isPending && | |
| availableTerminalHeight !== undefined | |
| ) { | |
| const MAX_CODE_LINES_WHEN_PENDING = Math.max( | |
| 0, | |
| availableTerminalHeight - RESERVED_LINES, | |
| ); | |
| if (content.length > MAX_CODE_LINES_WHEN_PENDING) { | |
| if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) { | |
| // Not enough space to even show the message meaningfully | |
| return ( | |
| <Box paddingLeft={CODE_BLOCK_PREFIX_PADDING}> | |
| <Text color={theme.text.secondary}> | |
| ... code is being written ... | |
| </Text> | |
| </Box> | |
| ); | |
| } | |
| const truncatedContent = content.slice(0, MAX_CODE_LINES_WHEN_PENDING); | |
| const colorizedTruncatedCode = colorizeCode({ | |
| code: truncatedContent.join('\n'), | |
| language: lang, | |
| availableHeight: availableTerminalHeight, | |
| maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING, | |
| settings, | |
| }); | |
| return ( | |
| <Box paddingLeft={CODE_BLOCK_PREFIX_PADDING} flexDirection="column"> | |
| {colorizedTruncatedCode} | |
| <Text color={theme.text.secondary}>... generating more ...</Text> | |
| </Box> | |
| ); | |
| } | |
| } | |
| const fullContent = content.join('\n'); | |
| const colorizedCode = colorizeCode({ | |
| code: fullContent, | |
| language: lang, | |
| availableHeight: isAlternateBuffer ? undefined : availableTerminalHeight, | |
| maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING, | |
| settings, | |
| }); | |
| return ( | |
| <Box | |
| paddingLeft={CODE_BLOCK_PREFIX_PADDING} | |
| flexDirection="column" | |
| width={terminalWidth} | |
| flexShrink={0} | |
| > | |
| {colorizedCode} | |
| </Box> | |
| ); | |
| }; | |
| const RenderCodeBlock = React.memo(RenderCodeBlockInternal); | |
| interface RenderListItemProps { | |
| itemText: string; | |
| type: 'ul' | 'ol'; | |
| marker: string; | |
| leadingWhitespace?: string; | |
| } | |
| const RenderListItemInternal: React.FC<RenderListItemProps> = ({ | |
| itemText, | |
| type, | |
| marker, | |
| leadingWhitespace = '', | |
| }) => { | |
| const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; | |
| const prefixWidth = prefix.length; | |
| // Account for leading whitespace (indentation level) plus the standard prefix padding | |
| const indentation = leadingWhitespace.length; | |
| const listResponseColor = theme.text.response ?? theme.text.primary; | |
| return ( | |
| <Box | |
| paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING} | |
| flexDirection="row" | |
| > | |
| <Box width={prefixWidth} flexShrink={0}> | |
| <Text color={listResponseColor}>{prefix}</Text> | |
| </Box> | |
| <Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}> | |
| <Text wrap="wrap" color={listResponseColor}> | |
| <RenderInline text={itemText} defaultColor={listResponseColor} /> | |
| </Text> | |
| </Box> | |
| </Box> | |
| ); | |
| }; | |
| const RenderListItem = React.memo(RenderListItemInternal); | |
| interface RenderTableProps { | |
| headers: string[]; | |
| rows: string[][]; | |
| terminalWidth: number; | |
| } | |
| const RenderTableInternal: React.FC<RenderTableProps> = ({ | |
| headers, | |
| rows, | |
| terminalWidth, | |
| }) => ( | |
| <TableRenderer headers={headers} rows={rows} terminalWidth={terminalWidth} /> | |
| ); | |
| const RenderTable = React.memo(RenderTableInternal); | |
| export const MarkdownDisplay = React.memo(MarkdownDisplayInternal); | |