diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
index 7f2e8f1d9a..fc788e18c7 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(lastFrame()).toContain('| edge');
+ });
+
it('inserts a single space between paragraphs', () => {
const text = `Paragraph 1.
diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
index 642b04d6b9..3c0edcc0e7 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
@@ -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';
@@ -43,7 +43,22 @@ const MarkdownDisplayInternal: React.FC = ({
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(/(? 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;
@@ -54,6 +69,7 @@ const MarkdownDisplayInternal: React.FC = ({
let inTable = false;
let tableRows: string[][] = [];
let tableHeaders: string[] = [];
+ let tableAligns: ColumnAlign[] = [];
function addContentBlock(block: React.ReactNode) {
if (block) {
@@ -105,13 +121,22 @@ const MarkdownDisplayInternal: React.FC = ({
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(/(? cell.trim().replaceAll('\\|', '|'));
+ const nextLine = index + 1 < lines.length ? lines[index + 1]! : '';
+ const sepMatch = nextLine.match(tableSeparatorRegex);
+ const sepColCount = sepMatch
+ ? nextLine
+ .split(/(? c.trim())
+ .filter((c) => c.length > 0).length
+ : 0;
+
+ if (sepMatch && sepColCount === potentialHeaders.length) {
inTable = true;
- tableHeaders = tableRowMatch[1].split(/(? cell.trim().replaceAll('\\|', '|'));
+ tableHeaders = potentialHeaders;
tableRows = [];
} else {
// Not a table, treat as regular text
@@ -124,10 +149,13 @@ const MarkdownDisplayInternal: React.FC = ({
);
}
} 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(/(? cell.trim().replaceAll('\\|', '|'));
+ const cells = tableRowMatch[1]
+ .split(/(? cell.trim().replaceAll('\\|', '|'));
// Ensure row has same column count as headers
while (cells.length < tableHeaders.length) {
cells.push('');
@@ -145,12 +173,14 @@ const MarkdownDisplayInternal: React.FC = ({
headers={tableHeaders}
rows={tableRows}
contentWidth={contentWidth}
+ aligns={tableAligns}
/>,
);
}
inTable = false;
tableRows = [];
tableHeaders = [];
+ tableAligns = [];
// Process current line as normal
if (line.trim().length > 0) {
@@ -279,6 +309,7 @@ const MarkdownDisplayInternal: React.FC = ({
headers={tableHeaders}
rows={tableRows}
contentWidth={contentWidth}
+ aligns={tableAligns}
/>,
);
}
@@ -408,14 +439,21 @@ interface RenderTableProps {
headers: string[];
rows: string[][];
contentWidth: number;
+ aligns?: ColumnAlign[];
}
const RenderTableInternal: React.FC = ({
headers,
rows,
contentWidth,
+ aligns,
}) => (
-
+
);
const RenderTable = React.memo(RenderTableInternal);
diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx
new file mode 100644
index 0000000000..51bc466d8b
--- /dev/null
+++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx
@@ -0,0 +1,486 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import stripAnsi from 'strip-ansi';
+import stringWidth from 'string-width';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { TableRenderer, type ColumnAlign } from './TableRenderer.js';
+
+describe('', () => {
+ const renderTable = (
+ headers: string[],
+ rows: string[][],
+ contentWidth = 80,
+ aligns?: ColumnAlign[],
+ ) => {
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+ return lastFrame() ?? '';
+ };
+
+ const getVisibleLines = (output: string) =>
+ output
+ .split('\n')
+ .map((line) => line.replace(/\r/g, ''))
+ .filter((line) => line.length > 0);
+
+ const expectAllLinesToHaveSameVisibleWidth = (output: string) => {
+ const lines = getVisibleLines(output);
+ expect(lines.length).toBeGreaterThan(0);
+ const widths = lines.map((line) => stringWidth(stripAnsi(line)));
+ expect(new Set(widths).size).toBe(1);
+ };
+
+ it('renders a basic table with borders', () => {
+ const output = renderTable(['Name', 'Value'], [['foo', 'bar']]);
+
+ expect(output).toContain('Name');
+ expect(output).toContain('Value');
+ expect(output).toContain('foo');
+ expect(output).toContain('bar');
+ // Should have border characters
+ expect(output).toContain('┌');
+ expect(output).toContain('┐');
+ expect(output).toContain('└');
+ expect(output).toContain('┘');
+ expect(output).toContain('│');
+ expectAllLinesToHaveSameVisibleWidth(output);
+ });
+
+ it('keeps all rendered lines at the same visible width for mixed content', () => {
+ const output = renderTable(
+ ['项目', 'ANSI', 'Markdown'],
+ [['中文内容', '\u001b[31mRed\u001b[0m Blue', '**bold** and `code`']],
+ 42,
+ ['left', 'center', 'right'],
+ );
+ expectAllLinesToHaveSameVisibleWidth(output);
+ });
+
+ it('handles CJK characters with correct column alignment', () => {
+ const output = renderTable(
+ ['项目', '描述'],
+ [['名称', '这是一个很长的描述']],
+ );
+
+ expect(output).toContain('项目');
+ expect(output).toContain('描述');
+ expect(output).toContain('名称');
+ expect(output).toContain('这是一个很长的描述');
+ });
+
+ it('handles mixed CJK and ASCII content', () => {
+ const output = renderTable(
+ ['Feature', '功能'],
+ [
+ ['Speed', '速度很快'],
+ ['Quality', '质量很高'],
+ ],
+ );
+
+ expect(output).toContain('Feature');
+ expect(output).toContain('功能');
+ expect(output).toContain('Speed');
+ expect(output).toContain('速度很快');
+ expect(output).toContain('Quality');
+ expect(output).toContain('质量很高');
+ });
+
+ it('wraps long cell content instead of truncating', () => {
+ const longText = 'This is a very long text that should wrap';
+ const output = renderTable(
+ ['Col'],
+ [[longText]],
+ 30, // narrow terminal to force wrapping
+ );
+
+ // The content should still appear (not truncated with ...)
+ expect(output).toContain('This is a very long');
+ expect(output).toContain('text that should');
+ expect(output).toContain('wrap');
+ });
+
+ it('respects left alignment', () => {
+ const output = renderTable(['Header'], [['left']], 30, ['left']);
+ expect(output).toContain('left');
+ });
+
+ it('respects center alignment', () => {
+ const output = renderTable(['Header'], [['center']], 30, ['center']);
+ expect(output).toContain('center');
+ });
+
+ it('respects right alignment', () => {
+ const output = renderTable(['Header'], [['right']], 30, ['right']);
+ expect(output).toContain('right');
+ });
+
+ it('handles multiple columns with mixed alignment', () => {
+ const output = renderTable(
+ ['Left', 'Center', 'Right'],
+ [['L', 'C', 'R']],
+ 40,
+ ['left', 'center', 'right'],
+ );
+
+ expect(output).toContain('Left');
+ expect(output).toContain('Center');
+ expect(output).toContain('Right');
+ });
+
+ it('handles tables wider than terminal width', () => {
+ const output = renderTable(
+ ['Column A', 'Column B', 'Column C'],
+ [
+ ['AAAA', 'BBBB', 'CCCC'],
+ ['DDDD', 'EEEE', 'FFFF'],
+ ],
+ 30, // narrow terminal
+ );
+
+ // Content should still appear, wrapped across lines
+ // "Column A" gets split by wrap-ansi into "Colum" + "n A"
+ expect(output).toContain('Colum');
+ expect(output).toContain('n A');
+ expect(output).toContain('AAAA');
+ expect(output).toContain('DDDD');
+ });
+
+ it('renders CJK-heavy table that would previously be misaligned', () => {
+ // This is the classic failure case: CJK chars counted as width 1
+ // causes column misalignment
+ const output = renderTable(
+ ['对比项', 'Claude Code', 'Qwen Code'],
+ [
+ ['性能', '优秀', '优秀'],
+ ['中文支持', '一般', '很好'],
+ ['开源', '否', '是'],
+ ],
+ 50,
+ );
+
+ expect(output).toContain('对比项');
+ expect(output).toContain('Claude Code');
+ expect(output).toContain('Qwen Code');
+ expect(output).toContain('性能');
+ expect(output).toContain('中文支持');
+ expect(output).toContain('开源');
+ });
+
+ it('handles inline markdown in cells', () => {
+ const output = renderTable(['Feature'], [['**bold** and `code`']]);
+
+ expect(output).toContain('bold');
+ expect(output).toContain('code');
+ });
+
+ it('handles empty cells', () => {
+ const output = renderTable(['A', 'B'], [['', 'content']]);
+
+ expect(output).toContain('content');
+ });
+
+ it('handles rows with fewer columns than headers', () => {
+ const output = renderTable(
+ ['A', 'B', 'C'],
+ [['only-one']], // row has only 1 cell
+ );
+
+ expect(output).toContain('only-one');
+ });
+
+ it('wraps content for very narrow terminals with many columns', () => {
+ const output = renderTable(
+ ['Col1', 'Col2', 'Col3', 'Col4', 'Col5'],
+ [['LongValue1', 'LongValue2', 'LongValue3', 'LongValue4', 'LongValue5']],
+ 20, // very narrow
+ );
+
+ // In a very narrow terminal, content gets wrapped into multi-line rows
+ // All content should still appear (may be split across lines)
+ expect(output).toContain('Col');
+ expect(output).toContain('Lon');
+ expect(output).toContain('gVa');
+ expect(output).toContain('lue');
+ });
+
+ // ─── Reverse audit: edge cases that SHOULD NOT break ───
+
+ it('handles empty headers array without crash', () => {
+ const output = renderTable([], [], 80);
+ // Should render an empty box without crashing
+ expect(output).toBeDefined();
+ });
+
+ it('handles contentWidth of 0 without crash', () => {
+ const output = renderTable(['A', 'B'], [['1', '2']], 0);
+ expect(output).toBeDefined();
+ });
+
+ it('handles contentWidth of 1 without crash', () => {
+ const output = renderTable(['A', 'B'], [['1', '2']], 1);
+ expect(output).toBeDefined();
+ });
+
+ it('handles single-column table', () => {
+ const output = renderTable(['Name'], [['Alice'], ['Bob']]);
+ expect(output).toContain('Name');
+ expect(output).toContain('Alice');
+ expect(output).toContain('Bob');
+ });
+
+ it('handles cell content that is all CJK', () => {
+ const output = renderTable(
+ ['项目名', '状态'],
+ [
+ ['数据库连接测试', '成功'],
+ ['缓存压力测试', '失败'],
+ ],
+ 40,
+ );
+ expect(output).toContain('数据库连接测试');
+ expect(output).toContain('缓存压力测试');
+ });
+
+ it('handles row with more columns than headers (truncation)', () => {
+ const output = renderTable(['A'], [['extra1', 'extra2', 'extra3']]);
+ // Should only show content for declared columns
+ expect(output).toContain('extra1');
+ });
+
+ it('handles headers with inline markdown syntax', () => {
+ const output = renderTable(['**Bold**', '`Code`'], [['val1', 'val2']]);
+ expect(output).toContain('Bold');
+ expect(output).toContain('Code');
+ });
+
+ it('padAligned: center alignment with odd padding', () => {
+ // When padding is odd, left gets the extra space
+ const output = renderTable(['X'], [['A']], 10, ['center']);
+ expect(output).toContain('A');
+ });
+
+ it('table with only one row still has all borders', () => {
+ const output = renderTable(['H1', 'H2'], [['v1', 'v2']]);
+ // Should have top, single middle, bottom border
+ const borderChars = output.match(/┌/g);
+ expect(borderChars).toHaveLength(1);
+ const bottomBorders = output.match(/└/g);
+ expect(bottomBorders).toHaveLength(1);
+ });
+
+ it('does not produce NaN column widths when scaling', () => {
+ // When contentWidth is very small, scaleFactor could produce NaN/Infinity
+ const output = renderTable(['A', 'B'], [['x', 'y']], 5);
+ expect(output).toBeDefined();
+ expect(output).not.toContain('NaN');
+ expect(output).not.toContain('Infinity');
+ });
+
+ it('preserves ANSI escape sequences in non-markdown cells', () => {
+ const red = '\u001b[31m红色\u001b[0m';
+ const output = renderTable(['状态', '值'], [[red, 'OK']], 40);
+ expect(output).toContain('\u001b[31m');
+ expect(output).toContain('红色');
+ });
+
+ it('wraps complex ANSI-colored content without losing segments', () => {
+ const colorful =
+ '\u001b[31m红色\u001b[0m and \u001b[32mgreen\u001b[0m then \u001b[34mblue文本\u001b[0m';
+ const output = renderTable(['状态'], [[colorful]], 24);
+ expect(output).toContain('\u001b[31m');
+ expect(output).toContain('\u001b[32m');
+ expect(output).toContain('\u001b[34m');
+ expect(output).toContain('红色');
+ expect(output).toContain('green');
+ expect(output).toContain('blue');
+ expect(output).toContain('文本');
+ expectAllLinesToHaveSameVisibleWidth(output);
+ });
+
+ it('handles ANSI + CJK mixed width without losing content', () => {
+ const green = '\u001b[32m中文ABC\u001b[0m';
+ const output = renderTable(['列1', '列2'], [[green, '普通文本']], 40);
+ expect(output).toContain('\u001b[32m');
+ expect(output).toContain('中文ABC');
+ expect(output).toContain('普通文本');
+ });
+
+ it('keeps markdown cells readable while preserving layout', () => {
+ const output = renderTable(
+ ['名称', '描述'],
+ [['**加粗**', '`code` 和 普通文本']],
+ 40,
+ );
+ expect(output).toContain('加粗');
+ expect(output).toContain('code');
+ expect(output).toContain('普通文本');
+ });
+
+ it('handles ANSI and markdown mixed across different columns', () => {
+ const blue = '\u001b[34mBlue\u001b[0m';
+ const output = renderTable(
+ ['ANSI', 'Markdown'],
+ [[blue, '**bold** text']],
+ 50,
+ );
+ expect(output).toContain('\u001b[34m');
+ expect(output).toContain('Blue');
+ expect(output).toContain('bold');
+ });
+
+ it('renders markdown links as readable plain text in cells', () => {
+ const output = renderTable(
+ ['Name', 'Link'],
+ [['Doc', '[Qwen](https://example.com/path)']],
+ 60,
+ );
+ expect(output).toContain('Qwen');
+ expect(output).not.toContain('[Qwen](');
+ });
+
+ it('renders inline code and bold text readably in the same cell', () => {
+ const output = renderTable(
+ ['Desc'],
+ [['Use `npm test` with **care**']],
+ 40,
+ );
+ expect(output).toContain('npm test');
+ expect(output).toContain('care');
+ });
+
+ it('renders underline html tag readably in cells', () => {
+ const output = renderTable(['Desc'], [['underlined text']], 40);
+ expect(output).toContain('underlined');
+ expect(output).toContain('text');
+ });
+
+ it('does not collapse content when multiple markdown syntaxes coexist', () => {
+ const output = renderTable(
+ ['Mixed'],
+ [['**bold** _italic_ `code` [link](https://a.b)']],
+ 60,
+ );
+ expect(output).toContain('bold');
+ expect(output).toContain('italic');
+ expect(output).toContain('code');
+ expect(output).toContain('link');
+ });
+
+ it('handles cells containing literal newlines without crashing', () => {
+ const output = renderTable(['A', 'B'], [['line1\nline2', 'value']], 40);
+ expect(output).toContain('line1');
+ expect(output).toContain('line2');
+ });
+
+ it('handles all-ANSI cell content', () => {
+ const colorful =
+ '\u001b[31mR\u001b[0m\u001b[32mG\u001b[0m\u001b[34mB\u001b[0m';
+ const output = renderTable(['Color'], [[colorful]], 20);
+ expect(output).toContain('\u001b[31m');
+ expect(output).toContain('\u001b[32m');
+ expect(output).toContain('\u001b[34m');
+ });
+
+ it('handles empty column content across all rows', () => {
+ const output = renderTable(
+ ['A', 'B'],
+ [
+ ['', 'x'],
+ ['', 'y'],
+ ],
+ 30,
+ );
+ expect(output).toContain('x');
+ expect(output).toContain('y');
+ });
+
+ it('falls back safely under extremely narrow width', () => {
+ const output = renderTable(
+ ['HeaderA', 'HeaderB'],
+ [['ValueA', 'ValueB']],
+ 2,
+ );
+ expect(output).toBeDefined();
+ expect(output).not.toContain('NaN');
+ });
+
+ it('preserves non-space trailing content while trimming wrap artifacts', () => {
+ const output = renderTable(['A'], [['abc def']], 20);
+ expect(output).toContain('abc');
+ expect(output).toContain('def');
+ });
+
+ it('keeps CJK + ANSI + wrapping stable near width boundary', () => {
+ const cyan = '\u001b[36m中文对比ABC\u001b[0m';
+ const output = renderTable(
+ ['项目', '结果说明'],
+ [[cyan, '这是一个接近边界宽度的说明文本']],
+ 26,
+ );
+ expect(output).toContain('\u001b[36m');
+ expect(output).toContain('中文');
+ expect(output).toContain('对比');
+ expect(output).toContain('ABC');
+ expect(output).toContain('说明文本');
+ });
+
+ it('keeps alignment stable with mixed widths near boundary', () => {
+ const output = renderTable(
+ ['短', 'LongHeader'],
+ [['中文', 'abcdefghi']],
+ 24,
+ ['center', 'right'],
+ );
+ expect(output).toContain('中');
+ expect(output).toContain('文');
+ expect(output).toContain('abcdefghi');
+ expect(output).not.toContain('NaN');
+ });
+
+ it('renders vertical fallback with CJK labels readably', () => {
+ const output = renderTable(
+ ['字段一', '字段二', '字段三'],
+ [['很长的值一', '很长的值二', '很长的值三']],
+ 10,
+ );
+ expect(output).toContain('字段一');
+ expect(output).toContain('很长的值一');
+ });
+
+ it('stays stable across multiple content widths', () => {
+ for (const width of [8, 10, 12, 16, 20, 30, 40, 60]) {
+ const output = renderTable(
+ ['项目', '状态', '说明'],
+ [
+ [
+ '中文ABC',
+ '\u001b[33mWARN\u001b[0m',
+ '**long** explanation with mixed 中英 content',
+ ],
+ ],
+ width,
+ ['left', 'center', 'right'],
+ );
+ expect(output).toBeDefined();
+ expect(output).not.toContain('NaN');
+ expect(output).not.toContain('Infinity');
+ expect(output).toContain('项目');
+ expect(output).toContain('状态');
+ expect(output).toContain('说明');
+ if (output.includes('┌')) {
+ expectAllLinesToHaveSameVisibleWidth(output);
+ }
+ }
+ });
+});
diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx
index db3ff8a950..6d647980a1 100644
--- a/packages/cli/src/ui/utils/TableRenderer.tsx
+++ b/packages/cli/src/ui/utils/TableRenderer.tsx
@@ -4,156 +4,537 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import React from 'react';
+import type React from 'react';
import { Text, Box } from 'ink';
+import wrapAnsi from 'wrap-ansi';
+import stripAnsi from 'strip-ansi';
+import { getCachedStringWidth } from './textUtils.js';
import { theme } from '../semantic-colors.js';
-import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js';
+
+/** Minimum column width to prevent degenerate layouts */
+const MIN_COLUMN_WIDTH = 3;
+
+/** Maximum number of lines per row before switching to vertical format */
+const MAX_ROW_LINES = 4;
+
+/** Safety margin to account for terminal resize races */
+const SAFETY_MARGIN = 4;
+
+export type ColumnAlign = 'left' | 'center' | 'right';
interface TableRendererProps {
headers: string[];
rows: string[][];
contentWidth: number;
+ /** Per-column alignment parsed from markdown separator line */
+ aligns?: ColumnAlign[];
+}
+
+/** Map Ink-compatible named colors to ANSI foreground codes */
+const INK_COLOR_TO_ANSI: Record = {
+ black: 30,
+ red: 31,
+ green: 32,
+ yellow: 33,
+ blue: 34,
+ magenta: 35,
+ cyan: 36,
+ white: 37,
+ gray: 90,
+ grey: 90,
+ blackbright: 90,
+ redbright: 91,
+ greenbright: 92,
+ yellowbright: 93,
+ bluebright: 94,
+ magentabright: 95,
+ cyanbright: 96,
+ whitebright: 97,
+};
+
+const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
+
+/** Get raw ANSI foreground color escape (without reset) for re-application */
+function getColorCode(color: string): string {
+ if (!color) return '';
+ if (color.startsWith('#')) {
+ if (!HEX_COLOR_RE.test(color)) return '';
+ const hex =
+ color.length === 4
+ ? color[1]! + color[1]! + color[2]! + color[2]! + color[3]! + color[3]!
+ : color.slice(1);
+ const r = parseInt(hex.slice(0, 2), 16);
+ const g = parseInt(hex.slice(2, 4), 16);
+ const b = parseInt(hex.slice(4, 6), 16);
+ return `\x1b[38;2;${r};${g};${b}m`;
+ }
+ const code = INK_COLOR_TO_ANSI[color.toLowerCase()];
+ if (code !== undefined) return `\x1b[${code}m`;
+ return '';
+}
+
+/** Apply an Ink-compatible color (hex or named) to text via raw ANSI codes */
+function applyColor(text: string, color: string): string {
+ const code = getColorCode(color);
+ if (!code) return text;
+ return `${code}${text}\x1b[39m`;
}
/**
- * Custom table renderer for markdown tables
- * We implement our own instead of using ink-table due to module compatibility issues
+ * Re-apply a color code after any SGR sequence that resets foreground:
+ * \x1b[39m (default foreground) and \x1b[0m (full reset).
+ */
+function recolorAfterResets(text: string, colorCode: string): string {
+ const fgReset = '\x1b[39m';
+ const fullReset = '\x1b[0m';
+ return text
+ .split(fgReset)
+ .join(fgReset + colorCode)
+ .split(fullReset)
+ .join(fullReset + colorCode);
+}
+
+/** ANSI text formatting helpers (always produce escape codes, unlike chalk) */
+const ansiFmt = {
+ bold: (t: string) => `\x1b[1m${t}\x1b[22m`,
+ italic: (t: string) => `\x1b[3m${t}\x1b[23m`,
+ underline: (t: string) => `\x1b[4m${t}\x1b[24m`,
+ strikethrough: (t: string) => `\x1b[9m${t}\x1b[29m`,
+};
+
+/**
+ * Convert inline markdown to ANSI-styled text.
+ * Mirrors RenderInline's behavior but outputs strings instead of React nodes.
+ */
+function renderMarkdownToAnsi(text: string): string {
+ const inlineRegex =
+ /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g;
+
+ let result = '';
+ let lastIndex = 0;
+ let match;
+
+ while ((match = inlineRegex.exec(text)) !== null) {
+ result += text.slice(lastIndex, match.index);
+ const fullMatch = match[0]!;
+ let rendered: string | null = null;
+
+ if (
+ fullMatch.startsWith('**') &&
+ fullMatch.endsWith('**') &&
+ fullMatch.length > 4
+ ) {
+ rendered = ansiFmt.bold(fullMatch.slice(2, -2));
+ } else if (
+ fullMatch.length > 2 &&
+ ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
+ (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
+ !/\w/.test(text.substring(match.index - 1, match.index)) &&
+ !/\w/.test(
+ text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1),
+ ) &&
+ !/\S[./\\]/.test(text.substring(match.index - 2, match.index)) &&
+ !/[./\\]\S/.test(
+ text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
+ )
+ ) {
+ rendered = ansiFmt.italic(fullMatch.slice(1, -1));
+ } else if (
+ fullMatch.startsWith('~~') &&
+ fullMatch.endsWith('~~') &&
+ fullMatch.length > 4
+ ) {
+ rendered = ansiFmt.strikethrough(fullMatch.slice(2, -2));
+ } else if (
+ fullMatch.startsWith('`') &&
+ fullMatch.endsWith('`') &&
+ fullMatch.length > 1
+ ) {
+ const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
+ if (codeMatch?.[2]) {
+ rendered = applyColor(codeMatch[2], theme.text.code);
+ }
+ } else if (
+ fullMatch.startsWith('[') &&
+ fullMatch.includes('](') &&
+ fullMatch.endsWith(')')
+ ) {
+ const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
+ if (linkMatch) {
+ rendered = `${linkMatch[1]} ${applyColor(`(${linkMatch[2]})`, theme.text.link)}`;
+ }
+ } else if (
+ fullMatch.startsWith('') &&
+ fullMatch.endsWith('') &&
+ fullMatch.length > 7
+ ) {
+ rendered = ansiFmt.underline(fullMatch.slice(3, -4));
+ } else if (/^https?:\/\//.test(fullMatch)) {
+ rendered = applyColor(fullMatch, theme.text.link);
+ }
+
+ result += rendered ?? fullMatch;
+ lastIndex = inlineRegex.lastIndex;
+ }
+
+ result += text.slice(lastIndex);
+ return result;
+}
+
+/**
+ * Pad `content` to `targetWidth` according to alignment.
+ * `displayWidth` is the visible width of `content` — caller computes this
+ * via stringWidth so ANSI codes in `content` don't affect padding.
+ */
+function padAligned(
+ content: string,
+ displayWidth: number,
+ targetWidth: number,
+ align: ColumnAlign,
+): string {
+ const padding = Math.max(0, targetWidth - displayWidth);
+ if (align === 'center') {
+ const leftPad = Math.floor(padding / 2);
+ return ' '.repeat(leftPad) + content + ' '.repeat(padding - leftPad);
+ }
+ if (align === 'right') {
+ return ' '.repeat(padding) + content;
+ }
+ // left (default)
+ return content + ' '.repeat(padding);
+}
+
+/**
+ * Wrap text to fit within a given width, returning array of lines.
+ * ANSI-aware: preserves styling across line breaks.
+ */
+function wrapText(
+ text: string,
+ width: number,
+ options?: { hard?: boolean },
+): string[] {
+ if (width <= 0) return [text];
+ const trimmedText = text.trimEnd();
+ const wrapped = wrapAnsi(trimmedText, width, {
+ hard: options?.hard ?? false,
+ trim: false,
+ wordWrap: true,
+ });
+ const lines = wrapped.split('\n');
+ // Trim trailing empty lines (wrap-ansi artifacts) but preserve internal ones
+ while (lines.length > 1 && lines[lines.length - 1]!.length === 0) {
+ lines.pop();
+ }
+ return lines.length > 0 ? lines : [''];
+}
+
+/**
+ * Custom table renderer for markdown tables.
+ *
+ * Builds the table as pure ANSI strings (like Claude Code does)
+ * to prevent Ink from inserting mid-row line breaks.
+ *
+ * Improvements over original:
+ * 1. ANSI-aware + CJK-aware column width calculation via stringWidth
+ * 2. Cell content wraps (multi-line) instead of truncation
+ * 3. Supports left/center/right alignment from markdown separator markers
+ * 4. Vertical fallback format when rows would be too tall
+ * 5. Safety check against terminal resize races
*/
export const TableRenderer: React.FC = ({
headers,
rows,
contentWidth,
+ aligns,
}) => {
- // 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] || '')),
+ const colCount = headers.length;
+
+ // Empty table — nothing to render
+ if (colCount === 0) {
+ return ;
+ }
+
+ // ── Precompute per-cell metrics to avoid repeated renderMarkdownToAnsi calls ──
+ const computeMetrics = (text: string) => {
+ const rendered = renderMarkdownToAnsi(text);
+ const visible = stripAnsi(rendered);
+ const words = visible.split(/\s+/).filter((w) => w.length > 0);
+ return {
+ rendered,
+ renderedWidth: getCachedStringWidth(visible),
+ minWordWidth:
+ words.length > 0
+ ? Math.max(
+ ...words.map((w) => getCachedStringWidth(w)),
+ MIN_COLUMN_WIDTH,
+ )
+ : MIN_COLUMN_WIDTH,
+ };
+ };
+
+ const headerMetrics = headers.map((h) => computeMetrics(h));
+ const rowMetrics = rows.map((row) =>
+ Array.from({ length: colCount }, (_, i) => computeMetrics(row[i] || '')),
+ );
+
+ // ── Step 1: Calculate min (longest word) and ideal (full content) widths ──
+ const minColumnWidths = headers.map((_, colIndex) => {
+ let maxMin = headerMetrics[colIndex]!.minWordWidth;
+ for (const row of rowMetrics) {
+ maxMin = Math.max(maxMin, row[colIndex]!.minWordWidth);
+ }
+ return maxMin;
+ });
+
+ const idealWidths = headers.map((_, colIndex) => {
+ let maxIdeal = Math.max(
+ headerMetrics[colIndex]!.renderedWidth,
+ MIN_COLUMN_WIDTH,
);
- return Math.max(headerWidth, maxRowWidth) + 2; // Add padding
+ for (const row of rowMetrics) {
+ maxIdeal = Math.max(maxIdeal, row[colIndex]!.renderedWidth);
+ }
+ return maxIdeal;
});
- // Ensure table fits within terminal width
- const totalWidth = columnWidths.reduce((sum, width) => sum + width + 1, 1);
- const fixedWidth = columnWidths.length + 1;
- const scaleFactor = totalWidth > contentWidth ? (contentWidth - fixedWidth) / (totalWidth - fixedWidth) : 1;
- const adjustedWidths = columnWidths.map((width) =>
- Math.floor(width * scaleFactor),
+ // ── Step 2: Calculate available space ──
+ // Border overhead: │ content │ content │ = 1 + (width + 3) per column
+ const borderOverhead = 1 + colCount * 3;
+ const availableWidth = Math.max(
+ contentWidth - borderOverhead - SAFETY_MARGIN,
+ colCount * MIN_COLUMN_WIDTH,
);
- // 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 - 3) {
- bestTruncated = candidate;
- left = mid + 1;
- } else {
- right = mid - 1;
- }
- }
+ // ── Step 3: Calculate column widths that fit available space ──
+ const totalMin = minColumnWidths.reduce((sum, w) => sum + w, 0);
+ const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0);
+
+ let needsHardWrap = false;
+ let columnWidths: number[];
+
+ if (totalIdeal <= availableWidth) {
+ columnWidths = idealWidths;
+ } else if (totalMin <= availableWidth) {
+ const extraSpace = availableWidth - totalMin;
+ const overflows = idealWidths.map(
+ (ideal, i) => ideal - minColumnWidths[i]!,
+ );
+ const totalOverflow = overflows.reduce((sum, o) => sum + o, 0);
+
+ columnWidths = minColumnWidths.map((min, i) => {
+ if (totalOverflow === 0) return min;
+ const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace);
+ return min + extra;
+ });
+ } else {
+ needsHardWrap = true;
+ const scaleFactor = availableWidth / totalMin;
+ columnWidths = minColumnWidths.map((w) =>
+ Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH),
+ );
+ // Post-pass: MIN_COLUMN_WIDTH floor can push sum over availableWidth.
+ // Shave wider columns until the total fits.
+ let excess = columnWidths.reduce((s, w) => s + w, 0) - availableWidth;
+ while (excess > 0) {
+ const maxW = Math.max(...columnWidths);
+ if (maxW <= MIN_COLUMN_WIDTH) break;
+ const idx = columnWidths.indexOf(maxW);
+ const reduction = Math.min(excess, maxW - MIN_COLUMN_WIDTH);
+ columnWidths[idx] = maxW - reduction;
+ excess -= reduction;
+ }
+ }
- cellContent = bestTruncated + '...';
+ // ── Step 4: Check max row lines to decide vertical fallback ──
+ function calculateMaxRowLines(): number {
+ let maxLines = 1;
+ for (let i = 0; i < colCount; i++) {
+ const wrapped = wrapText(headerMetrics[i]!.rendered, columnWidths[i]!, {
+ hard: needsHardWrap,
+ });
+ maxLines = Math.max(maxLines, wrapped.length);
+ }
+ for (const row of rowMetrics) {
+ for (let i = 0; i < colCount; i++) {
+ const wrapped = wrapText(row[i]!.rendered, columnWidths[i]!, {
+ hard: needsHardWrap,
+ });
+ maxLines = Math.max(maxLines, wrapped.length);
}
}
+ return maxLines;
+ }
- // Calculate exact padding needed
- const actualDisplayWidth = getPlainTextLength(cellContent);
- const paddingNeeded = Math.max(0, contentWidth - actualDisplayWidth);
+ const maxRowLines = calculateMaxRowLines();
+ const useVerticalFormat = maxRowLines > MAX_ROW_LINES;
- return (
-
- {isHeader ? (
-
-
-
- ) : (
-
- )}
- {' '.repeat(paddingNeeded)}
-
+ // ── Helper: Get alignment for a column ──
+ const getAlign = (colIndex: number): ColumnAlign =>
+ aligns?.[colIndex] ?? 'left';
+
+ // ── Build horizontal border as pure string ──
+ function renderBorderLine(type: 'top' | 'middle' | 'bottom'): string {
+ const [left, mid, cross, right] = {
+ top: ['┌', '─', '┬', '┐'],
+ middle: ['├', '─', '┼', '┤'],
+ bottom: ['└', '─', '┴', '┘'],
+ }[type] as [string, string, string, string];
+
+ let line = left;
+ columnWidths.forEach((width, colIndex) => {
+ line += mid.repeat(width + 2);
+ line += colIndex < columnWidths.length - 1 ? cross : right;
+ });
+ return applyColor(line, theme.border.default);
+ }
+
+ // ── Build row lines as pure strings ──
+ // renderedCells: pre-rendered ANSI text for each column (already colCount-normalized)
+ function renderRowLines(
+ renderedCells: string[],
+ isHeader: boolean,
+ ): string[] {
+ const cellLines = renderedCells.map((cell, colIndex) =>
+ wrapText(cell, columnWidths[colIndex]!, { hard: needsHardWrap }),
);
- };
- // Helper function to render border
- const renderBorder = (type: 'top' | 'middle' | 'bottom'): React.ReactNode => {
- const chars = {
- top: { left: '┌', middle: '┬', right: '┐', horizontal: '─' },
- middle: { left: '├', middle: '┼', right: '┤', horizontal: '─' },
- bottom: { left: '└', middle: '┴', right: '┘', horizontal: '─' },
- };
+ const maxLines = Math.max(...cellLines.map((l) => l.length), 1);
+ // Vertical centering offset per cell
+ const offsets = cellLines.map((l) => Math.floor((maxLines - l.length) / 2));
- const char = chars[type];
- const borderParts = adjustedWidths.map((w) => char.horizontal.repeat(w));
- const border = char.left + borderParts.join(char.middle) + char.right;
+ const borderPipe = applyColor('│', theme.border.default);
+ const result: string[] = [];
+ for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) {
+ let line = borderPipe;
+ for (let colIndex = 0; colIndex < colCount; colIndex++) {
+ const lines = cellLines[colIndex]!;
+ const offset = offsets[colIndex]!;
+ const contentLineIdx = lineIdx - offset;
+ const lineText =
+ contentLineIdx >= 0 && contentLineIdx < lines.length
+ ? lines[contentLineIdx]!
+ : '';
- return {border};
- };
+ const width = columnWidths[colIndex]!;
+ const displayWidth = getCachedStringWidth(stripAnsi(lineText));
+ // Respect explicit alignment; default headers to center when unspecified
+ const align =
+ aligns?.[colIndex] != null
+ ? getAlign(colIndex)
+ : isHeader
+ ? 'center'
+ : 'left';
+ const padded = padAligned(lineText, displayWidth, width, align);
- // Helper function to render a table row
- const renderRow = (cells: string[], isHeader = false): React.ReactNode => {
- const renderedCells = cells.map((cell, index) => {
- const width = adjustedWidths[index] || 0;
- return renderCell(cell || '', width, isHeader);
+ // Re-apply base color after any SGR reset (\x1b[39m or \x1b[0m)
+ if (isHeader) {
+ const linkCode = getColorCode(theme.text.link);
+ const recolored = linkCode
+ ? recolorAfterResets(padded, linkCode)
+ : padded;
+ const styledPadded = applyColor(
+ ansiFmt.bold(recolored),
+ theme.text.link,
+ );
+ line += ' ' + styledPadded + ' ' + borderPipe;
+ } else {
+ const primaryCode = getColorCode(theme.text.primary);
+ const recolored = primaryCode
+ ? recolorAfterResets(padded, primaryCode)
+ : padded;
+ const styledCell = primaryCode
+ ? applyColor(recolored, theme.text.primary)
+ : recolored;
+ line += ' ' + styledCell + ' ' + borderPipe;
+ }
+ }
+ result.push(line);
+ }
+ return result;
+ }
+
+ // ── Vertical format (key-value pairs) for narrow terminals ──
+ function renderVerticalFormat(): string {
+ const lines: string[] = [];
+ const separatorWidth = Math.max(Math.min(contentWidth - 1, 40), 0);
+ const separator = separatorWidth > 0 ? '─'.repeat(separatorWidth) : '';
+
+ rowMetrics.forEach((row, rowIndex) => {
+ if (rowIndex > 0) {
+ lines.push(separator);
+ }
+ for (let colIndex = 0; colIndex < colCount; colIndex++) {
+ const rawLabel = headers[colIndex] ?? `Column ${colIndex + 1}`;
+ const label = renderMarkdownToAnsi(rawLabel);
+ const value = row[colIndex]!.rendered.trim()
+ .replace(/\n+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ const linkCode = getColorCode(theme.text.link);
+ const recoloredLabel = linkCode
+ ? recolorAfterResets(`${label}:`, linkCode)
+ : `${label}:`;
+ const primaryCode = getColorCode(theme.text.primary);
+ const styledValue = primaryCode
+ ? applyColor(
+ recolorAfterResets(value, primaryCode),
+ theme.text.primary,
+ )
+ : value;
+ lines.push(
+ `${applyColor(ansiFmt.bold(recoloredLabel), theme.text.link)} ${styledValue}`,
+ );
+ }
});
+ return lines.join('\n');
+ }
+ // ── Choose format ──
+ if (useVerticalFormat) {
return (
-
- │{' '}
- {renderedCells.map((cell, index) => (
-
- {cell}
- {index < renderedCells.length - 1 ? ' │ ' : ''}
-
- ))}{' '}
- │
-
+
+ {renderVerticalFormat()}
+
);
- };
-
- return (
-
- {/* Top border */}
- {renderBorder('top')}
-
- {/* Header row */}
- {renderRow(headers, true)}
+ }
- {/* Middle border */}
- {renderBorder('middle')}
+ // ── Build the complete horizontal table as strings ──
+ const headerRendered = headerMetrics.map((m) => m.rendered);
+ const tableLines: string[] = [];
+ tableLines.push(renderBorderLine('top'));
+ tableLines.push(...renderRowLines(headerRendered, true));
+ tableLines.push(renderBorderLine('middle'));
+ rowMetrics.forEach((row, rowIndex) => {
+ tableLines.push(
+ ...renderRowLines(
+ row.map((m) => m.rendered),
+ false,
+ ),
+ );
+ if (rowIndex < rows.length - 1) {
+ tableLines.push(renderBorderLine('middle'));
+ }
+ });
+ tableLines.push(renderBorderLine('bottom'));
- {/* Data rows */}
- {rows.map((row, index) => (
- {renderRow(row)}
- ))}
+ // ── Safety check: verify no line exceeds content width ──
+ const maxLineWidth = Math.max(
+ ...tableLines.map((line) => getCachedStringWidth(stripAnsi(line))),
+ );
+ if (maxLineWidth > contentWidth - SAFETY_MARGIN) {
+ // Fallback to vertical format to prevent terminal resize flicker
+ return (
+
+ {renderVerticalFormat()}
+
+ );
+ }
- {/* Bottom border */}
- {renderBorder('bottom')}
+ // Render as a single Text block to prevent Ink wrapping mid-row
+ return (
+
+ {tableLines.join('\n')}
);
};
diff --git a/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap
index b0c25adc6d..6d719af3ce 100644
--- a/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap
@@ -48,6 +48,28 @@ exports[` > with 'Unix' line endings > renders a fenced code
exports[` > with 'Unix' line endings > renders a fenced code block without a language 1`] = `" 1 plain text"`;
+exports[` > with 'Unix' line endings > renders a single-column table 1`] = `
+"
+[38;2;108;112;134m┌───────┐[39m
+[38;2;108;112;134m│[39m [38;2;137;180;250m[1mName [22m[39m [38;2;108;112;134m│[39m
+[38;2;108;112;134m├───────┤[39m
+[38;2;108;112;134m│[39m Alice [38;2;108;112;134m│[39m
+[38;2;108;112;134m├───────┤[39m
+[38;2;108;112;134m│[39m Bob [38;2;108;112;134m│[39m
+[38;2;108;112;134m└───────┘[39m
+"
+`;
+
+exports[` > with 'Unix' line endings > renders a single-column table with center alignment 1`] = `
+"
+[38;2;108;112;134m┌───────┐[39m
+[38;2;108;112;134m│[39m [38;2;137;180;250m[1mName [22m[39m [38;2;108;112;134m│[39m
+[38;2;108;112;134m├───────┤[39m
+[38;2;108;112;134m│[39m Alice [38;2;108;112;134m│[39m
+[38;2;108;112;134m└───────┘[39m
+"
+`;
+
exports[` > with 'Unix' line endings > renders headers with correct levels 1`] = `
"Header 1
Header 2
@@ -80,12 +102,13 @@ exports[` > with 'Unix' line endings > renders ordered lists
exports[` > with 'Unix' line endings > renders tables correctly 1`] = `
"
-┌──────────┬──────────┐
-│ Header 1 │ Header 2 │
-├──────────┼──────────┤
-│ Cell 1 │ Cell 2 │
-│ Cell 3 │ Cell 4 │
-└──────────┴──────────┘
+[38;2;108;112;134m┌──────────┬──────────┐[39m
+[38;2;108;112;134m│[39m [38;2;137;180;250m[1mHeader 1[22m[39m [38;2;108;112;134m│[39m [38;2;137;180;250m[1mHeader 2[22m[39m [38;2;108;112;134m│[39m
+[38;2;108;112;134m├──────────┼──────────┤[39m
+[38;2;108;112;134m│[39m Cell 1 [38;2;108;112;134m│[39m Cell 2 [38;2;108;112;134m│[39m
+[38;2;108;112;134m├──────────┼──────────┤[39m
+[38;2;108;112;134m│[39m Cell 3 [38;2;108;112;134m│[39m Cell 4 [38;2;108;112;134m│[39m
+[38;2;108;112;134m└──────────┴──────────┘[39m
"
`;
@@ -136,6 +159,28 @@ exports[` > with 'Windows' line endings > renders a fenced co
exports[` > with 'Windows' line endings > renders a fenced code block without a language 1`] = `" 1 plain text"`;
+exports[` > with 'Windows' line endings > renders a single-column table 1`] = `
+"
+[38;2;108;112;134m┌───────┐[39m
+[38;2;108;112;134m│[39m [38;2;137;180;250m[1mName [22m[39m [38;2;108;112;134m│[39m
+[38;2;108;112;134m├───────┤[39m
+[38;2;108;112;134m│[39m Alice [38;2;108;112;134m│[39m
+[38;2;108;112;134m├───────┤[39m
+[38;2;108;112;134m│[39m Bob [38;2;108;112;134m│[39m
+[38;2;108;112;134m└───────┘[39m
+"
+`;
+
+exports[` > with 'Windows' line endings > renders a single-column table with center alignment 1`] = `
+"
+[38;2;108;112;134m┌───────┐[39m
+[38;2;108;112;134m│[39m [38;2;137;180;250m[1mName [22m[39m [38;2;108;112;134m│[39m
+[38;2;108;112;134m├───────┤[39m
+[38;2;108;112;134m│[39m Alice [38;2;108;112;134m│[39m
+[38;2;108;112;134m└───────┘[39m
+"
+`;
+
exports[` > with 'Windows' line endings > renders headers with correct levels 1`] = `
"Header 1
Header 2
@@ -168,12 +213,13 @@ exports[` > with 'Windows' line endings > renders ordered lis
exports[` > with 'Windows' line endings > renders tables correctly 1`] = `
"
-┌──────────┬──────────┐
-│ Header 1 │ Header 2 │
-├──────────┼──────────┤
-│ Cell 1 │ Cell 2 │
-│ Cell 3 │ Cell 4 │
-└──────────┴──────────┘
+[38;2;108;112;134m┌──────────┬──────────┐[39m
+[38;2;108;112;134m│[39m [38;2;137;180;250m[1mHeader 1[22m[39m [38;2;108;112;134m│[39m [38;2;137;180;250m[1mHeader 2[22m[39m [38;2;108;112;134m│[39m
+[38;2;108;112;134m├──────────┼──────────┤[39m
+[38;2;108;112;134m│[39m Cell 1 [38;2;108;112;134m│[39m Cell 2 [38;2;108;112;134m│[39m
+[38;2;108;112;134m├──────────┼──────────┤[39m
+[38;2;108;112;134m│[39m Cell 3 [38;2;108;112;134m│[39m Cell 4 [38;2;108;112;134m│[39m
+[38;2;108;112;134m└──────────┴──────────┘[39m
"
`;