mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-19 08:14:35 -07:00
perf(ui): optimize MarkdownDisplay with memoization
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { colorizeCode } from './CodeColorizer.js';
|
||||
@@ -28,6 +28,14 @@ const CODE_BLOCK_PREFIX_PADDING = 1;
|
||||
const LIST_ITEM_PREFIX_PADDING = 1;
|
||||
const LIST_ITEM_TEXT_FLEX_GROW = 1;
|
||||
|
||||
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 MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
@@ -39,282 +47,284 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const responseColor = theme.text.response ?? theme.text.primary;
|
||||
|
||||
if (!text) return <></>;
|
||||
const contentBlocks = useMemo(() => {
|
||||
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;
|
||||
// Raw markdown mode - display syntax-highlighted markdown without rendering
|
||||
if (!renderMarkdown) {
|
||||
const colorizedMarkdown = colorizeCode({
|
||||
code: text,
|
||||
language: 'markdown',
|
||||
availableHeight: isAlternateBuffer
|
||||
? undefined
|
||||
: availableTerminalHeight,
|
||||
maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING,
|
||||
settings,
|
||||
hideLineNumbers: true,
|
||||
});
|
||||
return [
|
||||
<Box
|
||||
key="raw-markdown"
|
||||
paddingLeft={CODE_BLOCK_PREFIX_PADDING}
|
||||
flexDirection="column"
|
||||
>
|
||||
{colorizedMarkdown}
|
||||
</Box>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const key = `line-${index}`;
|
||||
const lines = text.split(/\r?\n/);
|
||||
const blocks: 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 addBlock(block: React.ReactNode) {
|
||||
if (block) {
|
||||
blocks.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
|
||||
) {
|
||||
addBlock(
|
||||
<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) {
|
||||
if (
|
||||
index + 1 < lines.length &&
|
||||
lines[index + 1].match(tableSeparatorRegex)
|
||||
) {
|
||||
inTable = true;
|
||||
tableHeaders = tableRowMatch[1].split('|').map((cell) => cell.trim());
|
||||
tableRows = [];
|
||||
} else {
|
||||
addBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap" color={responseColor}>
|
||||
<RenderInline text={line} defaultColor={responseColor} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
} else if (inTable && tableSeparatorMatch) {
|
||||
// Skip separator
|
||||
} else if (inTable && tableRowMatch) {
|
||||
const cells = tableRowMatch[1].split('|').map((cell) => cell.trim());
|
||||
while (cells.length < tableHeaders.length) {
|
||||
cells.push('');
|
||||
}
|
||||
if (cells.length > tableHeaders.length) {
|
||||
cells.length = tableHeaders.length;
|
||||
}
|
||||
tableRows.push(cells);
|
||||
} else if (inTable && !tableRowMatch) {
|
||||
if (tableHeaders.length > 0 && tableRows.length > 0) {
|
||||
addBlock(
|
||||
<RenderTable
|
||||
key={`table-${blocks.length}`}
|
||||
headers={tableHeaders}
|
||||
rows={tableRows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
inTable = false;
|
||||
tableRows = [];
|
||||
tableHeaders = [];
|
||||
|
||||
if (line.trim().length > 0) {
|
||||
addBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap" color={responseColor}>
|
||||
<RenderInline text={line} defaultColor={responseColor} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
} else if (hrMatch) {
|
||||
addBlock(
|
||||
<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:
|
||||
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) addBlock(<Box key={key}>{headerNode}</Box>);
|
||||
} else if (ulMatch) {
|
||||
const leadingWhitespace = ulMatch[1];
|
||||
const marker = ulMatch[2];
|
||||
const itemText = ulMatch[3];
|
||||
addBlock(
|
||||
<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];
|
||||
addBlock(
|
||||
<RenderListItem
|
||||
key={key}
|
||||
itemText={itemText}
|
||||
type="ol"
|
||||
marker={marker}
|
||||
leadingWhitespace={leadingWhitespace}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
if (line.trim().length === 0 && !inCodeBlock) {
|
||||
if (!lastLineEmpty) {
|
||||
blocks.push(
|
||||
<Box key={`spacer-${index}`} height={EMPTY_LINE_HEIGHT} />,
|
||||
);
|
||||
lastLineEmpty = true;
|
||||
}
|
||||
} else {
|
||||
addBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap" color={responseColor}>
|
||||
<RenderInline text={line} defaultColor={responseColor} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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}
|
||||
addBlock(
|
||||
<RenderCodeBlock
|
||||
key="line-eof"
|
||||
content={codeBlockContent}
|
||||
lang={codeBlockLang}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
} 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}
|
||||
}
|
||||
|
||||
if (inTable && tableHeaders.length > 0 && tableRows.length > 0) {
|
||||
addBlock(
|
||||
<RenderTable
|
||||
key={`table-${blocks.length}`}
|
||||
headers={tableHeaders}
|
||||
rows={tableRows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
} 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}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return blocks;
|
||||
}, [
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
renderMarkdown,
|
||||
settings,
|
||||
isAlternateBuffer,
|
||||
responseColor,
|
||||
]);
|
||||
|
||||
// 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}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!text) return <></>;
|
||||
|
||||
return <>{contentBlocks}</>;
|
||||
};
|
||||
|
||||
// Helper functions (adapted from static methods of MarkdownRenderer)
|
||||
// Helper components
|
||||
|
||||
interface RenderCodeBlockProps {
|
||||
content: string[];
|
||||
@@ -333,11 +343,9 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
|
||||
}) => {
|
||||
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
|
||||
const MIN_LINES_FOR_MESSAGE = 1;
|
||||
const RESERVED_LINES = 2;
|
||||
|
||||
// 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 &&
|
||||
@@ -350,7 +358,6 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
|
||||
|
||||
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}>
|
||||
@@ -414,7 +421,6 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
||||
}) => {
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user