From e0ed5fe41193729f0db73ee7b69746b6d2520856 Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:56:17 -0800 Subject: [PATCH] chore: Improve markdown render for lists and tables (#228) --- src/cli/components/MarkdownDisplay.tsx | 216 ++++++++++++++++++------- 1 file changed, 154 insertions(+), 62 deletions(-) diff --git a/src/cli/components/MarkdownDisplay.tsx b/src/cli/components/MarkdownDisplay.tsx index 7255dd0..bce517c 100644 --- a/src/cli/components/MarkdownDisplay.tsx +++ b/src/cli/components/MarkdownDisplay.tsx @@ -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 = ({ text, @@ -25,54 +44,116 @@ export const MarkdownDisplay: React.FC = ({ 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 ( + + {/* 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 (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( {code} , ); - 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 = ({ 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 = ( - + contentBlocks.push( + + - ); - } else if (level === 2) { - headerElement = ( - - - - ); - } else if (level === 3) { - headerElement = ( - - - - ); - } else { - headerElement = ( - - - - ); - } - - contentBlocks.push({headerElement}); - return; + , + ); + index++; + continue; } // Check for list items @@ -125,9 +186,8 @@ export const MarkdownDisplay: React.FC = ({ 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 = ({ , ); - return; + index++; + continue; } // Check for blockquotes @@ -156,7 +217,8 @@ export const MarkdownDisplay: React.FC = ({ , ); - return; + index++; + continue; } // Check for horizontal rules @@ -166,13 +228,42 @@ export const MarkdownDisplay: React.FC = ({ ─────────────────────────────── , ); - 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(); - return; + index++; + continue; } // Regular paragraph text with optional hanging indent for wrapped lines @@ -195,7 +286,8 @@ export const MarkdownDisplay: React.FC = ({ )} , ); - }); + index++; + } // Handle unclosed code block at end of input if (inCodeBlock && codeBlockContent.length > 0) {