mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 20:30:53 -07:00
feat(ux): added text wrapping capabilities to markdown tables (#18240)
Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
@@ -4,10 +4,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import {
|
||||
type StyledChar,
|
||||
toStyledCharacters,
|
||||
styledCharsToString,
|
||||
styledCharsWidth,
|
||||
wordBreakStyledChars,
|
||||
wrapStyledChars,
|
||||
widestLineFromStyledChars,
|
||||
} from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js';
|
||||
import { RenderInline } from './InlineMarkdownRenderer.js';
|
||||
|
||||
interface TableRendererProps {
|
||||
headers: string[];
|
||||
@@ -15,6 +24,26 @@ interface TableRendererProps {
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
const MIN_COLUMN_WIDTH = 5;
|
||||
const COLUMN_PADDING = 2;
|
||||
const TABLE_MARGIN = 2;
|
||||
|
||||
const calculateWidths = (text: string) => {
|
||||
const styledChars = toStyledCharacters(text);
|
||||
const contentWidth = styledCharsWidth(styledChars);
|
||||
|
||||
const words: StyledChar[][] = wordBreakStyledChars(styledChars);
|
||||
const maxWordWidth = widestLineFromStyledChars(words);
|
||||
|
||||
return { contentWidth, maxWordWidth };
|
||||
};
|
||||
|
||||
// Used to reduce redundant parsing and cache the widths for each line
|
||||
interface ProcessedLine {
|
||||
text: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom table renderer for markdown tables
|
||||
* We implement our own instead of using ink-table due to module compatibility issues
|
||||
@@ -24,89 +53,146 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
rows,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
// Calculate column widths using actual display width after markdown processing
|
||||
const columnWidths = headers.map((header, index) => {
|
||||
const headerWidth = getPlainTextLength(header);
|
||||
const maxRowWidth = Math.max(
|
||||
...rows.map((row) => getPlainTextLength(row[index] || '')),
|
||||
// Clean headers: remove bold markers since we already render headers as bold
|
||||
// and having them can break wrapping when the markers are split across lines.
|
||||
const cleanedHeaders = useMemo(
|
||||
() => headers.map((header) => header.replace(/\*\*(.*?)\*\*/g, '$1')),
|
||||
[headers],
|
||||
);
|
||||
|
||||
const { wrappedHeaders, wrappedRows, adjustedWidths } = useMemo(() => {
|
||||
// --- Define Constraints per Column ---
|
||||
const constraints = cleanedHeaders.map((header, colIndex) => {
|
||||
let { contentWidth: maxContentWidth, maxWordWidth } =
|
||||
calculateWidths(header);
|
||||
|
||||
rows.forEach((row) => {
|
||||
const cell = row[colIndex] || '';
|
||||
const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } =
|
||||
calculateWidths(cell);
|
||||
|
||||
maxContentWidth = Math.max(maxContentWidth, cellWidth);
|
||||
maxWordWidth = Math.max(maxWordWidth, cellWordWidth);
|
||||
});
|
||||
|
||||
const minWidth = maxWordWidth;
|
||||
const maxWidth = Math.max(minWidth, maxContentWidth);
|
||||
|
||||
return { minWidth, maxWidth };
|
||||
});
|
||||
|
||||
// --- Calculate Available Space ---
|
||||
// Fixed overhead: borders (n+1) + padding (2n)
|
||||
const fixedOverhead =
|
||||
cleanedHeaders.length + 1 + cleanedHeaders.length * COLUMN_PADDING;
|
||||
const availableWidth = Math.max(
|
||||
0,
|
||||
terminalWidth - fixedOverhead - TABLE_MARGIN,
|
||||
);
|
||||
return Math.max(headerWidth, maxRowWidth) + 2; // Add padding
|
||||
});
|
||||
|
||||
// Ensure table fits within terminal width
|
||||
// We calculate scale based on content width vs available width (terminal - borders)
|
||||
// First, extract content widths by removing the 2-char padding.
|
||||
const contentWidths = columnWidths.map((width) => Math.max(0, width - 2));
|
||||
const totalContentWidth = contentWidths.reduce(
|
||||
(sum, width) => sum + width,
|
||||
0,
|
||||
);
|
||||
// --- Allocation Algorithm ---
|
||||
const totalMinWidth = constraints.reduce((sum, c) => sum + c.minWidth, 0);
|
||||
let finalContentWidths: number[];
|
||||
|
||||
// Fixed overhead includes padding (2 per column) and separators (1 per column + 1 final).
|
||||
const fixedOverhead = headers.length * 2 + (headers.length + 1);
|
||||
if (totalMinWidth > availableWidth) {
|
||||
// We must scale all the columns except the ones that are very short(<=5 characters)
|
||||
const shortColumns = constraints.filter(
|
||||
(c) => c.maxWidth <= MIN_COLUMN_WIDTH,
|
||||
);
|
||||
const totalShortColumnWidth = shortColumns.reduce(
|
||||
(sum, c) => sum + c.minWidth,
|
||||
0,
|
||||
);
|
||||
|
||||
// Subtract 1 from available width to avoid edge-case wrapping on some terminals
|
||||
const availableWidth = Math.max(0, terminalWidth - fixedOverhead - 1);
|
||||
const finalTotalShortColumnWidth =
|
||||
totalShortColumnWidth >= availableWidth ? 0 : totalShortColumnWidth;
|
||||
|
||||
const scaleFactor =
|
||||
totalContentWidth > availableWidth ? availableWidth / totalContentWidth : 1;
|
||||
const adjustedWidths = contentWidths.map(
|
||||
(width) => Math.floor(width * scaleFactor) + 2,
|
||||
);
|
||||
|
||||
// Helper function to render a cell with proper width
|
||||
const renderCell = (
|
||||
content: string,
|
||||
width: number,
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const contentWidth = Math.max(0, width - 2);
|
||||
const displayWidth = getPlainTextLength(content);
|
||||
|
||||
let cellContent = content;
|
||||
if (displayWidth > contentWidth) {
|
||||
if (contentWidth <= 3) {
|
||||
// Just truncate by character count
|
||||
cellContent = content.substring(
|
||||
0,
|
||||
Math.min(content.length, contentWidth),
|
||||
);
|
||||
} else {
|
||||
// Truncate preserving markdown formatting using binary search
|
||||
let left = 0;
|
||||
let right = content.length;
|
||||
let bestTruncated = content;
|
||||
|
||||
// Binary search to find the optimal truncation point
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const candidate = content.substring(0, mid);
|
||||
const candidateWidth = getPlainTextLength(candidate);
|
||||
|
||||
if (candidateWidth <= contentWidth - 1) {
|
||||
bestTruncated = candidate;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
const scale =
|
||||
(availableWidth - finalTotalShortColumnWidth) /
|
||||
(totalMinWidth - finalTotalShortColumnWidth);
|
||||
finalContentWidths = constraints.map((c) => {
|
||||
if (c.maxWidth <= MIN_COLUMN_WIDTH && finalTotalShortColumnWidth > 0) {
|
||||
return c.minWidth;
|
||||
}
|
||||
return Math.floor(c.minWidth * scale);
|
||||
});
|
||||
} else {
|
||||
const surplus = availableWidth - totalMinWidth;
|
||||
const totalGrowthNeed = constraints.reduce(
|
||||
(sum, c) => sum + (c.maxWidth - c.minWidth),
|
||||
0,
|
||||
);
|
||||
|
||||
cellContent = bestTruncated + '…';
|
||||
if (totalGrowthNeed === 0) {
|
||||
finalContentWidths = constraints.map((c) => c.minWidth);
|
||||
} else {
|
||||
finalContentWidths = constraints.map((c) => {
|
||||
const growthNeed = c.maxWidth - c.minWidth;
|
||||
const share = growthNeed / totalGrowthNeed;
|
||||
const extra = Math.floor(surplus * share);
|
||||
return Math.min(c.maxWidth, c.minWidth + extra);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate exact padding needed
|
||||
const actualDisplayWidth = getPlainTextLength(cellContent);
|
||||
const paddingNeeded = Math.max(0, contentWidth - actualDisplayWidth);
|
||||
// --- Pre-wrap and Optimize Widths ---
|
||||
const actualColumnWidths = new Array(cleanedHeaders.length).fill(0);
|
||||
|
||||
const wrapAndProcessRow = (row: string[]) => {
|
||||
const rowResult: ProcessedLine[][] = [];
|
||||
row.forEach((cell, colIndex) => {
|
||||
const allocatedWidth = finalContentWidths[colIndex];
|
||||
const contentWidth = Math.max(1, allocatedWidth);
|
||||
|
||||
const contentStyledChars = toStyledCharacters(cell);
|
||||
const wrappedStyledLines = wrapStyledChars(
|
||||
contentStyledChars,
|
||||
contentWidth,
|
||||
);
|
||||
|
||||
const maxLineWidth = widestLineFromStyledChars(wrappedStyledLines);
|
||||
actualColumnWidths[colIndex] = Math.max(
|
||||
actualColumnWidths[colIndex],
|
||||
maxLineWidth,
|
||||
);
|
||||
|
||||
const lines = wrappedStyledLines.map((line) => ({
|
||||
text: styledCharsToString(line),
|
||||
width: styledCharsWidth(line),
|
||||
}));
|
||||
rowResult.push(lines);
|
||||
});
|
||||
return rowResult;
|
||||
};
|
||||
|
||||
const wrappedHeaders = wrapAndProcessRow(cleanedHeaders);
|
||||
const wrappedRows = rows.map((row) => wrapAndProcessRow(row));
|
||||
|
||||
// Use the TIGHTEST widths that fit the wrapped content + padding
|
||||
const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING);
|
||||
|
||||
return { wrappedHeaders, wrappedRows, adjustedWidths };
|
||||
}, [cleanedHeaders, rows, terminalWidth]);
|
||||
|
||||
// Helper function to render a cell with proper width
|
||||
const renderCell = (
|
||||
content: ProcessedLine,
|
||||
width: number,
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const contentWidth = Math.max(0, width - COLUMN_PADDING);
|
||||
// Use pre-calculated width to avoid re-parsing
|
||||
const displayWidth = content.width;
|
||||
const paddingNeeded = Math.max(0, contentWidth - displayWidth);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{isHeader ? (
|
||||
<Text bold color={theme.text.link}>
|
||||
<RenderInline text={cellContent} />
|
||||
<RenderInline text={content.text} />
|
||||
</Text>
|
||||
) : (
|
||||
<RenderInline text={cellContent} />
|
||||
<RenderInline text={content.text} />
|
||||
)}
|
||||
{' '.repeat(paddingNeeded)}
|
||||
</Text>
|
||||
@@ -128,11 +214,14 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
return <Text color={theme.border.default}>{border}</Text>;
|
||||
};
|
||||
|
||||
// Helper function to render a table row
|
||||
const renderRow = (cells: string[], isHeader = false): React.ReactNode => {
|
||||
// Helper function to render a single visual line of a row
|
||||
const renderVisualRow = (
|
||||
cells: ProcessedLine[],
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const renderedCells = cells.map((cell, index) => {
|
||||
const width = adjustedWidths[index] || 0;
|
||||
return renderCell(cell || '', width, isHeader);
|
||||
return renderCell(cell, width, isHeader);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -151,21 +240,46 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Handles the wrapping logic for a logical data row
|
||||
const renderDataRow = (
|
||||
wrappedCells: ProcessedLine[][],
|
||||
rowIndex?: number,
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const key = isHeader ? 'header' : `${rowIndex}`;
|
||||
const maxHeight = Math.max(...wrappedCells.map((lines) => lines.length), 1);
|
||||
|
||||
const visualRows: React.ReactNode[] = [];
|
||||
for (let i = 0; i < maxHeight; i++) {
|
||||
const visualRowCells = wrappedCells.map(
|
||||
(lines) => lines[i] || { text: '', width: 0 },
|
||||
);
|
||||
visualRows.push(
|
||||
<React.Fragment key={`${key}-${i}`}>
|
||||
{renderVisualRow(visualRowCells, isHeader)}
|
||||
</React.Fragment>,
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment key={rowIndex}>{visualRows}</React.Fragment>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{/* Top border */}
|
||||
{renderBorder('top')}
|
||||
|
||||
{/* Header row */}
|
||||
{renderRow(headers, true)}
|
||||
{/*
|
||||
Header row
|
||||
Keep the rowIndex as -1 to differentiate from data rows
|
||||
*/}
|
||||
{renderDataRow(wrappedHeaders, -1, true)}
|
||||
|
||||
{/* Middle border */}
|
||||
{renderBorder('middle')}
|
||||
|
||||
{/* Data rows */}
|
||||
{rows.map((row, index) => (
|
||||
<React.Fragment key={index}>{renderRow(row)}</React.Fragment>
|
||||
))}
|
||||
{wrappedRows.map((row, index) => renderDataRow(row, index))}
|
||||
|
||||
{/* Bottom border */}
|
||||
{renderBorder('bottom')}
|
||||
|
||||
Reference in New Issue
Block a user