diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
new file mode 100644
index 00000000..316c2d5d
--- /dev/null
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(lastFrame()).toContain('const x = 42;');
+ expect(lastFrame()).toContain('console.log(x);');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
index d78360b5..55f1ce57 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
@@ -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 = ({
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 = ({
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(
+
+
+
+
+ ,
+ );
+ }
+ } 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(
+ ,
+ );
+ }
+ inTable = false;
+ tableRows = [];
+ tableHeaders = [];
+
+ // Process current line as normal
+ if (line.trim().length > 0) {
+ contentBlocks.push(
+
+
+
+
+ ,
+ );
+ }
} else if (hrMatch) {
contentBlocks.push(
@@ -194,6 +260,18 @@ const MarkdownDisplayInternal: React.FC = ({
);
}
+ // Handle table at end of content
+ if (inTable && tableHeaders.length > 0 && tableRows.length > 0) {
+ contentBlocks.push(
+ ,
+ );
+ }
+
return <>{contentBlocks}>;
};
@@ -443,4 +521,20 @@ const RenderListItemInternal: React.FC = ({
const RenderListItem = React.memo(RenderListItemInternal);
+interface RenderTableProps {
+ headers: string[];
+ rows: string[][];
+ terminalWidth: number;
+}
+
+const RenderTableInternal: React.FC = ({
+ headers,
+ rows,
+ terminalWidth,
+}) => (
+
+);
+
+const RenderTable = React.memo(RenderTableInternal);
+
export const MarkdownDisplay = React.memo(MarkdownDisplayInternal);
diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx
new file mode 100644
index 00000000..745e5135
--- /dev/null
+++ b/packages/cli/src/ui/utils/TableRenderer.tsx
@@ -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 = ({
+ 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 (
+
+ {padded}
+
+ );
+ }
+ return {padded};
+ };
+
+ const renderRow = (cells: string[], isHeader = false) => (
+
+ │
+ {cells.map((cell, index) => (
+
+ {renderCell(cell, adjustedWidths[index] || 0, isHeader)}
+ │
+
+ ))}
+
+ );
+
+ const renderSeparator = () => {
+ const separator = adjustedWidths
+ .map((width) => '─'.repeat(Math.max(0, (width || 0) - 2)))
+ .join('─┼─');
+ return ├─{separator}─┤;
+ };
+
+ const renderTopBorder = () => {
+ const border = adjustedWidths
+ .map((width) => '─'.repeat(Math.max(0, (width || 0) - 2)))
+ .join('─┬─');
+ return ┌─{border}─┐;
+ };
+
+ const renderBottomBorder = () => {
+ const border = adjustedWidths
+ .map((width) => '─'.repeat(Math.max(0, (width || 0) - 2)))
+ .join('─┴─');
+ return └─{border}─┘;
+ };
+
+ return (
+
+ {renderTopBorder()}
+ {renderRow(headers, true)}
+ {renderSeparator()}
+ {rows.map((row, index) => (
+ {renderRow(row)}
+ ))}
+ {renderBottomBorder()}
+
+ );
+};