chore: Improve markdown render for lists and tables (#228)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { Box, Text, Transform } from "ink";
|
||||
import type React from "react";
|
||||
import { colors } from "./colors.js";
|
||||
import { InlineMarkdown } from "./InlineMarkdownRenderer.js";
|
||||
@@ -9,11 +9,30 @@ interface MarkdownDisplayProps {
|
||||
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!
|
||||
*/
|
||||
import { Transform } from "ink";
|
||||
|
||||
export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
text,
|
||||
@@ -25,54 +44,116 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
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) => {
|
||||
// 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 (line.match(codeBlockRegex)) {
|
||||
if (codeBlockRegex.test(line)) {
|
||||
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}>
|
||||
<Text color={colors.code.inline}>{code}</Text>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
codeBlockContent = [];
|
||||
_codeBlockLang = "";
|
||||
}
|
||||
return;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're inside a code block, collect the content
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
return;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for headers
|
||||
@@ -80,37 +161,17 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
if (headerMatch?.[1] && headerMatch[2] !== undefined) {
|
||||
const level = headerMatch[1].length;
|
||||
const content = headerMatch[2];
|
||||
const style = headerStyles[level] ?? defaultHeaderStyle;
|
||||
|
||||
// Different styling for different header levels
|
||||
let headerElement: React.ReactNode;
|
||||
if (level === 1) {
|
||||
headerElement = (
|
||||
<Text bold color={colors.heading.primary}>
|
||||
contentBlocks.push(
|
||||
<Box key={key}>
|
||||
<Text {...style}>
|
||||
<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}>{headerElement}</Box>);
|
||||
return;
|
||||
</Box>,
|
||||
);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for list items
|
||||
@@ -125,9 +186,8 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
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} ` : "• ";
|
||||
// Preserve original marker for copy-paste compatibility
|
||||
const bullet = `${marker} `;
|
||||
const bulletWidth = bullet.length;
|
||||
|
||||
contentBlocks.push(
|
||||
@@ -142,7 +202,8 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
</Box>
|
||||
</Box>,
|
||||
);
|
||||
return;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for blockquotes
|
||||
@@ -156,7 +217,8 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
return;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for horizontal rules
|
||||
@@ -166,13 +228,42 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
<Text dimColor>───────────────────────────────</Text>
|
||||
</Box>,
|
||||
);
|
||||
return;
|
||||
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} />);
|
||||
return;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph text with optional hanging indent for wrapped lines
|
||||
@@ -195,7 +286,8 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
index++;
|
||||
}
|
||||
|
||||
// Handle unclosed code block at end of input
|
||||
if (inCodeBlock && codeBlockContent.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user