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 = ({ 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 ( {/* Header row */} {headerRow.map((cell, idx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static table content {" "} {cell.padEnd(colWidths[idx] ?? 3)} ))} {/* Separator */} {colWidths.map((width, idx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static table content {"─".repeat(width + 2)} {idx < colWidths.length - 1 ? "┼" : "┤"} ))} {/* Body rows */} {bodyRows.map((row, rowIdx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static table content {row.map((cell, colIdx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static table content {" "} {(cell || "").padEnd(colWidths[colIdx] || 3)} ))} ))} ); }; // 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( {code} , ); 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( , ); 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( {bullet} , ); index++; continue; } // Check for blockquotes const blockquoteMatch = line.match(blockquoteRegex); if (blockquoteMatch && blockquoteMatch[1] !== undefined) { contentBlocks.push( , ); index++; continue; } // Check for horizontal rules if (line.match(hrRegex)) { contentBlocks.push( ─────────────────────────────── , ); 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(); index++; continue; } // Regular paragraph text with optional hanging indent for wrapped lines contentBlocks.push( {hangingIndent > 0 ? ( i === 0 ? ln : " ".repeat(hangingIndent) + ln } > ) : ( )} , ); index++; } // Handle unclosed code block at end of input if (inCodeBlock && codeBlockContent.length > 0) { const code = codeBlockContent.join("\n"); contentBlocks.push( {code} , ); } return {contentBlocks}; };