Files
letta-code/src/cli/components/MarkdownDisplay.tsx

304 lines
9.0 KiB
TypeScript

import { Box, Text, Transform } from "ink";
import type React from "react";
import { colors } from "./colors.js";
import { InlineMarkdown } from "./InlineMarkdownRenderer.js";
interface MarkdownDisplayProps {
text: string;
dimColor?: boolean;
hangingIndent?: number; // indent for wrapped lines within a paragraph
}
// Regex patterns for markdown elements (defined outside component to avoid re-creation)
const headerRegex = /^(#{1,6})\s+(.*)$/;
const codeBlockRegex = /^```(\w*)?$/;
const listItemRegex = /^(\s*)([*\-+]|\d+\.)\s+(.*)$/;
const blockquoteRegex = /^>\s*(.*)$/;
const hrRegex = /^[-*_]{3,}$/;
const tableRowRegex = /^\|(.+)\|$/;
const tableSeparatorRegex = /^\|[\s:]*[-]+[\s:]*(\|[\s:]*[-]+[\s:]*)+\|$/;
// Header styles lookup
const headerStyles: Record<
number,
{ bold?: boolean; italic?: boolean; color?: string }
> = {
1: { bold: true, color: colors.heading.primary },
2: { bold: true, color: colors.heading.secondary },
3: { bold: true },
};
const defaultHeaderStyle = { italic: true };
/**
* Renders full markdown content using pure Ink components.
* Based on Gemini CLI's approach - NO ANSI codes, NO marked-terminal!
*/
export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
text,
dimColor,
hangingIndent = 0,
}) => {
if (!text) return null;
const lines = text.split("\n");
const contentBlocks: React.ReactNode[] = [];
let inCodeBlock = false;
let codeBlockContent: string[] = [];
// Helper function to parse table cells from a row
const parseTableCells = (row: string): string[] => {
return row
.slice(1, -1) // Remove leading and trailing |
.split("|")
.map((cell) => cell.trim());
};
// Helper function to render a table
const renderTable = (
tableLines: string[],
startIndex: number,
): React.ReactNode => {
if (tableLines.length < 2 || !tableLines[0]) return null;
const headerRow = parseTableCells(tableLines[0]);
const bodyRows = tableLines.slice(2).map(parseTableCells); // Skip separator row
// Calculate column widths
const colWidths = headerRow.map((header, colIdx) => {
const bodyMax = bodyRows.reduce((max, row) => {
const cell = row[colIdx] || "";
return Math.max(max, cell.length);
}, 0);
return Math.max(header.length, bodyMax, 3); // Minimum 3 chars
});
return (
<Box key={`table-${startIndex}`} flexDirection="column" marginY={0}>
{/* Header row */}
<Box flexDirection="row">
<Text dimColor={dimColor}></Text>
{headerRow.map((cell, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static table content
<Box key={`h-${idx}`} flexDirection="row">
<Text bold dimColor={dimColor}>
{" "}
{cell.padEnd(colWidths[idx] ?? 3)}
</Text>
<Text dimColor={dimColor}> </Text>
</Box>
))}
</Box>
{/* Separator */}
<Box flexDirection="row">
<Text dimColor={dimColor}></Text>
{colWidths.map((width, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static table content
<Box key={`s-${idx}`} flexDirection="row">
<Text dimColor={dimColor}>{"─".repeat(width + 2)}</Text>
<Text dimColor={dimColor}>
{idx < colWidths.length - 1 ? "┼" : "┤"}
</Text>
</Box>
))}
</Box>
{/* Body rows */}
{bodyRows.map((row, rowIdx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static table content
<Box key={`r-${rowIdx}`} flexDirection="row">
<Text dimColor={dimColor}></Text>
{row.map((cell, colIdx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static table content
<Box key={`c-${colIdx}`} flexDirection="row">
<Text dimColor={dimColor}>
{" "}
{(cell || "").padEnd(colWidths[colIdx] || 3)}
</Text>
<Text dimColor={dimColor}> </Text>
</Box>
))}
</Box>
))}
</Box>
);
};
// Use index-based loop to handle multi-line elements (tables)
let index = 0;
while (index < lines.length) {
const line = lines[index] as string; // Safe: index < lines.length
const key = `line-${index}`;
// Handle code blocks
if (codeBlockRegex.test(line)) {
if (!inCodeBlock) {
inCodeBlock = true;
codeBlockContent = [];
} else {
inCodeBlock = false;
const code = codeBlockContent.join("\n");
contentBlocks.push(
<Box key={key} paddingLeft={2}>
<Text color={colors.code.inline}>{code}</Text>
</Box>,
);
codeBlockContent = [];
}
index++;
continue;
}
// If we're inside a code block, collect the content
if (inCodeBlock) {
codeBlockContent.push(line);
index++;
continue;
}
// Check for headers
const headerMatch = line.match(headerRegex);
if (headerMatch?.[1] && headerMatch[2] !== undefined) {
const level = headerMatch[1].length;
const content = headerMatch[2];
const style = headerStyles[level] ?? defaultHeaderStyle;
contentBlocks.push(
<Box key={key}>
<Text {...style}>
<InlineMarkdown text={content} dimColor={dimColor} />
</Text>
</Box>,
);
index++;
continue;
}
// Check for list items
const listMatch = line.match(listItemRegex);
if (
listMatch &&
listMatch[1] !== undefined &&
listMatch[2] &&
listMatch[3] !== undefined
) {
const indent = listMatch[1].length;
const marker = listMatch[2];
const content = listMatch[3];
// Preserve original marker for copy-paste compatibility
const bullet = `${marker} `;
const bulletWidth = bullet.length;
contentBlocks.push(
<Box key={key} paddingLeft={indent} flexDirection="row">
<Box width={bulletWidth} flexShrink={0}>
<Text dimColor={dimColor}>{bullet}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" dimColor={dimColor}>
<InlineMarkdown text={content} dimColor={dimColor} />
</Text>
</Box>
</Box>,
);
index++;
continue;
}
// Check for blockquotes
const blockquoteMatch = line.match(blockquoteRegex);
if (blockquoteMatch && blockquoteMatch[1] !== undefined) {
contentBlocks.push(
<Box key={key} paddingLeft={2}>
<Text dimColor> </Text>
<Text wrap="wrap" dimColor={dimColor}>
<InlineMarkdown text={blockquoteMatch[1]} dimColor={dimColor} />
</Text>
</Box>,
);
index++;
continue;
}
// Check for horizontal rules
if (line.match(hrRegex)) {
contentBlocks.push(
<Box key={key}>
<Text dimColor></Text>
</Box>,
);
index++;
continue;
}
// Check for tables (must have | at start and end, and next line should be separator)
const nextLine = lines[index + 1];
if (
tableRowRegex.test(line) &&
nextLine &&
tableSeparatorRegex.test(nextLine)
) {
// Collect all table lines
const tableLines: string[] = [line];
let tableIdx = index + 1;
while (tableIdx < lines.length) {
const tableLine = lines[tableIdx];
if (!tableLine || !tableRowRegex.test(tableLine)) break;
tableLines.push(tableLine);
tableIdx++;
}
// Also accept separator-only lines
if (tableLines.length >= 2) {
const tableElement = renderTable(tableLines, index);
if (tableElement) {
contentBlocks.push(tableElement);
}
index = tableIdx;
continue;
}
}
// Empty lines
if (line.trim() === "") {
contentBlocks.push(<Box key={key} height={1} />);
index++;
continue;
}
// Regular paragraph text with optional hanging indent for wrapped lines
contentBlocks.push(
<Box key={key}>
{hangingIndent > 0 ? (
<Transform
transform={(ln, i) =>
i === 0 ? ln : " ".repeat(hangingIndent) + ln
}
>
<Text wrap="wrap" dimColor={dimColor}>
<InlineMarkdown text={line} dimColor={dimColor} />
</Text>
</Transform>
) : (
<Text wrap="wrap" dimColor={dimColor}>
<InlineMarkdown text={line} dimColor={dimColor} />
</Text>
)}
</Box>,
);
index++;
}
// Handle unclosed code block at end of input
if (inCodeBlock && codeBlockContent.length > 0) {
const code = codeBlockContent.join("\n");
contentBlocks.push(
<Box key="unclosed-code" paddingLeft={2}>
<Text color={colors.code.inline}>{code}</Text>
</Box>,
);
}
return <Box flexDirection="column">{contentBlocks}</Box>;
};