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

216 lines
6.1 KiB
TypeScript

import { Box, Text } 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
}
/**
* Renders full markdown content using pure Ink components.
* Based on Gemini CLI's approach - NO ANSI codes, NO marked-terminal!
*/
import { Transform } from "ink";
export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
text,
dimColor,
hangingIndent = 0,
}) => {
if (!text) return null;
const lines = text.split("\n");
const contentBlocks: React.ReactNode[] = [];
// Regex patterns for markdown elements
const headerRegex = /^(#{1,6})\s+(.*)$/;
const codeBlockRegex = /^```(\w*)?$/;
const listItemRegex = /^(\s*)([*\-+]|\d+\.)\s+(.*)$/;
const blockquoteRegex = /^>\s*(.*)$/;
const hrRegex = /^[-*_]{3,}$/;
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let _codeBlockLang = "";
lines.forEach((line, index) => {
const key = `line-${index}`;
// Handle code blocks
if (line.match(codeBlockRegex)) {
if (!inCodeBlock) {
// Start of code block
const match = line.match(codeBlockRegex);
_codeBlockLang = match?.[1] || "";
inCodeBlock = true;
codeBlockContent = [];
} else {
// End of code block
inCodeBlock = false;
// Render the code block
const code = codeBlockContent.join("\n");
// For now, use simple colored text for code blocks
// TODO: Could parse cli-highlight output and convert ANSI to Ink components
// but for MVP, just use a nice color like Gemini does
contentBlocks.push(
<Box key={key} paddingLeft={2} marginY={1}>
<Text color={colors.code.inline}>{code}</Text>
</Box>,
);
codeBlockContent = [];
_codeBlockLang = "";
}
return;
}
// If we're inside a code block, collect the content
if (inCodeBlock) {
codeBlockContent.push(line);
return;
}
// Check for headers
const headerMatch = line.match(headerRegex);
if (headerMatch?.[1] && headerMatch[2] !== undefined) {
const level = headerMatch[1].length;
const content = headerMatch[2];
// Different styling for different header levels
let headerElement: React.ReactNode;
if (level === 1) {
headerElement = (
<Text bold color={colors.heading.primary}>
<InlineMarkdown text={content} dimColor={dimColor} />
</Text>
);
} else if (level === 2) {
headerElement = (
<Text bold color={colors.heading.secondary}>
<InlineMarkdown text={content} dimColor={dimColor} />
</Text>
);
} else if (level === 3) {
headerElement = (
<Text bold>
<InlineMarkdown text={content} dimColor={dimColor} />
</Text>
);
} else {
headerElement = (
<Text italic>
<InlineMarkdown text={content} dimColor={dimColor} />
</Text>
);
}
contentBlocks.push(
<Box key={key} marginY={1}>
{headerElement}
</Box>,
);
return;
}
// 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];
// Determine if it's ordered or unordered list
const isOrdered = /^\d+\./.test(marker);
const bullet = isOrdered ? `${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>,
);
return;
}
// 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>,
);
return;
}
// Check for horizontal rules
if (line.match(hrRegex)) {
contentBlocks.push(
<Box key={key} marginY={1}>
<Text dimColor></Text>
</Box>,
);
return;
}
// Empty lines
if (line.trim() === "") {
contentBlocks.push(<Box key={key} height={1} />);
return;
}
// 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>,
);
});
// 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} marginY={1}>
<Text color={colors.code.inline}>{code}</Text>
</Box>,
);
}
return <Box flexDirection="column">{contentBlocks}</Box>;
};