perf(ui): optimize MarkdownDisplay with memoization

This commit is contained in:
Sehoon Shon
2026-03-12 01:16:42 -04:00
parent 3da1563c30
commit 4024ee3896
+273 -267
View File
@@ -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;