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`] = ` +" +┌───────┐ +│ Name  │ +├───────┤ +│ Alice │ +├───────┤ +│ Bob │ +└───────┘ +" +`; + +exports[` > with 'Unix' line endings > renders a single-column table with center alignment 1`] = ` +" +┌───────┐ +│ Name  │ +├───────┤ +│ Alice │ +└───────┘ +" +`; + 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 │ -└──────────┴──────────┘ +┌──────────┬──────────┐ +│ Header 1 │ Header 2 │ +├──────────┼──────────┤ +│ Cell 1 │ Cell 2 │ +├──────────┼──────────┤ +│ Cell 3 │ Cell 4 │ +└──────────┴──────────┘ " `; @@ -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`] = ` +" +┌───────┐ +│ Name  │ +├───────┤ +│ Alice │ +├───────┤ +│ Bob │ +└───────┘ +" +`; + +exports[` > with 'Windows' line endings > renders a single-column table with center alignment 1`] = ` +" +┌───────┐ +│ Name  │ +├───────┤ +│ Alice │ +└───────┘ +" +`; + 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 │ -└──────────┴──────────┘ +┌──────────┬──────────┐ +│ Header 1 │ Header 2 │ +├──────────┼──────────┤ +│ Cell 1 │ Cell 2 │ +├──────────┼──────────┤ +│ Cell 3 │ Cell 4 │ +└──────────┴──────────┘ " `;