Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/cli/src/ui/utils/TableRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,95 @@ describe('TableRenderer', () => {
expect(output).toContain('Data 3.4');
expect(output).toMatchSnapshot();
});

it('wraps long cell content correctly', () => {
const headers = ['Col 1', 'Col 2'];
const rows = [
[
'Short',
'This is a very long cell content that should wrap to multiple lines',
],
];
const terminalWidth = 40;

const { lastFrame } = renderWithProviders(
<TableRenderer
headers={headers}
rows={rows}
terminalWidth={terminalWidth}
/>,
);

const output = lastFrame();
expect(output).toContain('This is a very');
expect(output).toContain('long cell');
expect(output).toMatchSnapshot();
});

it('wraps all long columns correctly', () => {
const headers = ['Col 1', 'Col 2'];
const rows = [
[
'This is a very long text that needs wrapping in column 1',
'This is also a very long text that needs wrapping in column 2',
],
];
const terminalWidth = 50;

const { lastFrame } = renderWithProviders(
<TableRenderer
headers={headers}
rows={rows}
terminalWidth={terminalWidth}
/>,
);

const output = lastFrame();
expect(output).toContain('wrapping in');
expect(output).toMatchSnapshot();
});

it('wraps mixed long and short columns correctly', () => {
const headers = ['Short', 'Long'];
const rows = [
[
'Tiny',
'This is a very long text that definitely needs to wrap to the next line',
],
];
const terminalWidth = 40;

const { lastFrame } = renderWithProviders(
<TableRenderer
headers={headers}
rows={rows}
terminalWidth={terminalWidth}
/>,
);

const output = lastFrame();
expect(output).toContain('Tiny');
expect(output).toContain('definitely needs');
expect(output).toMatchSnapshot();
});

it('wraps columns with punctuation correctly', () => {
const headers = ['Punctuation'];
const rows = [
['Start. Stop. Comma, separated. Exclamation! Question? hyphen-ated'],
];
const terminalWidth = 30;

const { lastFrame } = renderWithProviders(
<TableRenderer
headers={headers}
rows={rows}
terminalWidth={terminalWidth}
/>,
);

const output = lastFrame();
expect(output).toContain('Start. Stop.');
expect(output).toMatchSnapshot();
});
});
199 changes: 133 additions & 66 deletions packages/cli/src/ui/utils/TableRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import React from 'react';
import { Text, Box } from 'ink';
import wrapAnsi from 'wrap-ansi';
import { theme } from '../semantic-colors.js';
import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js';

Expand All @@ -24,35 +25,76 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
rows,
terminalWidth,
}) => {
// Calculate column widths using actual display width after markdown processing
const columnWidths = headers.map((header, index) => {
// --- Step 1: Define Constraints per Column ---
const constraints = headers.map((header, colIndex) => {
const headerWidth = getPlainTextLength(header);
const maxRowWidth = Math.max(
...rows.map((row) => getPlainTextLength(row[index] || '')),
);
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,
);
// Calculate max content width and max word width for this column
let maxContentWidth = headerWidth;
let maxWordWidth = 0;

// Fixed overhead includes padding (2 per column) and separators (1 per column + 1 final).
const fixedOverhead = headers.length * 2 + (headers.length + 1);
rows.forEach((row) => {
const cell = row[colIndex] || '';
const cellWidth = getPlainTextLength(cell);
maxContentWidth = Math.max(maxContentWidth, cellWidth);

// Subtract 1 from available width to avoid edge-case wrapping on some terminals
// Find longest word to ensure it fits without splitting
const words = cell.split(/\s+/);
for (const word of words) {
const wordWidth = getPlainTextLength(word);
maxWordWidth = Math.max(maxWordWidth, wordWidth);
}
});

// min: used to guarantee minimum column width and prevent wrapping mid-word
// Defaults to header width or max word width
const min = Math.max(headerWidth, maxWordWidth);

// max: used to determine how much the column can grow if space allows
// Ensure max is never smaller than min
const max = Math.max(min, maxContentWidth);

return { min, max };
});

// --- Step 2: Calculate Available Space ---
// Fixed overhead: borders (n+1) + padding (2n)
const fixedOverhead = headers.length + 1 + headers.length * 2;
const availableWidth = Math.max(0, terminalWidth - fixedOverhead - 1);

const scaleFactor =
totalContentWidth > availableWidth ? availableWidth / totalContentWidth : 1;
const adjustedWidths = contentWidths.map(
(width) => Math.floor(width * scaleFactor) + 2,
);
// --- Step 3: Allocation Algorithm ---
const totalMinWidth = constraints.reduce((sum, c) => sum + c.min, 0);
let finalContentWidths: number[];

if (totalMinWidth > availableWidth) {
// Case A: Not enough space even for minimums.
// We must scale everything down proportionally.
const scale = availableWidth / totalMinWidth;
finalContentWidths = constraints.map((c) => Math.floor(c.min * scale));
} else {
// Case B: We have space! Distribute the surplus.
const surplus = availableWidth - totalMinWidth;
const totalGrowthNeed = constraints.reduce(
(sum, c) => sum + (c.max - c.min),
0,
);

if (totalGrowthNeed === 0) {
// If nobody wants to grow, simply give everyone their min.
finalContentWidths = constraints.map((c) => c.min);
} else {
finalContentWidths = constraints.map((c) => {
const growthNeed = c.max - c.min;
// Calculate share: (My Need / Total Need) * Surplus
const share = growthNeed / totalGrowthNeed;
const extra = Math.floor(surplus * share);
return c.min + extra;
});
}
}

// Add padding (+2) to get the visual widths expected by the renderers
const adjustedWidths = finalContentWidths.map((w) => w + 2);

// Helper function to render a cell with proper width
const renderCell = (
Expand All @@ -63,50 +105,17 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
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;
}
}

cellContent = bestTruncated + '…';
}
}

// Calculate exact padding needed
const actualDisplayWidth = getPlainTextLength(cellContent);
const paddingNeeded = Math.max(0, contentWidth - actualDisplayWidth);
const paddingNeeded = Math.max(0, contentWidth - displayWidth);

return (
<Text>
{isHeader ? (
<Text bold color={theme.text.link}>
<RenderInline text={cellContent} />
<RenderInline text={content} />
</Text>
) : (
<RenderInline text={cellContent} />
<RenderInline text={content} />
)}
{' '.repeat(paddingNeeded)}
</Text>
Expand All @@ -128,8 +137,11 @@ 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: string[],
isHeader = false,
): React.ReactNode => {
const renderedCells = cells.map((cell, index) => {
const width = adjustedWidths[index] || 0;
return renderCell(cell || '', width, isHeader);
Expand All @@ -151,21 +163,76 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
);
};

// Handles the wrapping logic for a logical data row
const renderDataRow = (
row: string[],
rowIndex: number,
isHeader = false,
): React.ReactNode => {
const wrappedCells = row.map((cell, colIndex) => {
// Get the calculated width for THIS column
const colWidth = adjustedWidths[colIndex];
const contentWidth = Math.max(1, colWidth - 2); // Subtract padding

// 1. Try soft wrap first (respects word boundaries)
// wrap-ansi with hard:false will put long words on their own line but won't split them,
// potentially exceeding contentWidth.
const softWrapped = wrapAnsi(cell, contentWidth, {
hard: false,
trim: true,
});

// 2. Post-process to handle "Giant Words" that overflow
const rawLines = softWrapped.split('\n');
const finalLines: string[] = [];

for (const line of rawLines) {
if (getPlainTextLength(line) > contentWidth) {
// It's too long (a single giant word or URL). Force break it.
const forced = wrapAnsi(line, contentWidth, {
hard: true,
trim: true,
});
finalLines.push(...forced.split('\n'));
} else {
finalLines.push(line);
}
}

return finalLines;
});

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] || '');
visualRows.push(
<React.Fragment key={`${rowIndex}-${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(headers, -1, true)}

{/* Middle border */}
{renderBorder('middle')}

{/* Data rows */}
{rows.map((row, index) => (
<React.Fragment key={index}>{renderRow(row)}</React.Fragment>
))}
{rows.map((row, index) => renderDataRow(row, index))}

{/* Bottom border */}
{renderBorder('bottom')}
Expand Down
Loading
Loading