/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { Text, Box } from 'ink'; import { theme } from '../colors.js'; import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js'; const TERMINAL_MARGIN = 2; const MIN_COLUMN_WIDTH = 4; const WRAP_CACHE_LIMIT = 200; const wrapCache = new Map(); const wrapCacheOrder: string[] = []; interface MarkerDef { open: string; close: string; symmetric: boolean; } interface MarkerState { def: MarkerDef; } const MARKER_DEFS: MarkerDef[] = [ { open: '**', close: '**', symmetric: true }, { open: '__', close: '__', symmetric: true }, { open: '~~', close: '~~', symmetric: true }, { open: '*', close: '*', symmetric: true }, { open: '_', close: '_', symmetric: true }, { open: '', close: '', symmetric: false }, ]; const SORTED_MARKERS = [...MARKER_DEFS].sort( (a, b) => Math.max(b.open.length, b.close.length) - Math.max(a.open.length, a.close.length), ); const MARKER_BOUNDARY_CHARS = new Set(['*', '_', '~', '<', '/', '`']); const getOpeningSequence = (stack: MarkerState[]): string => stack.map(({ def }) => def.open).join(''); const getClosingSequence = (stack: MarkerState[]): string => { let closing = ''; for (let index = stack.length - 1; index >= 0; index -= 1) { closing += stack[index]?.def.close ?? ''; } return closing; }; const applyCloseMarker = (marker: MarkerDef, updated: MarkerState[]): void => { const matchIndex = [...updated] .reverse() .findIndex((entry) => entry.def === marker); if (matchIndex !== -1) { updated.splice(updated.length - matchIndex - 1); } }; const applyOpenMarker = (marker: MarkerDef, updated: MarkerState[]): void => { const isClosingSymmetric = marker.symmetric && updated.length > 0 && updated[updated.length - 1]?.def === marker; if (isClosingSymmetric) { updated.pop(); } else { updated.push({ def: marker }); } }; /** * Consumes a single marker (open or close) at the given pointer position, * mutating the marker stack. Returns the number of characters consumed, or * 0 when no marker matches at that position. */ const consumeMarkerAt = ( segment: string, pointer: number, updated: MarkerState[], ): number => { for (const marker of SORTED_MARKERS) { if (!marker.symmetric && segment.startsWith(marker.close, pointer)) { applyCloseMarker(marker, updated); return marker.close.length; } if (segment.startsWith(marker.open, pointer)) { applyOpenMarker(marker, updated); return marker.open.length; } } return 0; }; const updateMarkerStack = ( segment: string, stack: MarkerState[], ): MarkerState[] => { if (!segment) return [...stack]; const updated = [...stack]; let pointer = 0; while (pointer < segment.length) { const consumed = consumeMarkerAt(segment, pointer, updated); pointer += consumed > 0 ? consumed : 1; } return updated; }; const findBreakIndex = ( content: string, prefix: string, maxWidth: number, ): number => { let left = 0; let right = content.length; let best = 0; while (left <= right) { const mid = Math.floor((left + right) / 2); const candidate = prefix + content.slice(0, mid); if (getPlainTextLength(candidate) <= maxWidth) { best = mid; left = mid + 1; } else { right = mid - 1; } } return best; }; const adjustBreakIndex = (content: string, index: number): number => { if (index <= 0 || index > content.length) { return index; } const slice = content.slice(0, index); let whitespaceIndex = -1; for (let pointer = slice.length - 1; pointer >= 0; pointer -= 1) { if (/\s/u.test(slice[pointer] ?? '')) { whitespaceIndex = pointer; break; } } let adjusted = index; if (whitespaceIndex !== -1) { adjusted = whitespaceIndex + 1; } if (adjusted === 0) { adjusted = index; } while ( adjusted > 0 && MARKER_BOUNDARY_CHARS.has(content[adjusted - 1] ?? '') ) { adjusted -= 1; } return adjusted > 0 ? adjusted : index; }; const wrapCellContent = (content: string, maxWidth: number): string[] => { const cacheKey = `${maxWidth}::${content}`; const cached = wrapCache.get(cacheKey); if (cached) { return cached; } if (maxWidth <= 0) { rememberWrapResult(cacheKey, ['']); return ['']; } if (!content) { rememberWrapResult(cacheKey, ['']); return ['']; } if (getPlainTextLength(content) <= maxWidth) { rememberWrapResult(cacheKey, [content]); return [content]; } const lines: string[] = []; let remaining = content; let activeStack: MarkerState[] = []; let guard = 0; while (remaining.length > 0 && guard < 1000) { guard += 1; const prefix = getOpeningSequence(activeStack); let breakIndex = findBreakIndex(remaining, prefix, maxWidth); if (breakIndex <= 0) { const [firstChar] = [...remaining]; const charLength = firstChar ? firstChar.length : 1; const segment = remaining.slice(0, charLength); lines.push(prefix + segment + getClosingSequence(activeStack)); remaining = remaining.slice(charLength); continue; } if (breakIndex < remaining.length) { const adjusted = adjustBreakIndex(remaining, breakIndex); if (adjusted > 0) { breakIndex = adjusted; } } const segmentRaw = remaining.slice(0, breakIndex); remaining = remaining.slice(breakIndex).trimStart(); const segment = segmentRaw.trimEnd(); const updatedStack = updateMarkerStack(segment, activeStack); const closingSequence = getClosingSequence(updatedStack); if (segment.length > 0 || closingSequence.length > 0) { lines.push(prefix + segment + closingSequence); } activeStack = updatedStack; } const result = lines.length > 0 ? lines : ['']; rememberWrapResult(cacheKey, result); return result; }; const rememberWrapResult = (key: string, lines: string[]): void => { if (wrapCache.has(key)) { const existingIndex = wrapCacheOrder.indexOf(key); if (existingIndex !== -1) { wrapCacheOrder.splice(existingIndex, 1); } } wrapCache.set(key, lines); wrapCacheOrder.push(key); while (wrapCacheOrder.length > WRAP_CACHE_LIMIT) { const oldestKey = wrapCacheOrder.shift(); if (oldestKey) { wrapCache.delete(oldestKey); } } }; interface TableRendererProps { headers: string[]; rows: string[][]; terminalWidth: number; } function computeColumnWidths(headers: string[], rows: string[][]): number[] { return headers.map((header, index) => { const headerWidth = getPlainTextLength(header); const maxRowWidth = Math.max( ...rows.map((row) => getPlainTextLength(row[index] || '')), ); return Math.max(headerWidth, maxRowWidth) + 2; }); } function fitColumnsToTerminal( columnWidths: number[], terminalWidth: number, columnCount: number, ): number[] { const totalWidth = columnWidths.reduce((sum, width) => sum + width + 1, 1); const minTotalWidth = columnCount * MIN_COLUMN_WIDTH + columnCount + 1; const availableWidth = Math.max( Math.min(totalWidth, Math.max(terminalWidth - TERMINAL_MARGIN, 0)), minTotalWidth, ); const scaleFactor = totalWidth > availableWidth ? availableWidth / totalWidth : 1; const adjustedWidths = columnWidths.map((width) => Math.max(MIN_COLUMN_WIDTH, Math.floor(width * scaleFactor)), ); let adjustedTotal = adjustedWidths.reduce((sum, width) => sum + width + 1, 1); while ( adjustedTotal > availableWidth && adjustedWidths.some((width) => width > MIN_COLUMN_WIDTH) ) { let indexToTrim = -1; let largestWidth = MIN_COLUMN_WIDTH; adjustedWidths.forEach((width, index) => { if (width > largestWidth) { largestWidth = width; indexToTrim = index; } }); if (indexToTrim === -1) { break; } adjustedWidths[indexToTrim] -= 1; adjustedTotal -= 1; } return adjustedWidths; } const renderBorder = ( type: 'top' | 'middle' | 'bottom', adjustedWidths: number[], ): React.ReactNode => { const chars = { top: { left: '┌', middle: '┬', right: '┐', horizontal: '─' }, middle: { left: '├', middle: '┼', right: '┤', horizontal: '─' }, bottom: { left: '└', middle: '┴', right: '┘', horizontal: '─' }, }; const char = chars[type]; const borderParts = adjustedWidths.map((w) => char.horizontal.repeat(w)); const border = char.left + borderParts.join(char.middle) + char.right; return {border}; }; const renderRow = ( cells: string[], adjustedWidths: number[], isHeader = false, ): React.ReactNode => { const cellData = cells.map((cell, index) => { const width = adjustedWidths[index] ?? 0; const contentWidth = Math.max(0, width - 2); const cacheKey = `${contentWidth}::${cell || ''}`; const cachedLines = wrapCache.get(cacheKey); const lines = cachedLines ?? wrapCellContent(cell || '', contentWidth); return { lines, contentWidth }; }); const maxLines = Math.max(1, ...cellData.map((data) => data.lines.length)); return ( {Array.from({ length: maxLines }).map((_, lineIndex) => ( {lineIndex > 0 ? '\n' : null} {' '} {cellData.map((data, index) => { const lineContent = data.lines[lineIndex] ?? ''; const displayWidth = getPlainTextLength(lineContent); const padding = Math.max(0, data.contentWidth - displayWidth); const contentNode = isHeader ? ( ) : ( ); return ( {contentNode} {padding > 0 ? ' '.repeat(padding) : ''} {index < cellData.length - 1 ? ( <> {' '} {' '} ) : null} ); })}{' '} ))} ); }; /** * Custom table renderer for markdown tables * We implement our own instead of using ink-table due to module compatibility issues */ export const TableRenderer: React.FC = ({ headers, rows, terminalWidth, }) => { const columnWidths = computeColumnWidths(headers, rows); const adjustedWidths = fitColumnsToTerminal( columnWidths, terminalWidth, headers.length, ); return ( {/* Top border */} {renderBorder('top', adjustedWidths)} {/* Header row */} {renderRow(headers, adjustedWidths, true)} {/* Middle border */} {renderBorder('middle', adjustedWidths)} {/* Data rows */} {rows.map((row, index) => ( {renderRow(row, adjustedWidths)} ))} {/* Bottom border */} {renderBorder('bottom', adjustedWidths)} ); };