perf: 表格渲染效率升级

This commit is contained in:
claude-code-best 2026-05-03 10:10:47 +08:00 committed by James Feng
parent b7bbbeb039
commit b4629aa5bb

View File

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