Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
143 changes: 143 additions & 0 deletions packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,149 @@ Some text before.
expect(lastFrame()).toMatchSnapshot();
});

it('renders a single-column table', () => {
const text = `
| Name |
|---|
| Alice |
| Bob |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('Name');
expect(output).toContain('Alice');
expect(output).toContain('Bob');
expect(output).toContain('┌');
expect(output).toContain('└');
expect(output).toMatchSnapshot();
});

it('renders a single-column table with center alignment', () => {
const text = `
| Name |
|:---:|
| Alice |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toContain('Alice');
expect(lastFrame()).toMatchSnapshot();
});

it('handles escaped pipes in table cells', () => {
const text = `
| Name | Value |
|---|---|
| A \\| B | C |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('A | B');
expect(output).toContain('C');
});

it('does not treat a lone table-like line as a table', () => {
const text = `
| just text |
next line
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('| just text |');
expect(output).not.toContain('┌');
});

it('does not treat invalid separator as a table separator', () => {
const text = `
| A | B |
| x | y |
| 1 | 2 |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('| A | B |');
expect(output).not.toContain('┌');
});

it('does not treat separator with mismatched column count as a table', () => {
const text = `
| A | B |
|---|
| 1 | 2 |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('| A | B |');
expect(output).not.toContain('┌');
});

it('does not treat a horizontal rule after a pipe line as a table separator', () => {
const text = `
| Header |
---
data
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
// `---` without any `|` is a horizontal rule, not a table separator
expect(output).toContain('| Header |');
expect(output).not.toContain('┌');
});

it('ends a table when a blank line appears', () => {
const text = `
| A | B |
|---|---|
| 1 | 2 |

After
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('┌');
expect(output).toContain('After');
});

it('does not treat separator-only text without header row as a table', () => {
const text = `
|---|---|
plain
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('|---|---|');
expect(output).not.toContain('┌');
});

it('does not crash on uneven escaped pipes near row edges', () => {
const text = `
| A | B |
|---|---|
| \\| edge | ok |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toContain('| edge');
});

it('inserts a single space between paragraphs', () => {
const text = `Paragraph 1.

Expand Down
60 changes: 49 additions & 11 deletions packages/cli/src/ui/utils/MarkdownDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../semantic-colors.js';
import { colorizeCode } from './CodeColorizer.js';
import { TableRenderer } from './TableRenderer.js';
import { TableRenderer, type ColumnAlign } from './TableRenderer.js';
import { RenderInline } from './InlineMarkdownRenderer.js';
import { useSettings } from '../contexts/SettingsContext.js';

Expand Down Expand Up @@ -43,7 +43,22 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/;
const hrRegex = /^ *([-*_] *){3,} *$/;
const tableRowRegex = /^\s*\|(.+)\|\s*$/;
const tableSeparatorRegex = /^\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)+\|?\s*$/;
const tableSeparatorRegex =
/^(?=.*\|)\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)*\|?\s*$/;

/** Parse column alignments from a markdown table separator like `|:---|:---:|---:|` */
const parseTableAligns = (line: string): ColumnAlign[] =>
line
.split(/(?<!\\)\|/)
.map((cell) => cell.trim())
.filter((cell) => cell.length > 0)
.map((cell) => {
const startsWithColon = cell.startsWith(':');
const endsWithColon = cell.endsWith(':');
if (startsWithColon && endsWithColon) return 'center';
if (endsWithColon) return 'right';
return 'left';
});

const contentBlocks: React.ReactNode[] = [];
let inCodeBlock = false;
Expand All @@ -54,6 +69,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
let inTable = false;
let tableRows: string[][] = [];
let tableHeaders: string[] = [];
let tableAligns: ColumnAlign[] = [];

function addContentBlock(block: React.ReactNode) {
if (block) {
Expand Down Expand Up @@ -105,13 +121,22 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
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)
) {
// Potential table start - check if next line is separator with matching column count
const potentialHeaders = tableRowMatch[1]
.split(/(?<!\\)\|/)
.map((cell) => cell.trim().replaceAll('\\|', '|'));
const nextLine = index + 1 < lines.length ? lines[index + 1]! : '';
const sepMatch = nextLine.match(tableSeparatorRegex);
const sepColCount = sepMatch
? nextLine
.split(/(?<!\\)\|/)
.map((c) => c.trim())
.filter((c) => c.length > 0).length
: 0;

if (sepMatch && sepColCount === potentialHeaders.length) {
inTable = true;
tableHeaders = tableRowMatch[1].split(/(?<!\\)\|/).map((cell) => cell.trim().replaceAll('\\|', '|'));
tableHeaders = potentialHeaders;
tableRows = [];
} else {
// Not a table, treat as regular text
Expand All @@ -124,10 +149,13 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
);
}
} else if (inTable && tableSeparatorMatch) {
// Skip separator line - already handled
// Parse alignment from separator line
tableAligns = parseTableAligns(line);
} else if (inTable && tableRowMatch) {
// Add table row
const cells = tableRowMatch[1].split(/(?<!\\)\|/).map((cell) => cell.trim().replaceAll('\\|', '|'));
const cells = tableRowMatch[1]
.split(/(?<!\\)\|/)
.map((cell) => cell.trim().replaceAll('\\|', '|'));
// Ensure row has same column count as headers
while (cells.length < tableHeaders.length) {
cells.push('');
Expand All @@ -145,12 +173,14 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
headers={tableHeaders}
rows={tableRows}
contentWidth={contentWidth}
aligns={tableAligns}
/>,
);
}
inTable = false;
tableRows = [];
tableHeaders = [];
tableAligns = [];

// Process current line as normal
if (line.trim().length > 0) {
Expand Down Expand Up @@ -279,6 +309,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
headers={tableHeaders}
rows={tableRows}
contentWidth={contentWidth}
aligns={tableAligns}
/>,
);
}
Expand Down Expand Up @@ -408,14 +439,21 @@ interface RenderTableProps {
headers: string[];
rows: string[][];
contentWidth: number;
aligns?: ColumnAlign[];
}

const RenderTableInternal: React.FC<RenderTableProps> = ({
headers,
rows,
contentWidth,
aligns,
}) => (
<TableRenderer headers={headers} rows={rows} contentWidth={contentWidth} />
<TableRenderer
headers={headers}
rows={rows}
contentWidth={contentWidth}
aligns={aligns}
/>
);

const RenderTable = React.memo(RenderTableInternal);
Expand Down
Loading
Loading