mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-15 22:07:29 -07:00
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.
This commit is contained in:
@@ -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<TableRendererProps> = ({
|
||||
const styledHeaders = useMemo(
|
||||
() =>
|
||||
headers.map((header) =>
|
||||
parseMarkdownToStyledChars(
|
||||
parseMarkdownToStyleSpans(
|
||||
stripUnsafeCharacters(header),
|
||||
theme.text.link,
|
||||
),
|
||||
@@ -82,7 +79,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
() =>
|
||||
rows.map((row) =>
|
||||
row.map((cell) =>
|
||||
parseMarkdownToStyledChars(
|
||||
parseMarkdownToStyleSpans(
|
||||
stripUnsafeCharacters(cell),
|
||||
theme.text.primary,
|
||||
),
|
||||
@@ -100,14 +97,14 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
// --- 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<TableRendererProps> = ({
|
||||
// --- 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user