import type React from 'react'; import { memo, useMemo } from 'react'; import { sanitizeTerminalText } from '../../utils/terminal'; import { type InlinePart, type MarkdownBlock, parseDocument, shortDomain } from '../markdown/parse'; import { terminalCellWidth, truncateTerminalText } from '../text'; import { colors } from '../theme'; const MAX_RENDERED_BLOCKS = 1_000; const MAX_RENDERED_CODE_LINES = 300; const MAX_RENDERED_TABLE_ROWS = 100; function renderInlinePart(part: InlinePart, key: string, hintLabels?: Map, compact = false) { const content = sanitizeTerminalText(part.content); switch (part.type) { case 'bold': return ( {content} ); case 'italic': return ( {content} ); case 'strikethrough': return ( {content} ); case 'code': return ( {compact ? null : '`'} {content} {compact ? null : '`'} ); case 'link': { const linkText = content; const url = sanitizeTerminalText(part.url ?? ''); const domain = shortDomain(url); const textIsUrl = linkText === url || linkText === domain || url.includes(linkText); const label = part.linkIndex !== undefined ? hintLabels?.get(part.linkIndex) : undefined; return ( {label ? ( {` ${label} `} ) : null} {label ? : null} {linkText} {!compact && !textIsUrl ? {` [${domain}]`} : null} ); } default: return ( {content} ); } } function InlineContent({ parts, hintLabels }: { parts: InlinePart[]; hintLabels?: Map }) { return {parts.map((part, i) => renderInlinePart(part, `${i}-${part.type}`, hintLabels))}; } function InlineSpans({ parts, hintLabels }: { parts: InlinePart[]; hintLabels?: Map }) { return parts.map((part, i) => renderInlinePart(part, `${i}-${part.type}`, hintLabels)); } function inlineCellWidth(parts: InlinePart[], hintLabels?: Map): number { return parts.reduce((width, part) => { const label = part.type === 'link' && part.linkIndex !== undefined ? hintLabels?.get(part.linkIndex) : undefined; return width + terminalCellWidth(sanitizeTerminalText(part.content)) + (label ? terminalCellWidth(label) + 3 : 0); }, 0); } function renderInlineCell( parts: InlinePart[], width: number, key: string, hintLabels?: Map, ): React.ReactNode { const fitted: InlinePart[] = []; let used = 0; for (const part of parts) { const label = part.type === 'link' && part.linkIndex !== undefined ? hintLabels?.get(part.linkIndex) : undefined; const labelWidth = label ? terminalCellWidth(label) + 3 : 0; const remaining = Math.max(0, width - used); if (labelWidth > remaining) break; const content = sanitizeTerminalText(part.content); const visibleContent = truncateTerminalText(content, remaining - labelWidth); if (!visibleContent && labelWidth === 0) break; fitted.push({ ...part, content: visibleContent }); used += labelWidth + terminalCellWidth(visibleContent); if (visibleContent !== content) break; } return ( {fitted.map((part, index) => renderInlinePart(part, `${key}-${index}`, hintLabels, true))} {' '.repeat(Math.max(0, width - used))} ); } export interface MarkdownRendererProps { content: string; width?: number; /** When set, links whose linkIndex appears as a key get a `[label]` prefix. */ hintLabels?: Map; /** * Pre-parsed blocks. When provided, the renderer reuses them instead of * re-parsing `content`, so callers that also need the parsed link list can * parse the document a single time and share the result. */ blocks?: MarkdownBlock[]; } export const MarkdownRenderer = memo(function MarkdownRenderer({ content, width: availableWidth, hintLabels, blocks: blocksProp, }: MarkdownRendererProps) { const blocks = useMemo(() => blocksProp ?? parseDocument(content).blocks, [blocksProp, content]); const hasFiniteExplicitWidth = typeof availableWidth === 'number' && Number.isFinite(availableWidth); const safeWidth = availableWidth === undefined ? 60 : hasFiniteExplicitWidth ? Math.max(0, Math.floor(availableWidth)) : 0; const hrWidth = Math.min(safeWidth, 60); const elements: React.ReactNode[] = []; let idx = 0; for (const block of blocks.slice(0, MAX_RENDERED_BLOCKS)) { const key = `md-${idx++}`; switch (block.type) { case 'h1': { const parts = (block.inlineParts ?? [{ type: 'text', content: block.content } satisfies InlinePart]).map( (part) => ({ ...part, content: part.content.toUpperCase() }), ); elements.push( {'═══ '} {' ═══'} , ); break; } case 'h2': { const parts = block.inlineParts ?? [{ type: 'text', content: block.content } satisfies InlinePart]; elements.push( {'── '} {' ──'} , ); break; } case 'h3': { const parts = block.inlineParts ?? [{ type: 'text', content: block.content } satisfies InlinePart]; elements.push( , ); break; } case 'h4': { const parts = block.inlineParts ?? [{ type: 'text', content: block.content } satisfies InlinePart]; elements.push( , ); break; } case 'h5': { const parts = block.inlineParts ?? [{ type: 'text', content: block.content } satisfies InlinePart]; elements.push( , ); break; } case 'h6': { const parts = block.inlineParts ?? [{ type: 'text', content: block.content } satisfies InlinePart]; elements.push( , ); break; } case 'blockquote': { const bqLines = block.blockquoteLines ?? []; const lineKeyCounts = new Map(); elements.push( {bqLines.map((parts) => { // Keep keys stable without copying an entire untrusted quote // line into the React key. A short signature plus occurrence // count handles duplicate lines while bounding key size. const signature = parts .map((part) => `${part.type}:${part.content.slice(0, 64)}`) .join('|') .slice(0, 256); const seen = lineKeyCounts.get(signature) ?? 0; lineKeyCounts.set(signature, seen + 1); return ( {'┃ '} ); })} , ); break; } case 'ulist': { const parts = block.inlineParts ?? []; elements.push( {'• '} , ); break; } case 'olist': { const parts = block.inlineParts ?? []; elements.push( {`${block.number ?? '·'}. `} , ); break; } case 'codeblock': { const codeLines = sanitizeTerminalText(block.content, { preserveNewlines: true }).split('\n'); const showLines = codeLines.length > 3; elements.push( {block.language ? ( {` ${block.language}`} ) : null} {codeLines.slice(0, MAX_RENDERED_CODE_LINES).map((line, lineIndex) => { const lineKey = `${key}-line-${lineIndex}`; return ( {showLines ? `${String(lineIndex + 1).padStart(3, ' ')} ` : ''} {line} ); })} {codeLines.length > MAX_RENDERED_CODE_LINES ? ( {`… ${codeLines.length - MAX_RENDERED_CODE_LINES} code lines omitted`} ) : null} , ); break; } case 'hr': elements.push( {'─'.repeat(hrWidth)} , ); break; case 'image': elements.push( {'🖼 '} {sanitizeTerminalText(block.content)} , ); break; case 'table': elements.push(renderTable(block, key, safeWidth, hintLabels)); break; case 'blank': elements.push(); break; case 'paragraph': { const parts = block.inlineParts ?? []; elements.push( , ); break; } } } if (blocks.length > MAX_RENDERED_BLOCKS) { elements.push( {`… ${blocks.length - MAX_RENDERED_BLOCKS} blocks omitted`}, ); } return ( 0 ? safeWidth : undefined}> {elements} ); }); function renderTable( block: MarkdownBlock, key: string, availableWidth: number | undefined, hintLabels?: Map, ) { const rows = block.rows ?? []; if (rows.length === 0) return null; const colCount = Math.max(...rows.map((r) => r.length)); if (colCount === 0) return null; // A table needs at least one character per visible column plus one space // between columns. On narrow panes, show as many leading columns as fit // rather than creating negative padding or overflowing the pane. const tableWidth = Math.max(0, Math.floor(availableWidth ?? 80)); if (tableWidth === 0) return null; const visibleColCount = Math.min(colCount, Math.max(1, Math.floor((tableWidth + 1) / 2))); const gapsWidth = visibleColCount - 1; const colWidths = Array.from({ length: visibleColCount }, () => 1); const naturalWidths = Array.from({ length: visibleColCount }, () => 1); for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { const row = rows[rowIndex] ?? []; for (let column = 0; column < visibleColCount; column++) { const parts = block.tableInlineParts?.[rowIndex]?.[column] ?? [ { type: 'text', content: row[column] ?? '' } satisfies InlinePart, ]; naturalWidths[column] = Math.max(naturalWidths[column] ?? 1, inlineCellWidth(parts, hintLabels)); } } let remainingWidth = tableWidth - visibleColCount - gapsWidth; // Natural widths are measured in one pass. The bounded allocation below // avoids repeatedly scanning every row for every terminal cell. while (remainingWidth > 0) { let expanded = false; for (let c = 0; c < visibleColCount && remainingWidth > 0; c++) { const naturalWidth = naturalWidths[c] ?? 1; if ((colWidths[c] ?? 0) < naturalWidth) { colWidths[c] = (colWidths[c] ?? 0) + 1; remainingWidth--; expanded = true; } } if (!expanded) break; } const headerRow = rows[0]; const dataRows = rows.slice(1, MAX_RENDERED_TABLE_ROWS + 1); return ( {headerRow ? ( {headerRow.slice(0, visibleColCount).map((cell, ci) => { const headerKey = `${key}-h-${ci}`; return ( {renderInlineCell( block.tableInlineParts?.[0]?.[ci] ?? [{ type: 'text', content: cell }], colWidths[ci] ?? 1, headerKey, hintLabels, )} {ci < visibleColCount - 1 ? ' ' : null} ); })} ) : null} {colWidths.map((w) => '─'.repeat(w)).join(' ')} {dataRows.map((row, dataRowIndex) => { const rowKey = `${key}-r-${dataRowIndex}`; return ( {row.slice(0, visibleColCount).map((cell, ci) => { const cellKey = `${rowKey}-c-${ci}`; return ( {renderInlineCell( block.tableInlineParts?.[dataRowIndex + 1]?.[ci] ?? [{ type: 'text', content: cell }], colWidths[ci] ?? 1, cellKey, hintLabels, )} {ci < visibleColCount - 1 ? ' ' : null} ); })} ); })} {block.omittedColumns ? {`… ${block.omittedColumns} columns omitted`} : null} {block.omittedRows || rows.length - 1 > MAX_RENDERED_TABLE_ROWS ? ( {`… ${(block.omittedRows ?? 0) + Math.max(0, rows.length - 1 - MAX_RENDERED_TABLE_ROWS)} table rows omitted`} ) : null} ); }