From 760dbc3e068d314d2f86ae1757f6815773a54513 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 25 Mar 2026 04:02:59 +0000 Subject: [PATCH] perf(ui): bypass ink object allocation in table renderer This replaces Ink's toStyledCharacters with a custom fast regex-based ANSI stripping and parsing approach in TableRenderer to significantly reduce V8 heap allocations during layout calculations of long or frequently updated text. A new StyleSpan abstraction handles the styled chunks efficiently. --- packages/cli/src/ui/utils/TableRenderer.tsx | 66 +++-- packages/cli/src/ui/utils/styleSpans.ts | 267 ++++++++++++++++++++ 2 files changed, 297 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/ui/utils/styleSpans.ts diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 6143571f6a..9b6a395757 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -5,20 +5,19 @@ */ import React, { useMemo } from 'react'; -import { styledCharsToString } from '@alcalzone/ansi-tokenize'; -import { - Text, - Box, - type StyledChar, - toStyledCharacters, - styledCharsWidth, - wordBreakStyledChars, - wrapStyledChars, - widestLineFromStyledChars, -} from 'ink'; +import { Text, Box } from 'ink'; import { theme } from '../semantic-colors.js'; import { parseMarkdownToANSI } from './markdownParsingUtils.js'; import { stripUnsafeCharacters } from './textUtils.js'; +import { + type StyleSpan, + parseToStyleSpans, + styleSpansWidth, + styleSpansToString, + wordBreakStyleSpans, + wrapStyleSpans, + widestLineFromStyleSpans, +} from './styleSpans.js'; interface TableRendererProps { headers: string[]; @@ -31,23 +30,21 @@ const COLUMN_PADDING = 2; const TABLE_MARGIN = 2; /** - * Parses markdown to StyledChar array by first converting to ANSI. - * This ensures character counts are accurate (markdown markers are removed - * and styles are applied to the character's internal style object). + * Parses markdown to StyleSpan array by first converting to ANSI. */ -const parseMarkdownToStyledChars = ( +const parseMarkdownToStyleSpans = ( text: string, defaultColor?: string, -): StyledChar[] => { +): StyleSpan[] => { const ansi = parseMarkdownToANSI(text, defaultColor); - return toStyledCharacters(ansi); + return parseToStyleSpans(ansi); }; -const calculateWidths = (styledChars: StyledChar[]) => { - const contentWidth = styledCharsWidth(styledChars); +const calculateWidths = (styleSpans: StyleSpan[]) => { + const contentWidth = styleSpansWidth(styleSpans); - const words: StyledChar[][] = wordBreakStyledChars(styledChars); - const maxWordWidth = widestLineFromStyledChars(words); + const words: StyleSpan[][] = wordBreakStyleSpans(styleSpans); + const maxWordWidth = widestLineFromStyleSpans(words); return { contentWidth, maxWordWidth }; }; @@ -70,7 +67,7 @@ export const TableRenderer: React.FC = ({ const styledHeaders = useMemo( () => headers.map((header) => - parseMarkdownToStyledChars( + parseMarkdownToStyleSpans( stripUnsafeCharacters(header), theme.text.link, ), @@ -82,7 +79,7 @@ export const TableRenderer: React.FC = ({ () => rows.map((row) => row.map((cell) => - parseMarkdownToStyledChars( + parseMarkdownToStyleSpans( stripUnsafeCharacters(cell), theme.text.primary, ), @@ -100,14 +97,14 @@ export const TableRenderer: React.FC = ({ // --- Define Constraints per Column --- const constraints = Array.from({ length: numColumns }).map( (_, colIndex) => { - const headerStyledChars = styledHeaders[colIndex] || []; + const headerStyleSpans = styledHeaders[colIndex] || []; let { contentWidth: maxContentWidth, maxWordWidth } = - calculateWidths(headerStyledChars); + calculateWidths(headerStyleSpans); styledRows.forEach((row) => { - const cellStyledChars = row[colIndex] || []; + const cellStyleSpans = row[colIndex] || []; const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } = - calculateWidths(cellStyledChars); + calculateWidths(cellStyleSpans); maxContentWidth = Math.max(maxContentWidth, cellWidth); maxWordWidth = Math.max(maxWordWidth, cellWordWidth); @@ -176,28 +173,25 @@ export const TableRenderer: React.FC = ({ // --- Pre-wrap and Optimize Widths --- const actualColumnWidths = new Array(numColumns).fill(0); - const wrapAndProcessRow = (row: StyledChar[][]) => { + const wrapAndProcessRow = (row: StyleSpan[][]) => { const rowResult: ProcessedLine[][] = []; // Ensure we iterate up to numColumns, filling with empty cells if needed for (let colIndex = 0; colIndex < numColumns; colIndex++) { - const cellStyledChars = row[colIndex] || []; + const cellStyleSpans = row[colIndex] || []; const allocatedWidth = finalContentWidths[colIndex]; const contentWidth = Math.max(1, allocatedWidth); - const wrappedStyledLines = wrapStyledChars( - cellStyledChars, - contentWidth, - ); + const wrappedStyledLines = wrapStyleSpans(cellStyleSpans, contentWidth); - const maxLineWidth = widestLineFromStyledChars(wrappedStyledLines); + const maxLineWidth = widestLineFromStyleSpans(wrappedStyledLines); actualColumnWidths[colIndex] = Math.max( actualColumnWidths[colIndex], maxLineWidth, ); const lines = wrappedStyledLines.map((line) => ({ - text: styledCharsToString(line), - width: styledCharsWidth(line), + text: styleSpansToString(line), + width: styleSpansWidth(line), })); rowResult.push(lines); } diff --git a/packages/cli/src/ui/utils/styleSpans.ts b/packages/cli/src/ui/utils/styleSpans.ts new file mode 100644 index 0000000000..3d72271090 --- /dev/null +++ b/packages/cli/src/ui/utils/styleSpans.ts @@ -0,0 +1,267 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import ansiRegex from 'ansi-regex'; +import { getCachedStringWidth } from './textUtils.js'; + +export interface StyleSpan { + text: string; + width: number; + ansiPrefix: string; + ansiSuffix: string; +} + +export const parseToStyleSpans = (text: string): StyleSpan[] => { + const spans: StyleSpan[] = []; + const regex = ansiRegex(); + let match; + let lastIndex = 0; + + let currentAnsiState: string[] = []; + + while ((match = regex.exec(text)) !== null) { + const ansiCode = match[0]; + const matchStart = match.index; + + if (matchStart > lastIndex) { + const chunk = text.slice(lastIndex, matchStart); + const ansiPrefix = currentAnsiState.join(''); + spans.push({ + text: chunk, + width: getCachedStringWidth(chunk), + ansiPrefix, + ansiSuffix: '\x1b[0m', + }); + } + + if ( + ansiCode === '\x1b[0m' || + ansiCode === '\x1b[39m' || + ansiCode === '\x1b[49m' + ) { + if (ansiCode === '\x1b[0m') { + currentAnsiState = []; + } else if (ansiCode === '\x1b[39m') { + currentAnsiState = currentAnsiState.filter( + // eslint-disable-next-line no-control-regex + (c) => !c.match(/\x1b\[3\d(?:;\d+)*m/), + ); + } else if (ansiCode === '\x1b[49m') { + currentAnsiState = currentAnsiState.filter( + // eslint-disable-next-line no-control-regex + (c) => !c.match(/\x1b\[4\d(?:;\d+)*m/), + ); + } else { + currentAnsiState.push(ansiCode); + } + } else { + currentAnsiState.push(ansiCode); + } + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + const chunk = text.slice(lastIndex); + if (chunk.length > 0) { + const ansiPrefix = currentAnsiState.join(''); + spans.push({ + text: chunk, + width: getCachedStringWidth(chunk), + ansiPrefix, + ansiSuffix: '\x1b[0m', + }); + } + } + + return spans; +}; +export const styleSpansToString = (spans: StyleSpan[]): string => { + let result = ''; + let currentAnsi = ''; + + for (const span of spans) { + if (span.ansiPrefix !== currentAnsi) { + if (currentAnsi !== '') { + result += '\x1b[0m'; + } + result += span.ansiPrefix; + currentAnsi = span.ansiPrefix; + } + result += span.text; + } + + if (currentAnsi !== '') { + result += '\x1b[0m'; + } + + return result; +}; +export const styleSpansWidth = (spans: StyleSpan[]): number => + spans.reduce((sum, span) => sum + span.width, 0); + +export const wordBreakStyleSpans = (spans: StyleSpan[]): StyleSpan[][] => { + const words: StyleSpan[][] = []; + let currentWord: StyleSpan[] = []; + + for (const span of spans) { + const text = span.text; + let i = 0; + while (i < text.length) { + if (text[i] === '\n' || text[i] === ' ') { + if (currentWord.length > 0) { + words.push(currentWord); + currentWord = []; + } + words.push([ + { + text: text[i], + width: getCachedStringWidth(text[i]), + ansiPrefix: span.ansiPrefix, + ansiSuffix: span.ansiSuffix, + }, + ]); + i++; + } else { + let j = i; + while (j < text.length && text[j] !== '\n' && text[j] !== ' ') { + j++; + } + const chunk = text.substring(i, j); + currentWord.push({ + text: chunk, + width: getCachedStringWidth(chunk), + ansiPrefix: span.ansiPrefix, + ansiSuffix: span.ansiSuffix, + }); + i = j; + } + } + } + + if (currentWord.length > 0) { + words.push(currentWord); + } + + return words; +}; + +export const widestLineFromStyleSpans = (lines: StyleSpan[][]): number => + lines.reduce((max, line) => Math.max(max, styleSpansWidth(line)), 0); + +export const wrapWord = ( + rowsRef: StyleSpan[][], + word: StyleSpan[], + columns: number, +) => { + let isFirstIteration = true; + + for (const span of word) { + let remainingText = span.text; + + while (remainingText.length > 0) { + let rowWidth = styleSpansWidth(rowsRef[rowsRef.length - 1]); + + if (!isFirstIteration && rowWidth >= columns && columns > 0) { + rowsRef.push([]); + rowWidth = 0; + } + isFirstIteration = false; + + let splitIndex = 0; + let chunkWidth = 0; + const chars = Array.from(remainingText); + + for (let i = 0; i < chars.length; i++) { + const charWidth = getCachedStringWidth(chars[i]); + if (rowWidth + chunkWidth + charWidth > columns && columns > 0) { + if (chunkWidth === 0 && rowWidth === 0) { + splitIndex = 1; + chunkWidth += charWidth; + } + break; + } + chunkWidth += charWidth; + splitIndex++; + } + + if (splitIndex === 0) { + rowsRef.push([]); + isFirstIteration = true; // reset to allow writing on next row + continue; + } + + const chunkText = chars.slice(0, splitIndex).join(''); + rowsRef[rowsRef.length - 1].push({ + text: chunkText, + width: chunkWidth, + ansiPrefix: span.ansiPrefix, + ansiSuffix: span.ansiSuffix, + }); + + remainingText = chars.slice(splitIndex).join(''); + } + } +}; + +export const wrapStyleSpans = ( + spans: StyleSpan[], + columns: number, +): StyleSpan[][] => { + const rows: StyleSpan[][] = [[]]; + const words = wordBreakStyleSpans(spans); + let isAtStartOfLogicalLine = true; + + for (const word of words) { + if (word.length === 0) continue; + + if (word[0].text === '\n') { + rows.push([]); + isAtStartOfLogicalLine = true; + continue; + } + + const wordWidth = styleSpansWidth(word); + const rowWidth = styleSpansWidth(rows[rows.length - 1]); + + if (rowWidth + wordWidth > columns) { + if ( + !isAtStartOfLogicalLine && + word[0].text === ' ' && + word.length === 1 + ) { + continue; + } + + if (!isAtStartOfLogicalLine) { + const lastRow = rows[rows.length - 1]; + while (lastRow.length > 0 && lastRow[lastRow.length - 1].text === ' ') { + lastRow.pop(); + } + } + + if (wordWidth > columns) { + if (rowWidth > 0) { + rows.push([]); + } + wrapWord(rows, word, columns); + } else { + rows.push([]); + rows[rows.length - 1].push(...word); + } + } else { + rows[rows.length - 1].push(...word); + } + + if ( + isAtStartOfLogicalLine && + !(word[0].text === ' ' && word.length === 1) + ) { + isAtStartOfLogicalLine = false; + } + } + + return rows; +};