diff --git a/src/components/MarkdownTable.tsx b/src/components/MarkdownTable.tsx index 0b3473d43..11d0d070e 100644 --- a/src/components/MarkdownTable.tsx +++ b/src/components/MarkdownTable.tsx @@ -1,37 +1,37 @@ -import type { Token, Tokens } from 'marked' -import React from 'react' -import stripAnsi from 'strip-ansi' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Ansi, stringWidth, useTheme, wrapAnsi } from '@anthropic/ink' -import type { CliHighlight } from '../utils/cliHighlight.js' -import { formatToken, padAligned } from '../utils/markdown.js' +import type { Token, Tokens } from 'marked'; +import React from 'react'; +import stripAnsi from 'strip-ansi'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Ansi, stringWidth, useTheme, wrapAnsi } from '@anthropic/ink'; +import type { CliHighlight } from '../utils/cliHighlight.js'; +import { formatToken, padAligned } from '../utils/markdown.js'; /** Accounts for parent indentation (e.g. message dot prefix) and terminal * resize races. Without enough margin the table overflows its layout box * and Ink's clip truncates differently on alternating frames, causing an * infinite flicker loop in scrollback. */ -const SAFETY_MARGIN = 4 +const SAFETY_MARGIN = 4; /** Minimum column width to prevent degenerate layouts */ -const MIN_COLUMN_WIDTH = 3 +const MIN_COLUMN_WIDTH = 3; /** * Maximum number of lines per row before switching to vertical format. * When wrapping would make rows taller than this, vertical (key-value) * format provides better readability. */ -const MAX_ROW_LINES = 4 +const MAX_ROW_LINES = 4; /** ANSI escape codes for text formatting */ -const ANSI_BOLD_START = '\x1b[1m' -const ANSI_BOLD_END = '\x1b[22m' +const ANSI_BOLD_START = '\x1b[1m'; +const ANSI_BOLD_END = '\x1b[22m'; type Props = { - token: Tokens.Table - highlight: CliHighlight | null + token: Tokens.Table; + highlight: CliHighlight | null; /** Override terminal width (useful for testing) */ - forceWidth?: number -} + forceWidth?: number; +}; /** * Wrap text to fit within a given width, returning array of lines. @@ -40,26 +40,22 @@ type Props = { * @param hard - If true, break words that exceed width (needed when columns * are narrower than the longest word). Default false. */ -function wrapText( - text: string, - width: number, - options?: { hard?: boolean }, -): string[] { - if (width <= 0) return [text] +function wrapText(text: string, width: number, options?: { hard?: boolean }): string[] { + if (width <= 0) return [text]; // Strip trailing whitespace/newlines before wrapping. // formatToken() adds EOL to paragraphs and other token types, // which would otherwise create extra blank lines in table cells. - const trimmedText = text.trimEnd() + const trimmedText = text.trimEnd(); const wrapped = wrapAnsi(trimmedText, width, { hard: options?.hard ?? false, trim: false, wordWrap: true, - }) + }); // Filter out empty lines that result from trailing newlines or // multiple consecutive newlines in the source content. - const lines = wrapped.split('\n').filter(line => line.length > 0) + const lines = wrapped.split('\n').filter(line => line.length > 0); // Ensure we always return at least one line (empty string for empty cells) - return lines.length > 0 ? lines : [''] + return lines.length > 0 ? lines : ['']; } /** @@ -69,175 +65,169 @@ function wrapText( * 2. Distributing available space proportionally * 3. Wrapping text within cells (no truncation) * 4. Properly aligning multi-line rows with borders + * + * Performance: uses per-render caches (formatCache, plainTextCache, wrapCache) + * to avoid redundant formatCell/wrapText calls across the multiple passes + * (width calculation, row line counting, rendering). Wrapped in React.memo + * to skip re-renders when props are unchanged. */ -export function MarkdownTable({ +export const MarkdownTable = React.memo(function MarkdownTable({ token, highlight, forceWidth, }: Props): React.ReactNode { - const [theme] = useTheme() - const { columns: actualTerminalWidth } = useTerminalSize() - const terminalWidth = forceWidth ?? actualTerminalWidth + const [theme] = useTheme(); + const { columns: actualTerminalWidth } = useTerminalSize(); + const terminalWidth = forceWidth ?? actualTerminalWidth; + + // Per-render caches — Token[] references are stable within a single token + // prop (from LRU cache in Markdown.tsx), so reference equality is sufficient. + const formatCache = new Map(); + const plainTextCache = new Map(); - // Format cell content to ANSI string function formatCell(tokens: Token[] | undefined): string { - return ( - tokens - ?.map(_ => formatToken(_, theme, 0, null, null, highlight)) - .join('') ?? '' - ) + const cached = formatCache.get(tokens); + if (cached !== undefined) return cached; + const result = tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? ''; + formatCache.set(tokens, result); + return result; } - // Get plain text (stripped of ANSI codes) function getPlainText(tokens: Token[] | undefined): string { - return stripAnsi(formatCell(tokens)) + const cached = plainTextCache.get(tokens); + if (cached !== undefined) return cached; + const result = stripAnsi(formatCell(tokens)); + plainTextCache.set(tokens, result); + return result; } // Get the longest word width in a cell (minimum width to avoid breaking words) function getMinWidth(tokens: Token[] | undefined): number { - const text = getPlainText(tokens) - const words = text.split(/\s+/).filter(w => w.length > 0) - if (words.length === 0) return MIN_COLUMN_WIDTH - return Math.max(...words.map(w => stringWidth(w)), MIN_COLUMN_WIDTH) + const text = getPlainText(tokens); + const words = text.split(/\s+/).filter(w => w.length > 0); + if (words.length === 0) return MIN_COLUMN_WIDTH; + return Math.max(...words.map(w => stringWidth(w)), MIN_COLUMN_WIDTH); } // Get ideal width (full content without wrapping) function getIdealWidth(tokens: Token[] | undefined): number { - return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH) + return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH); } // Calculate column widths // Step 1: Get minimum (longest word) and ideal (full content) widths const minWidths = token.header.map((header, colIndex) => { - let maxMinWidth = getMinWidth(header.tokens) + let maxMinWidth = getMinWidth(header.tokens); for (const row of token.rows) { - maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens)) + maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens)); } - return maxMinWidth - }) + return maxMinWidth; + }); const idealWidths = token.header.map((header, colIndex) => { - let maxIdeal = getIdealWidth(header.tokens) + let maxIdeal = getIdealWidth(header.tokens); for (const row of token.rows) { - maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens)) + maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens)); } - return maxIdeal - }) + return maxIdeal; + }); // Step 2: Calculate available space // Border overhead: │ content │ content │ = 1 + (width + 3) per column - const numCols = token.header.length - const borderOverhead = 1 + numCols * 3 // │ + (2 padding + 1 border) per col + const numCols = token.header.length; + const borderOverhead = 1 + numCols * 3; // │ + (2 padding + 1 border) per col // Account for SAFETY_MARGIN to avoid triggering the fallback safety check - const availableWidth = Math.max( - terminalWidth - borderOverhead - SAFETY_MARGIN, - numCols * MIN_COLUMN_WIDTH, - ) + const availableWidth = Math.max(terminalWidth - borderOverhead - SAFETY_MARGIN, numCols * MIN_COLUMN_WIDTH); // Step 3: Calculate column widths that fit available space - const totalMin = minWidths.reduce((sum, w) => sum + w, 0) - const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0) + const totalMin = minWidths.reduce((sum, w) => sum + w, 0); + const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0); // Track whether columns are narrower than longest words (needs hard wrap) - let needsHardWrap = false + let needsHardWrap = false; - let columnWidths: number[] + let columnWidths: number[]; if (totalIdeal <= availableWidth) { // Everything fits - use ideal widths - columnWidths = idealWidths + columnWidths = idealWidths; } else if (totalMin <= availableWidth) { // Need to shrink - give each column its min, distribute remaining space - const extraSpace = availableWidth - totalMin - const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!) - const totalOverflow = overflows.reduce((sum, o) => sum + o, 0) + const extraSpace = availableWidth - totalMin; + const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!); + const totalOverflow = overflows.reduce((sum, o) => sum + o, 0); columnWidths = minWidths.map((min, i) => { - if (totalOverflow === 0) return min - const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace) - return min + extra - }) + if (totalOverflow === 0) return min; + const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace); + return min + extra; + }); } else { // Table wider than terminal at minimum widths // Shrink columns proportionally to fit, allowing word breaks - needsHardWrap = true - const scaleFactor = availableWidth / totalMin - columnWidths = minWidths.map(w => - Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH), - ) + needsHardWrap = true; + const scaleFactor = availableWidth / totalMin; + columnWidths = minWidths.map(w => Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH)); } - // Step 4: Calculate max row lines to determine if vertical format is needed - function calculateMaxRowLines(): number { - let maxLines = 1 - // Check header - for (let i = 0; i < token.header.length; i++) { - const content = formatCell(token.header[i]!.tokens) - const wrapped = wrapText(content, columnWidths[i]!, { - hard: needsHardWrap, - }) - maxLines = Math.max(maxLines, wrapped.length) - } - // Check rows - for (const row of token.rows) { - for (let i = 0; i < row.length; i++) { - const content = formatCell(row[i]?.tokens) - const wrapped = wrapText(content, columnWidths[i]!, { - hard: needsHardWrap, - }) - maxLines = Math.max(maxLines, wrapped.length) - } - } - return maxLines + // Step 4: Single-pass cell preparation — wraps each cell once, caches results + // for reuse by both row-line counting and rendering. + const wrapCache = new Map(); + + function getWrappedLines(tokens: Token[] | undefined, colIndex: number): string[] { + const cached = wrapCache.get(tokens); + if (cached !== undefined) return cached; + const formatted = formatCell(tokens); + const lines = wrapText(formatted, columnWidths[colIndex]!, { + hard: needsHardWrap, + }); + wrapCache.set(tokens, lines); + return lines; } - // Use vertical format if wrapping would make rows too tall - const maxRowLines = calculateMaxRowLines() - const useVerticalFormat = maxRowLines > MAX_ROW_LINES + // Step 5: Calculate max row lines using cached wrapped results + let maxRowLines = 1; + for (let i = 0; i < token.header.length; i++) { + maxRowLines = Math.max(maxRowLines, getWrappedLines(token.header[i]!.tokens, i).length); + } + for (const row of token.rows) { + for (let i = 0; i < row.length; i++) { + maxRowLines = Math.max(maxRowLines, getWrappedLines(row[i]?.tokens, i).length); + } + } + + const useVerticalFormat = maxRowLines > MAX_ROW_LINES; // Render a single row with potential multi-line cells // Returns an array of strings, one per line of the row - function renderRowLines( - cells: Array<{ tokens?: Token[] }>, - isHeader: boolean, - ): string[] { - // Get wrapped lines for each cell (preserving ANSI formatting) - const cellLines = cells.map((cell, colIndex) => { - const formattedText = formatCell(cell.tokens) - const width = columnWidths[colIndex]! - return wrapText(formattedText, width, { hard: needsHardWrap }) - }) + function renderRowLines(cells: Array<{ tokens?: Token[] }>, isHeader: boolean): string[] { + // Reuse cached wrapped lines — no redundant formatCell/wrapText + const cellLines = cells.map((cell, colIndex) => getWrappedLines(cell.tokens, colIndex)); // Find max number of lines in this row - const maxLines = Math.max(...cellLines.map(lines => lines.length), 1) + const maxLines = Math.max(...cellLines.map(lines => lines.length), 1); // Calculate vertical offset for each cell (to center vertically) - const verticalOffsets = cellLines.map(lines => - Math.floor((maxLines - lines.length) / 2), - ) + const verticalOffsets = cellLines.map(lines => Math.floor((maxLines - lines.length) / 2)); // Build each line of the row as a single string - const result: string[] = [] + const result: string[] = []; for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { - let line = '│' + let line = '│'; for (let colIndex = 0; colIndex < cells.length; colIndex++) { - const lines = cellLines[colIndex]! - const offset = verticalOffsets[colIndex]! - const contentLineIdx = lineIdx - offset - const lineText = - contentLineIdx >= 0 && contentLineIdx < lines.length - ? lines[contentLineIdx]! - : '' - const width = columnWidths[colIndex]! + const lines = cellLines[colIndex]!; + const offset = verticalOffsets[colIndex]!; + const contentLineIdx = lineIdx - offset; + const lineText = contentLineIdx >= 0 && contentLineIdx < lines.length ? lines[contentLineIdx]! : ''; + const width = columnWidths[colIndex]!; // Headers always centered; data uses table alignment - const align = isHeader ? 'center' : (token.align?.[colIndex] ?? 'left') + const align = isHeader ? 'center' : (token.align?.[colIndex] ?? 'left'); - line += - ' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │' + line += ' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │'; } - result.push(line) + result.push(line); } - return result + return result; } // Render horizontal border as a single string @@ -246,109 +236,103 @@ export function MarkdownTable({ top: ['┌', '─', '┬', '┐'], middle: ['├', '─', '┼', '┤'], bottom: ['└', '─', '┴', '┘'], - }[type] as [string, string, string, string] + }[type] as [string, string, string, string]; - let line = left + let line = left; columnWidths.forEach((width, colIndex) => { - line += mid.repeat(width + 2) - line += colIndex < columnWidths.length - 1 ? cross : right - }) - return line + line += mid.repeat(width + 2); + line += colIndex < columnWidths.length - 1 ? cross : right; + }); + return line; } // Render vertical format (key-value pairs) for extra-narrow terminals + // Uses formatCell cache; wrapping uses terminal-width params (not column widths) function renderVerticalFormat(): string { - const lines: string[] = [] - const headers = token.header.map(h => getPlainText(h.tokens)) - const separatorWidth = Math.min(terminalWidth - 1, 40) - const separator = '─'.repeat(separatorWidth) + const lines: string[] = []; + const headers = token.header.map(h => getPlainText(h.tokens)); + const separatorWidth = Math.min(terminalWidth - 1, 40); + const separator = '─'.repeat(separatorWidth); // Small indent for wrapped lines (just 2 spaces) - const wrapIndent = ' ' + const wrapIndent = ' '; token.rows.forEach((row, rowIndex) => { if (rowIndex > 0) { - lines.push(separator) + lines.push(separator); } row.forEach((cell, colIndex) => { - const label = headers[colIndex] || `Column ${colIndex + 1}` + const label = headers[colIndex] || `Column ${colIndex + 1}`; // Clean value: trim, remove extra internal whitespace/newlines - const rawValue = formatCell(cell.tokens).trimEnd() - const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() + const rawValue = formatCell(cell.tokens).trimEnd(); + const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim(); // Wrap value to fit terminal, accounting for label on first line - const firstLineWidth = terminalWidth - stringWidth(label) - 3 - const subsequentLineWidth = terminalWidth - wrapIndent.length - 1 + const firstLineWidth = terminalWidth - stringWidth(label) - 3; + const subsequentLineWidth = terminalWidth - wrapIndent.length - 1; // Two-pass wrap: first line is narrower (label takes space), // continuation lines get the full width minus indent. - const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10)) - const firstLine = firstPassLines[0] || '' + const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10)); + const firstLine = firstPassLines[0] || ''; - let wrappedValue: string[] - if ( - firstPassLines.length <= 1 || - subsequentLineWidth <= firstLineWidth - ) { - wrappedValue = firstPassLines + let wrappedValue: string[]; + if (firstPassLines.length <= 1 || subsequentLineWidth <= firstLineWidth) { + wrappedValue = firstPassLines; } else { // Re-join remaining text and re-wrap to the wider continuation width const remainingText = firstPassLines .slice(1) .map(l => l.trim()) - .join(' ') - const rewrapped = wrapText(remainingText, subsequentLineWidth) - wrappedValue = [firstLine, ...rewrapped] + .join(' '); + const rewrapped = wrapText(remainingText, subsequentLineWidth); + wrappedValue = [firstLine, ...rewrapped]; } // First line: bold label + value - lines.push( - `${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`, - ) + lines.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`); // Subsequent lines with small indent (skip empty lines) for (let i = 1; i < wrappedValue.length; i++) { - const line = wrappedValue[i]! - if (!line.trim()) continue - lines.push(`${wrapIndent}${line}`) + const line = wrappedValue[i]!; + if (!line.trim()) continue; + lines.push(`${wrapIndent}${line}`); } - }) - }) + }); + }); - return lines.join('\n') + return lines.join('\n'); } // Choose format based on available width if (useVerticalFormat) { - return {renderVerticalFormat()} + return {renderVerticalFormat()}; } // Build the complete horizontal table as an array of strings - const tableLines: string[] = [] - tableLines.push(renderBorderLine('top')) - tableLines.push(...renderRowLines(token.header, true)) - tableLines.push(renderBorderLine('middle')) + const tableLines: string[] = []; + tableLines.push(renderBorderLine('top')); + tableLines.push(...renderRowLines(token.header, true)); + tableLines.push(renderBorderLine('middle')); token.rows.forEach((row, rowIndex) => { - tableLines.push(...renderRowLines(row, false)) + tableLines.push(...renderRowLines(row, false)); if (rowIndex < token.rows.length - 1) { - tableLines.push(renderBorderLine('middle')) + tableLines.push(renderBorderLine('middle')); } - }) - tableLines.push(renderBorderLine('bottom')) + }); + tableLines.push(renderBorderLine('bottom')); // Safety check: verify no line exceeds terminal width. // This catches edge cases during terminal resize where calculations // were based on a different width than the current render target. - const maxLineWidth = Math.max( - ...tableLines.map(line => stringWidth(stripAnsi(line))), - ) + const maxLineWidth = Math.max(...tableLines.map(line => stringWidth(stripAnsi(line)))); // If we're within SAFETY_MARGIN characters of the edge, use vertical format // to account for terminal resize race conditions. if (maxLineWidth > terminalWidth - SAFETY_MARGIN) { - return {renderVerticalFormat()} + return {renderVerticalFormat()}; } // Render as a single Ansi block to prevent Ink from wrapping mid-row - return {tableLines.join('\n')} -} + return {tableLines.join('\n')}; +});