feat: Add markdown table rendering support (#1955)

Co-authored-by: heartyguy <heartyguy@users.noreply.github.com>
Co-authored-by: Allen Hutchison <adh@google.com>
This commit is contained in:
Tian Jian Wang 2025-06-30 20:25:19 -07:00 committed by GitHub
parent 1a30b9656f
commit a4062cb44a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 488 additions and 0 deletions

View File

@ -0,0 +1,280 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { MarkdownDisplay } from './MarkdownDisplay.js';
describe('MarkdownDisplay', () => {
describe('Table Rendering', () => {
it('should render a simple table', () => {
const tableMarkdown = `
| Name | Age | City |
|------|-----|------|
| John | 25 | NYC |
| Jane | 30 | LA |
`;
const { lastFrame } = render(
<MarkdownDisplay
text={tableMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Name');
expect(lastFrame()).toContain('Age');
expect(lastFrame()).toContain('City');
expect(lastFrame()).toContain('John');
expect(lastFrame()).toContain('25');
expect(lastFrame()).toContain('NYC');
expect(lastFrame()).toContain('Jane');
expect(lastFrame()).toContain('30');
expect(lastFrame()).toContain('LA');
});
it('should handle tables with varying column widths', () => {
const tableMarkdown = `
| Short | Medium Column | Very Long Column Name |
|-------|---------------|----------------------|
| A | Some text | This is a longer text content |
| B | More content | Another piece of content here |
`;
const { lastFrame } = render(
<MarkdownDisplay
text={tableMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Short');
expect(lastFrame()).toContain('Medium Column');
expect(lastFrame()).toContain('Very Long Column Name');
});
it('should handle empty cells in tables', () => {
const tableMarkdown = `
| Col1 | Col2 | Col3 |
|------|------|------|
| A | | C |
| | B | |
`;
const { lastFrame } = render(
<MarkdownDisplay
text={tableMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Col1');
expect(lastFrame()).toContain('Col2');
expect(lastFrame()).toContain('Col3');
expect(lastFrame()).toContain('A');
expect(lastFrame()).toContain('B');
expect(lastFrame()).toContain('C');
});
it('should handle mixed content with tables', () => {
const mixedMarkdown = `
# Header
Some paragraph text before the table.
| Feature | Status | Notes |
|---------|--------|-------|
| Auth | Done | OAuth |
| API | WIP | REST |
Some text after the table.
`;
const { lastFrame } = render(
<MarkdownDisplay
text={mixedMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Header');
expect(lastFrame()).toContain('Some paragraph text before the table.');
expect(lastFrame()).toContain('Feature');
expect(lastFrame()).toContain('Status');
expect(lastFrame()).toContain('Auth');
expect(lastFrame()).toContain('Done');
expect(lastFrame()).toContain('Some text after the table.');
});
it('should handle tables with empty cells at edges', () => {
const tableMarkdown = `
| | Middle | |
|-|--------|-|
| | Value | |
`;
const { lastFrame } = render(
<MarkdownDisplay
text={tableMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Middle');
expect(lastFrame()).toContain('Value');
// Should maintain column structure even with empty edge cells
});
it('should handle PR reviewer test case 1', () => {
const tableMarkdown = `
| Package | Lines of Code |
|---------|---------------|
| CLI | 18407 |
| Core | 14445 |
`;
const { lastFrame } = render(
<MarkdownDisplay
text={tableMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
const output = lastFrame();
expect(output).toContain('Package');
expect(output).toContain('Lines of Code');
expect(output).toContain('CLI');
expect(output).toContain('18407');
expect(output).toContain('Core');
expect(output).toContain('14445');
});
it('should handle PR reviewer test case 2 - long table', () => {
const tableMarkdown = `
| Letter | Count |
|--------|-------|
| a | 15 |
| b | 2 |
| c | 26 |
| Total | 283 |
`;
const { lastFrame } = render(
<MarkdownDisplay
text={tableMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
const output = lastFrame();
expect(output).toContain('Letter');
expect(output).toContain('Count');
expect(output).toContain('a');
expect(output).toContain('15');
expect(output).toContain('Total');
expect(output).toContain('283');
});
it('should not render malformed tables', () => {
const malformedMarkdown = `
| This looks like a table |
But there's no separator line
| So it shouldn't render as table |
`;
const { lastFrame } = render(
<MarkdownDisplay
text={malformedMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
// Should render as regular text, not a table
expect(lastFrame()).toContain('| This looks like a table |');
expect(lastFrame()).toContain("But there's no separator line");
expect(lastFrame()).toContain("| So it shouldn't render as table |");
});
it('should not crash when rendering a very wide table in a narrow terminal', () => {
const wideTable = `
| Col 1 | Col 2 | Col 3 | Col 4 | Col 5 | Col 6 | Col 7 | Col 8 | Col 9 | Col 10 |
|-------|-------|-------|-------|-------|-------|-------|-------|-------|--------|
| ${'a'.repeat(20)} | ${'b'.repeat(20)} | ${'c'.repeat(20)} | ${'d'.repeat(
20,
)} | ${'e'.repeat(20)} | ${'f'.repeat(20)} | ${'g'.repeat(20)} | ${'h'.repeat(
20,
)} | ${'i'.repeat(20)} | ${'j'.repeat(20)} |
`;
const renderNarrow = () =>
render(
<MarkdownDisplay
text={wideTable}
isPending={false}
terminalWidth={40}
/>,
);
// The important part is that this does not throw an error.
expect(renderNarrow).not.toThrow();
// We can also check that it rendered *something*.
const { lastFrame } = renderNarrow();
expect(lastFrame()).not.toBe('');
});
});
describe('Existing Functionality', () => {
it('should render headers correctly', () => {
const headerMarkdown = `
# H1 Header
## H2 Header
### H3 Header
#### H4 Header
`;
const { lastFrame } = render(
<MarkdownDisplay
text={headerMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('H1 Header');
expect(lastFrame()).toContain('H2 Header');
expect(lastFrame()).toContain('H3 Header');
expect(lastFrame()).toContain('H4 Header');
});
it('should render code blocks correctly', () => {
const codeMarkdown = `
\`\`\`javascript
const x = 42;
console.log(x);
\`\`\`
`;
const { lastFrame } = render(
<MarkdownDisplay
text={codeMarkdown}
isPending={false}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('const x = 42;');
expect(lastFrame()).toContain('console.log(x);');
});
});
});

View File

@ -8,6 +8,7 @@ import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../colors.js';
import { colorizeCode } from './CodeColorizer.js';
import { TableRenderer } from './TableRenderer.js';
interface MarkdownDisplayProps {
text: string;
@ -43,12 +44,17 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/;
const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/;
const hrRegex = /^ *([-*_] *){3,} *$/;
const tableRowRegex = /^\s*\|(.+)\|\s*$/;
const tableSeparatorRegex = /^\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)+\|?\s*$/;
const contentBlocks: React.ReactNode[] = [];
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let codeBlockLang: string | null = null;
let codeBlockFence = '';
let inTable = false;
let tableRows: string[][] = [];
let tableHeaders: string[] = [];
lines.forEach((line, index) => {
const key = `line-${index}`;
@ -85,11 +91,71 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
const ulMatch = line.match(ulItemRegex);
const olMatch = line.match(olItemRegex);
const hrMatch = line.match(hrRegex);
const tableRowMatch = line.match(tableRowRegex);
const tableSeparatorMatch = line.match(tableSeparatorRegex);
if (codeFenceMatch) {
inCodeBlock = true;
codeBlockFence = codeFenceMatch[1];
codeBlockLang = codeFenceMatch[2] || null;
} else if (tableRowMatch && !inTable) {
// Potential table start - check if next line is separator
if (
index + 1 < lines.length &&
lines[index + 1].match(tableSeparatorRegex)
) {
inTable = true;
tableHeaders = tableRowMatch[1].split('|').map((cell) => cell.trim());
tableRows = [];
} else {
// Not a table, treat as regular text
contentBlocks.push(
<Box key={key}>
<Text wrap="wrap">
<RenderInline text={line} />
</Text>
</Box>,
);
}
} else if (inTable && tableSeparatorMatch) {
// Skip separator line - already handled
} else if (inTable && tableRowMatch) {
// Add table row
const cells = tableRowMatch[1].split('|').map((cell) => cell.trim());
// Ensure row has same column count as headers
while (cells.length < tableHeaders.length) {
cells.push('');
}
if (cells.length > tableHeaders.length) {
cells.length = tableHeaders.length;
}
tableRows.push(cells);
} else if (inTable && !tableRowMatch) {
// End of table
if (tableHeaders.length > 0 && tableRows.length > 0) {
contentBlocks.push(
<RenderTable
key={`table-${contentBlocks.length}`}
headers={tableHeaders}
rows={tableRows}
terminalWidth={terminalWidth}
/>,
);
}
inTable = false;
tableRows = [];
tableHeaders = [];
// Process current line as normal
if (line.trim().length > 0) {
contentBlocks.push(
<Box key={key}>
<Text wrap="wrap">
<RenderInline text={line} />
</Text>
</Box>,
);
}
} else if (hrMatch) {
contentBlocks.push(
<Box key={key}>
@ -194,6 +260,18 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
);
}
// Handle table at end of content
if (inTable && tableHeaders.length > 0 && tableRows.length > 0) {
contentBlocks.push(
<RenderTable
key={`table-${contentBlocks.length}`}
headers={tableHeaders}
rows={tableRows}
terminalWidth={terminalWidth}
/>,
);
}
return <>{contentBlocks}</>;
};
@ -443,4 +521,20 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
const RenderListItem = React.memo(RenderListItemInternal);
interface RenderTableProps {
headers: string[];
rows: string[][];
terminalWidth: number;
}
const RenderTableInternal: React.FC<RenderTableProps> = ({
headers,
rows,
terminalWidth,
}) => (
<TableRenderer headers={headers} rows={rows} terminalWidth={terminalWidth} />
);
const RenderTable = React.memo(RenderTableInternal);
export const MarkdownDisplay = React.memo(MarkdownDisplayInternal);

View File

@ -0,0 +1,114 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../colors.js';
interface TableRendererProps {
headers: string[];
rows: string[][];
terminalWidth: number;
}
/**
* Custom table renderer for markdown tables
* We implement our own instead of using ink-table due to module compatibility issues
*/
export const TableRenderer: React.FC<TableRendererProps> = ({
headers,
rows,
terminalWidth,
}) => {
// Calculate column widths
const columnWidths = headers.map((header, index) => {
const headerWidth = header.length;
const maxRowWidth = Math.max(
...rows.map((row) => (row[index] || '').length),
);
return Math.max(headerWidth, maxRowWidth) + 2; // Add padding
});
// Ensure table fits within terminal width
const totalWidth = columnWidths.reduce((sum, width) => sum + width + 1, 1);
const scaleFactor =
totalWidth > terminalWidth ? terminalWidth / totalWidth : 1;
const adjustedWidths = columnWidths.map((width) =>
Math.floor(width * scaleFactor),
);
const renderCell = (content: string, width: number, isHeader = false) => {
// The actual space for content inside the padding
const contentWidth = Math.max(0, width - 2);
let cellContent = content;
if (content.length > contentWidth) {
if (contentWidth <= 3) {
// Not enough space for '...'
cellContent = content.substring(0, contentWidth);
} else {
cellContent = content.substring(0, contentWidth - 3) + '...';
}
}
// Pad the content to fill the cell
const padded = cellContent.padEnd(contentWidth, ' ');
if (isHeader) {
return (
<Text bold color={Colors.AccentCyan}>
{padded}
</Text>
);
}
return <Text>{padded}</Text>;
};
const renderRow = (cells: string[], isHeader = false) => (
<Box flexDirection="row">
<Text> </Text>
{cells.map((cell, index) => (
<React.Fragment key={index}>
{renderCell(cell, adjustedWidths[index] || 0, isHeader)}
<Text> </Text>
</React.Fragment>
))}
</Box>
);
const renderSeparator = () => {
const separator = adjustedWidths
.map((width) => '─'.repeat(Math.max(0, (width || 0) - 2)))
.join('─┼─');
return <Text>{separator}</Text>;
};
const renderTopBorder = () => {
const border = adjustedWidths
.map((width) => '─'.repeat(Math.max(0, (width || 0) - 2)))
.join('─┬─');
return <Text>{border}</Text>;
};
const renderBottomBorder = () => {
const border = adjustedWidths
.map((width) => '─'.repeat(Math.max(0, (width || 0) - 2)))
.join('─┴─');
return <Text>{border}</Text>;
};
return (
<Box flexDirection="column" marginY={1}>
{renderTopBorder()}
{renderRow(headers, true)}
{renderSeparator()}
{rows.map((row, index) => (
<React.Fragment key={index}>{renderRow(row)}</React.Fragment>
))}
{renderBottomBorder()}
</Box>
);
};