From c1da17e90968f653e7b199b16529831f9ec916e9 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 03:09:08 +0800 Subject: [PATCH 01/13] fix(cli): improve markdown table rendering in terminal --- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 114 ++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 41 +- .../cli/src/ui/utils/TableRenderer.test.tsx | 490 ++++++++++++++++++ packages/cli/src/ui/utils/TableRenderer.tsx | 431 ++++++++++----- .../MarkdownDisplay.test.tsx.snap | 78 ++- 5 files changed, 1019 insertions(+), 135 deletions(-) create mode 100644 packages/cli/src/ui/utils/TableRenderer.test.tsx diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 7f2e8f1d9a..4e04515969 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -155,6 +155,120 @@ 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('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..dbe050fa13 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,20 @@ 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 +67,7 @@ const MarkdownDisplayInternal: React.FC = ({ let inTable = false; let tableRows: string[][] = []; let tableHeaders: string[] = []; + let tableAligns: ColumnAlign[] = []; function addContentBlock(block: React.ReactNode) { if (block) { @@ -111,7 +125,9 @@ const MarkdownDisplayInternal: React.FC = ({ lines[index + 1].match(tableSeparatorRegex) ) { inTable = true; - tableHeaders = tableRowMatch[1].split(/(? cell.trim().replaceAll('\\|', '|')); + tableHeaders = tableRowMatch[1] + .split(/(? cell.trim().replaceAll('\\|', '|')); tableRows = []; } else { // Not a table, treat as regular text @@ -124,10 +140,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 +164,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 +300,7 @@ const MarkdownDisplayInternal: React.FC = ({ headers={tableHeaders} rows={tableRows} contentWidth={contentWidth} + aligns={tableAligns} />, ); } @@ -408,14 +430,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..77fcb4de71 --- /dev/null +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -0,0 +1,490 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } 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); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + 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..ea8c27b89c 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -4,156 +4,353 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import type React from 'react'; import { Text, Box } from 'ink'; -import { theme } from '../semantic-colors.js'; -import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js'; +import wrapAnsi from 'wrap-ansi'; +import stripAnsi from 'strip-ansi'; +import { getPlainTextLength } from './InlineMarkdownRenderer.js'; +import { getCachedStringWidth } from './textUtils.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; + +/** ANSI escape codes for text formatting */ +const ANSI_BOLD_START = '\x1b[1m'; +const ANSI_BOLD_END = '\x1b[22m'; + +export type ColumnAlign = 'left' | 'center' | 'right'; interface TableRendererProps { headers: string[]; rows: string[][]; contentWidth: number; + /** Per-column alignment parsed from markdown separator line */ + aligns?: ColumnAlign[]; +} + +const INLINE_MARKDOWN_REGEX = + /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>)/; + +/** + * Strip inline markdown syntax from text to get plain content. + * Used for column width calculation and text wrapping. + */ +function stripInlineMarkdown(text: string): string { + return text + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/_(.*?)_/g, '$1') + .replace(/~~(.*?)~~/g, '$1') + .replace(/`(.*?)`/g, '$1') + .replace(/(.*?)<\/u>/g, '$1') + .replace(/\[(.*?)\]\(.*?\)/g, '$1'); +} + +function hasInlineMarkdown(text: string): boolean { + return INLINE_MARKDOWN_REGEX.test(text); } /** - * Custom table renderer for markdown tables - * We implement our own instead of using ink-table due to module compatibility issues + * 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').filter((line) => line.length > 0); + return lines.length > 0 ? lines : ['']; +} + +/** + * Get the visual width of the longest word in a cell text. + * This determines the minimum column width to avoid breaking words. + */ +function getMinWordWidth(text: string): number { + const clean = stripAnsi(stripInlineMarkdown(text)); + const words = clean.split(/\s+/).filter((w) => w.length > 0); + if (words.length === 0) return MIN_COLUMN_WIDTH; + return Math.max( + ...words.map((w) => getCachedStringWidth(w)), + MIN_COLUMN_WIDTH, + ); +} + +/** + * 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] || '')), - ); - return Math.max(headerWidth, maxRowWidth) + 2; // Add padding + const colCount = headers.length; + + // ── Step 1: Calculate min (longest word) and ideal (full content) widths ── + const minColumnWidths = headers.map((header, colIndex) => { + let maxMin = getMinWordWidth(header); + for (const row of rows) { + maxMin = Math.max(maxMin, getMinWordWidth(row[colIndex] || '')); + } + return maxMin; + }); + + const idealWidths = headers.map((header, colIndex) => { + let maxIdeal = Math.max(getPlainTextLength(header), MIN_COLUMN_WIDTH); + for (const row of rows) { + maxIdeal = Math.max(maxIdeal, getPlainTextLength(row[colIndex] || '')); + } + 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), + // ── 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), + ); + } + + // ── Helper: Get plain text for a cell (strips markdown + ANSI) ── + const getCellPlainText = (text: string): string => + stripAnsi(stripInlineMarkdown(text)); + + // Preserve ANSI when possible; markdown currently falls back to plain text. + const getFormattedCellText = (text: string): string => + hasInlineMarkdown(text) ? stripInlineMarkdown(text) : text; + + // ── Step 4: Check max row lines to decide vertical fallback ── + function calculateMaxRowLines(): number { + let maxLines = 1; + for (let i = 0; i < headers.length; i++) { + const wrapped = wrapText( + getCellPlainText(headers[i]!), + columnWidths[i]!, + { hard: needsHardWrap }, + ); + maxLines = Math.max(maxLines, wrapped.length); + } + for (const row of rows) { + for (let i = 0; i < row.length; i++) { + const wrapped = wrapText( + getCellPlainText(row[i] || ''), + columnWidths[i]!, + { hard: needsHardWrap }, ); - } 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; - } - } + maxLines = Math.max(maxLines, wrapped.length); + } + } + return maxLines; + } + + const maxRowLines = calculateMaxRowLines(); + const useVerticalFormat = maxRowLines > MAX_ROW_LINES; + + // ── 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 line; + } + + // ── Build row lines as pure strings ── + function renderRowLines(cells: string[], isHeader: boolean): string[] { + // Wrap each cell's formatted content. Preserve ANSI when possible. + const cellLines = cells.map((cell, colIndex) => + wrapText(getFormattedCellText(cell || ''), columnWidths[colIndex]!, { + hard: needsHardWrap, + }), + ); - cellContent = bestTruncated + '...'; + 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 result: string[] = []; + for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { + let line = '│'; + 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]! + : ''; + + const width = columnWidths[colIndex]!; + const displayWidth = getCachedStringWidth(stripAnsi(lineText)); + // Header row always center-aligned; data uses column alignment + const align = isHeader ? 'center' : getAlign(colIndex); + const padded = padAligned(lineText, displayWidth, width, align); + + if (isHeader) { + const headerText = lineText.includes('\x1b[') + ? padded + : `${ANSI_BOLD_START}${padded}${ANSI_BOLD_END}`; + line += ' ' + headerText + ' │'; + } else { + line += ' ' + padded + ' │'; + } } + result.push(line); } + return result; + } - // Calculate exact padding needed - const actualDisplayWidth = getPlainTextLength(cellContent); - const paddingNeeded = Math.max(0, contentWidth - actualDisplayWidth); + // ── 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) : ''; + rows.forEach((row, rowIndex) => { + if (rowIndex > 0) { + lines.push(separator); + } + row.forEach((cell, colIndex) => { + const label = headers[colIndex] || `Column ${colIndex + 1}`; + const value = getFormattedCellText(cell || '') + .trim() + .replace(/\n+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + lines.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${value}`); + }); + }); + return lines.join('\n'); + } + + // ── Choose format ── + if (useVerticalFormat) { return ( - - {isHeader ? ( - - - - ) : ( - - )} - {' '.repeat(paddingNeeded)} - + + {renderVerticalFormat()} + ); - }; - - // 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 char = chars[type]; - const borderParts = adjustedWidths.map((w) => char.horizontal.repeat(w)); - const border = char.left + borderParts.join(char.middle) + char.right; - - return {border}; - }; - - // 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); - }); + } + // ── Build the complete horizontal table as strings ── + const tableLines: string[] = []; + tableLines.push(renderBorderLine('top')); + tableLines.push(...renderRowLines(headers, true)); + tableLines.push(renderBorderLine('middle')); + rows.forEach((row, rowIndex) => { + tableLines.push(...renderRowLines(row, false)); + if (rowIndex < rows.length - 1) { + tableLines.push(renderBorderLine('middle')); + } + }); + tableLines.push(renderBorderLine('bottom')); + + // ── 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 ( - - │{' '} - {renderedCells.map((cell, index) => ( - - {cell} - {index < renderedCells.length - 1 ? ' │ ' : ''} - - ))}{' '} - │ - + + {renderVerticalFormat()} + ); - }; + } + // Render as a single Text block to prevent Ink wrapping mid-row return ( - {/* Top border */} - {renderBorder('top')} - - {/* Header row */} - {renderRow(headers, true)} - - {/* Middle border */} - {renderBorder('middle')} - - {/* Data rows */} - {rows.map((row, index) => ( - {renderRow(row)} - ))} - - {/* Bottom border */} - {renderBorder('bottom')} + {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..07a04af1c0 100644 --- a/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap @@ -26,9 +26,13 @@ Another paragraph. exports[` > with 'Unix' line endings > handles a table at the end of the input 1`] = ` "Some text before. -| A | B | -|---| -| 1 | 2 |" + +┌─────┬─────┐ +│  A  │  B  │ +├─────┼─────┤ +│ 1 │ 2 │ +└─────┴─────┘ +" `; exports[` > with 'Unix' line endings > handles unclosed (pending) code blocks 1`] = `" 1 let y = 2;"`; @@ -48,6 +52,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 @@ -81,10 +107,11 @@ exports[` > with 'Unix' line endings > renders ordered lists exports[` > with 'Unix' line endings > renders tables correctly 1`] = ` " ┌──────────┬──────────┐ -│ Header 1 │ Header 2 │ +│ Header 1 │ Header 2 │ +├──────────┼──────────┤ +│ Cell 1 │ Cell 2 │ ├──────────┼──────────┤ -│ Cell 1 │ Cell 2 │ -│ Cell 3 │ Cell 4 │ +│ Cell 3 │ Cell 4 │ └──────────┴──────────┘ " `; @@ -114,9 +141,13 @@ Another paragraph. exports[` > with 'Windows' line endings > handles a table at the end of the input 1`] = ` "Some text before. -| A | B | -|---| -| 1 | 2 |" + +┌─────┬─────┐ +│  A  │  B  │ +├─────┼─────┤ +│ 1 │ 2 │ +└─────┴─────┘ +" `; exports[` > with 'Windows' line endings > handles unclosed (pending) code blocks 1`] = `" 1 let y = 2;"`; @@ -136,6 +167,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 @@ -169,10 +222,11 @@ exports[` > with 'Windows' line endings > renders ordered lis exports[` > with 'Windows' line endings > renders tables correctly 1`] = ` " ┌──────────┬──────────┐ -│ Header 1 │ Header 2 │ +│ Header 1 │ Header 2 │ +├──────────┼──────────┤ +│ Cell 1 │ Cell 2 │ ├──────────┼──────────┤ -│ Cell 1 │ Cell 2 │ -│ Cell 3 │ Cell 4 │ +│ Cell 3 │ Cell 4 │ └──────────┴──────────┘ " `; From 81cbeab72449d209d9d54575a153a0d5163107c1 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 06:28:41 +0800 Subject: [PATCH 02/13] fix(cli): restore theme colors and inline markdown rendering in tables Improvements over previous commit: - Restore theme.border.default color for table borders - Restore theme.text.link color + bold for table headers - Add renderMarkdownToAnsi() to render **bold**, `code`, *italic*, ~~strikethrough~~, underline, [links](url), and bare URLs as ANSI-styled text in table cells (mirrors RenderInline behavior) - Use raw ANSI escape codes instead of chalk (chalk.level=0 in tests) - Remove dead code: INLINE_MARKDOWN_REGEX, hasInlineMarkdown, ANSI_BOLD_START/END constants, unused vi/beforeEach in tests - Update 8 snapshots to reflect themed output Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/utils/TableRenderer.test.tsx | 6 +- packages/cli/src/ui/utils/TableRenderer.tsx | 160 +++++++++++++++--- .../MarkdownDisplay.test.tsx.snap | 96 +++++------ 3 files changed, 190 insertions(+), 72 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx index 77fcb4de71..51bc466d8b 100644 --- a/packages/cli/src/ui/utils/TableRenderer.test.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import stripAnsi from 'strip-ansi'; import stringWidth from 'string-width'; import { renderWithProviders } from '../../test-utils/render.js'; @@ -41,10 +41,6 @@ describe('', () => { expect(new Set(widths).size).toBe(1); }; - beforeEach(() => { - vi.clearAllMocks(); - }); - it('renders a basic table with borders', () => { const output = renderTable(['Name', 'Value'], [['foo', 'bar']]); diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index ea8c27b89c..b18d239fe8 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -10,6 +10,7 @@ import wrapAnsi from 'wrap-ansi'; import stripAnsi from 'strip-ansi'; import { getPlainTextLength } from './InlineMarkdownRenderer.js'; import { getCachedStringWidth } from './textUtils.js'; +import { theme } from '../semantic-colors.js'; /** Minimum column width to prevent degenerate layouts */ const MIN_COLUMN_WIDTH = 3; @@ -20,10 +21,6 @@ const MAX_ROW_LINES = 4; /** Safety margin to account for terminal resize races */ const SAFETY_MARGIN = 4; -/** ANSI escape codes for text formatting */ -const ANSI_BOLD_START = '\x1b[1m'; -const ANSI_BOLD_END = '\x1b[22m'; - export type ColumnAlign = 'left' | 'center' | 'right'; interface TableRendererProps { @@ -34,9 +31,6 @@ interface TableRendererProps { aligns?: ColumnAlign[]; } -const INLINE_MARKDOWN_REGEX = - /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>)/; - /** * Strip inline markdown syntax from text to get plain content. * Used for column width calculation and text wrapping. @@ -52,8 +46,131 @@ function stripInlineMarkdown(text: string): string { .replace(/\[(.*?)\]\(.*?\)/g, '$1'); } -function hasInlineMarkdown(text: string): boolean { - return INLINE_MARKDOWN_REGEX.test(text); +/** 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, +}; + +/** Apply an Ink-compatible color (hex or named) to text via raw ANSI codes */ +function applyColor(text: string, color: string): string { + if (!color) return text; + if (color.startsWith('#')) { + 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${text}\x1b[39m`; + } + const code = INK_COLOR_TO_ANSI[color.toLowerCase()]; + if (code !== undefined) return `\x1b[${code}m${text}\x1b[39m`; + return text; +} + +/** 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; } /** @@ -192,9 +309,9 @@ export const TableRenderer: React.FC = ({ const getCellPlainText = (text: string): string => stripAnsi(stripInlineMarkdown(text)); - // Preserve ANSI when possible; markdown currently falls back to plain text. + // Render inline markdown to ANSI-styled text; preserve existing ANSI codes. const getFormattedCellText = (text: string): string => - hasInlineMarkdown(text) ? stripInlineMarkdown(text) : text; + renderMarkdownToAnsi(text); // ── Step 4: Check max row lines to decide vertical fallback ── function calculateMaxRowLines(): number { @@ -240,7 +357,7 @@ export const TableRenderer: React.FC = ({ line += mid.repeat(width + 2); line += colIndex < columnWidths.length - 1 ? cross : right; }); - return line; + return applyColor(line, theme.border.default); } // ── Build row lines as pure strings ── @@ -256,9 +373,10 @@ export const TableRenderer: React.FC = ({ // Vertical centering offset per cell const offsets = cellLines.map((l) => Math.floor((maxLines - l.length) / 2)); + const borderPipe = applyColor('│', theme.border.default); const result: string[] = []; for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { - let line = '│'; + let line = borderPipe; for (let colIndex = 0; colIndex < colCount; colIndex++) { const lines = cellLines[colIndex]!; const offset = offsets[colIndex]!; @@ -275,12 +393,14 @@ export const TableRenderer: React.FC = ({ const padded = padAligned(lineText, displayWidth, width, align); if (isHeader) { - const headerText = lineText.includes('\x1b[') - ? padded - : `${ANSI_BOLD_START}${padded}${ANSI_BOLD_END}`; - line += ' ' + headerText + ' │'; + // Apply bold + link color for headers, matching original theme + const hasAnsi = lineText.includes('\x1b['); + const styledPadded = hasAnsi + ? ansiFmt.bold(padded) + : applyColor(ansiFmt.bold(padded), theme.text.link); + line += ' ' + styledPadded + ' ' + borderPipe; } else { - line += ' ' + padded + ' │'; + line += ' ' + padded + ' ' + borderPipe; } } result.push(line); @@ -306,7 +426,9 @@ export const TableRenderer: React.FC = ({ .replace(/\s+/g, ' ') .trim(); - lines.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${value}`); + lines.push( + `${applyColor(ansiFmt.bold(`${label}:`), theme.text.link)} ${value}`, + ); }); }); return lines.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 07a04af1c0..14f8f10c1d 100644 --- a/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap @@ -27,11 +27,11 @@ Another paragraph. exports[` > with 'Unix' line endings > handles a table at the end of the input 1`] = ` "Some text before. -┌─────┬─────┐ -│  A  │  B  │ -├─────┼─────┤ -│ 1 │ 2 │ -└─────┴─────┘ +┌─────┬─────┐ +│  A  │  B  │ +├─────┼─────┤ +│ 1 │ 2 │ +└─────┴─────┘ " `; @@ -54,23 +54,23 @@ exports[` > with 'Unix' line endings > renders a fenced code exports[` > with 'Unix' line endings > renders a single-column table 1`] = ` " -┌───────┐ -│ Name  │ -├───────┤ -│ Alice │ -├───────┤ -│ Bob │ -└───────┘ +┌───────┐ +│ Name  │ +├───────┤ +│ Alice │ +├───────┤ +│ Bob │ +└───────┘ " `; exports[` > with 'Unix' line endings > renders a single-column table with center alignment 1`] = ` " -┌───────┐ -│ Name  │ -├───────┤ -│ Alice │ -└───────┘ +┌───────┐ +│ Name  │ +├───────┤ +│ Alice │ +└───────┘ " `; @@ -106,13 +106,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 │ +└──────────┴──────────┘ " `; @@ -142,11 +142,11 @@ Another paragraph. exports[` > with 'Windows' line endings > handles a table at the end of the input 1`] = ` "Some text before. -┌─────┬─────┐ -│  A  │  B  │ -├─────┼─────┤ -│ 1 │ 2 │ -└─────┴─────┘ +┌─────┬─────┐ +│  A  │  B  │ +├─────┼─────┤ +│ 1 │ 2 │ +└─────┴─────┘ " `; @@ -169,23 +169,23 @@ exports[` > with 'Windows' line endings > renders a fenced co exports[` > with 'Windows' line endings > renders a single-column table 1`] = ` " -┌───────┐ -│ Name  │ -├───────┤ -│ Alice │ -├───────┤ -│ Bob │ -└───────┘ +┌───────┐ +│ Name  │ +├───────┤ +│ Alice │ +├───────┤ +│ Bob │ +└───────┘ " `; exports[` > with 'Windows' line endings > renders a single-column table with center alignment 1`] = ` " -┌───────┐ -│ Name  │ -├───────┤ -│ Alice │ -└───────┘ +┌───────┐ +│ Name  │ +├───────┤ +│ Alice │ +└───────┘ " `; @@ -221,13 +221,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 │ +└──────────┴──────────┘ " `; From 98651dc9b960331061fe5f18f33d527571c5fecb Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 06:46:54 +0800 Subject: [PATCH 03/13] fix(cli): address Copilot review comments on table rendering - renderRowLines: normalize cells to exactly colCount (pad/truncate) to prevent undefined access when row has fewer cells than headers - calculateMaxRowLines: iterate colCount instead of row.length to prevent undefined columnWidths access for extra cells - tableSeparatorRegex: add (?=.*\|) lookahead to require at least one pipe character, preventing `---` (horizontal rule) from being mis-parsed as a table separator - Add test: horizontal rule after pipe line is not a table separator Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 15 +++++++++++++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 6 ++++-- packages/cli/src/ui/utils/TableRenderer.tsx | 12 +++++++++--- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 4e04515969..63cf4ea983 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -228,6 +228,21 @@ next line 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 | diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index dbe050fa13..e185a512ff 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -43,10 +43,12 @@ 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 + const parseTableAligns = (line: string): ColumnAlign[] => + line .split(/(? cell.trim()) .filter((cell) => cell.length > 0) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index b18d239fe8..ab571cdd45 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -325,7 +325,7 @@ export const TableRenderer: React.FC = ({ maxLines = Math.max(maxLines, wrapped.length); } for (const row of rows) { - for (let i = 0; i < row.length; i++) { + for (let i = 0; i < colCount; i++) { const wrapped = wrapText( getCellPlainText(row[i] || ''), columnWidths[i]!, @@ -362,9 +362,15 @@ export const TableRenderer: React.FC = ({ // ── Build row lines as pure strings ── function renderRowLines(cells: string[], isHeader: boolean): string[] { + // Normalize cells to exactly colCount (pad missing, truncate extras) + const normalizedCells = Array.from( + { length: colCount }, + (_, i) => cells[i] || '', + ); + // Wrap each cell's formatted content. Preserve ANSI when possible. - const cellLines = cells.map((cell, colIndex) => - wrapText(getFormattedCellText(cell || ''), columnWidths[colIndex]!, { + const cellLines = normalizedCells.map((cell, colIndex) => + wrapText(getFormattedCellText(cell), columnWidths[colIndex]!, { hard: needsHardWrap, }), ); From 77f743791af86c277d47a359cfb59e579f13c3aa Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 06:56:30 +0800 Subject: [PATCH 04/13] fix(cli): address Copilot round-2 review on table rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - idealWidths: use getRenderedWidth() (markdown→ANSI→stripAnsi→stringWidth) instead of getPlainTextLength() so link URLs are accounted for in column width calculation - calculateMaxRowLines: use getFormattedCellText() (same as renderRowLines) so vertical fallback decision matches actual rendered row height - renderVerticalFormat: normalize row to colCount (pad/truncate) for consistency with horizontal format - renderVerticalFormat: render markdown in labels via renderMarkdownToAnsi() instead of showing raw syntax - Remove unused getCellPlainText helper and getPlainTextLength import Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 28 +++++++++++---------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index ab571cdd45..abbb82896d 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -8,7 +8,6 @@ import type React from 'react'; import { Text, Box } from 'ink'; import wrapAnsi from 'wrap-ansi'; import stripAnsi from 'strip-ansi'; -import { getPlainTextLength } from './InlineMarkdownRenderer.js'; import { getCachedStringWidth } from './textUtils.js'; import { theme } from '../semantic-colors.js'; @@ -260,10 +259,14 @@ export const TableRenderer: React.FC = ({ return maxMin; }); + // Use rendered width (after markdown→ANSI) so link URLs etc. are accounted for + const getRenderedWidth = (text: string): number => + getCachedStringWidth(stripAnsi(renderMarkdownToAnsi(text))); + const idealWidths = headers.map((header, colIndex) => { - let maxIdeal = Math.max(getPlainTextLength(header), MIN_COLUMN_WIDTH); + let maxIdeal = Math.max(getRenderedWidth(header), MIN_COLUMN_WIDTH); for (const row of rows) { - maxIdeal = Math.max(maxIdeal, getPlainTextLength(row[colIndex] || '')); + maxIdeal = Math.max(maxIdeal, getRenderedWidth(row[colIndex] || '')); } return maxIdeal; }); @@ -305,20 +308,17 @@ export const TableRenderer: React.FC = ({ ); } - // ── Helper: Get plain text for a cell (strips markdown + ANSI) ── - const getCellPlainText = (text: string): string => - stripAnsi(stripInlineMarkdown(text)); - // Render inline markdown to ANSI-styled text; preserve existing ANSI codes. const getFormattedCellText = (text: string): string => renderMarkdownToAnsi(text); // ── Step 4: Check max row lines to decide vertical fallback ── + // Use formatted text (same as renderRowLines) so row height estimate is accurate function calculateMaxRowLines(): number { let maxLines = 1; for (let i = 0; i < headers.length; i++) { const wrapped = wrapText( - getCellPlainText(headers[i]!), + getFormattedCellText(headers[i]!), columnWidths[i]!, { hard: needsHardWrap }, ); @@ -327,7 +327,7 @@ export const TableRenderer: React.FC = ({ for (const row of rows) { for (let i = 0; i < colCount; i++) { const wrapped = wrapText( - getCellPlainText(row[i] || ''), + getFormattedCellText(row[i] || ''), columnWidths[i]!, { hard: needsHardWrap }, ); @@ -424,9 +424,11 @@ export const TableRenderer: React.FC = ({ if (rowIndex > 0) { lines.push(separator); } - row.forEach((cell, colIndex) => { - const label = headers[colIndex] || `Column ${colIndex + 1}`; - const value = getFormattedCellText(cell || '') + // Normalize row to exactly colCount (consistent with horizontal format) + for (let colIndex = 0; colIndex < colCount; colIndex++) { + const rawLabel = headers[colIndex] || `Column ${colIndex + 1}`; + const label = renderMarkdownToAnsi(rawLabel); + const value = getFormattedCellText(row[colIndex] || '') .trim() .replace(/\n+/g, ' ') .replace(/\s+/g, ' ') @@ -435,7 +437,7 @@ export const TableRenderer: React.FC = ({ lines.push( `${applyColor(ansiFmt.bold(`${label}:`), theme.text.link)} ${value}`, ); - }); + } }); return lines.join('\n'); } From caf597c87340d9965fccb92656cb59df7e3af9c2 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 07:06:08 +0800 Subject: [PATCH 05/13] fix(cli): address Copilot round-3 review on table rendering - Early return empty when headers is empty (colCount === 0) to prevent malformed border output - Always apply theme.text.link color to header cells regardless of ANSI content, matching original Ink implementation behavior - Validate separator column count matches header column count before entering table mode, preventing mismatched separators like `| A | B |` followed by `|---|` from creating invalid tables - Add test for column count mismatch detection - Update 2 snapshots for consistent header link color Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 14 +++++++++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 23 ++++++++++++------- packages/cli/src/ui/utils/TableRenderer.tsx | 15 ++++++++---- .../MarkdownDisplay.test.tsx.snap | 20 +++++----------- 4 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 63cf4ea983..fc788e18c7 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -228,6 +228,20 @@ next line 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 | diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index e185a512ff..3c0edcc0e7 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -121,15 +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 diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index abbb82896d..0543b8954f 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -250,6 +250,11 @@ export const TableRenderer: React.FC = ({ }) => { const colCount = headers.length; + // Empty table — nothing to render + if (colCount === 0) { + return ; + } + // ── Step 1: Calculate min (longest word) and ideal (full content) widths ── const minColumnWidths = headers.map((header, colIndex) => { let maxMin = getMinWordWidth(header); @@ -399,11 +404,11 @@ export const TableRenderer: React.FC = ({ const padded = padAligned(lineText, displayWidth, width, align); if (isHeader) { - // Apply bold + link color for headers, matching original theme - const hasAnsi = lineText.includes('\x1b['); - const styledPadded = hasAnsi - ? ansiFmt.bold(padded) - : applyColor(ansiFmt.bold(padded), theme.text.link); + // Always apply bold + link color for headers, matching original theme + const styledPadded = applyColor( + ansiFmt.bold(padded), + theme.text.link, + ); line += ' ' + styledPadded + ' ' + borderPipe; } else { line += ' ' + padded + ' ' + borderPipe; 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 14f8f10c1d..6d719af3ce 100644 --- a/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap @@ -26,13 +26,9 @@ Another paragraph. exports[` > with 'Unix' line endings > handles a table at the end of the input 1`] = ` "Some text before. - -┌─────┬─────┐ -│  A  │  B  │ -├─────┼─────┤ -│ 1 │ 2 │ -└─────┴─────┘ -" +| A | B | +|---| +| 1 | 2 |" `; exports[` > with 'Unix' line endings > handles unclosed (pending) code blocks 1`] = `" 1 let y = 2;"`; @@ -141,13 +137,9 @@ Another paragraph. exports[` > with 'Windows' line endings > handles a table at the end of the input 1`] = ` "Some text before. - -┌─────┬─────┐ -│  A  │  B  │ -├─────┼─────┤ -│ 1 │ 2 │ -└─────┴─────┘ -" +| A | B | +|---| +| 1 | 2 |" `; exports[` > with 'Windows' line endings > handles unclosed (pending) code blocks 1`] = `" 1 let y = 2;"`; From 125d56a742e580bb1598df35cd8856a25e6beb37 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 07:17:58 +0800 Subject: [PATCH 06/13] fix(cli): address Copilot round-4 review on table rendering - getMinWordWidth: use renderMarkdownToAnsi output so link URLs are included as unbreakable tokens in minimum column width calculation - Remove now-unused stripInlineMarkdown function - Header alignment: respect explicit alignment markers from separator; only default to center when no alignment is specified for the column - Header color nesting: re-apply theme.text.link color after inner foreground resets (from inline code/links) to match Ink's nested color behavior where parent color is restored after child resets - Add getColorCode() helper for extracting raw ANSI color escape Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 56 +++++++++++++-------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 0543b8954f..93d2b8867b 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -30,21 +30,6 @@ interface TableRendererProps { aligns?: ColumnAlign[]; } -/** - * Strip inline markdown syntax from text to get plain content. - * Used for column width calculation and text wrapping. - */ -function stripInlineMarkdown(text: string): string { - return text - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*(.*?)\*/g, '$1') - .replace(/_(.*?)_/g, '$1') - .replace(/~~(.*?)~~/g, '$1') - .replace(/`(.*?)`/g, '$1') - .replace(/(.*?)<\/u>/g, '$1') - .replace(/\[(.*?)\]\(.*?\)/g, '$1'); -} - /** Map Ink-compatible named colors to ANSI foreground codes */ const INK_COLOR_TO_ANSI: Record = { black: 30, @@ -85,6 +70,24 @@ function applyColor(text: string, color: string): string { return text; } +/** Get raw ANSI foreground color escape (without reset) for re-application */ +function getColorCode(color: string): string { + if (!color) return ''; + if (color.startsWith('#')) { + 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 ''; +} + /** ANSI text formatting helpers (always produce escape codes, unlike chalk) */ const ansiFmt = { bold: (t: string) => `\x1b[1m${t}\x1b[22m`, @@ -220,7 +223,8 @@ function wrapText( * This determines the minimum column width to avoid breaking words. */ function getMinWordWidth(text: string): number { - const clean = stripAnsi(stripInlineMarkdown(text)); + // Use rendered text so link URLs are included as unbreakable tokens + const clean = stripAnsi(renderMarkdownToAnsi(text)); const words = clean.split(/\s+/).filter((w) => w.length > 0); if (words.length === 0) return MIN_COLUMN_WIDTH; return Math.max( @@ -399,14 +403,26 @@ export const TableRenderer: React.FC = ({ const width = columnWidths[colIndex]!; const displayWidth = getCachedStringWidth(stripAnsi(lineText)); - // Header row always center-aligned; data uses column alignment - const align = isHeader ? 'center' : getAlign(colIndex); + // 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); if (isHeader) { - // Always apply bold + link color for headers, matching original theme + // Apply bold + link color; re-apply link color after inner \x1b[39m + // resets (e.g. from inline `code`) to match Ink's nested color behavior + const linkCode = getColorCode(theme.text.link); + // Re-apply link color after inner foreground resets (\x1b[39m) + const fgReset = '\x1b[39m'; + const recolored = linkCode + ? padded.split(fgReset).join(fgReset + linkCode) + : padded; const styledPadded = applyColor( - ansiFmt.bold(padded), + ansiFmt.bold(recolored), theme.text.link, ); line += ' ' + styledPadded + ' ' + borderPipe; From 3a90134813985f66641848bb6039222c220ed1e6 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 07:27:20 +0800 Subject: [PATCH 07/13] fix(cli): address Copilot round-5 review on table rendering - Apply theme.text.primary color to non-header cells and re-apply after inner foreground resets, matching header recolor behavior - Use nullish coalescing (??) for vertical format labels so empty header strings are preserved instead of replaced with Column N Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 93d2b8867b..7a00248f80 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -412,12 +412,10 @@ export const TableRenderer: React.FC = ({ : 'left'; const padded = padAligned(lineText, displayWidth, width, align); + // Re-apply base color after inner foreground resets (\x1b[39m) + const fgReset = '\x1b[39m'; if (isHeader) { - // Apply bold + link color; re-apply link color after inner \x1b[39m - // resets (e.g. from inline `code`) to match Ink's nested color behavior const linkCode = getColorCode(theme.text.link); - // Re-apply link color after inner foreground resets (\x1b[39m) - const fgReset = '\x1b[39m'; const recolored = linkCode ? padded.split(fgReset).join(fgReset + linkCode) : padded; @@ -427,7 +425,14 @@ export const TableRenderer: React.FC = ({ ); line += ' ' + styledPadded + ' ' + borderPipe; } else { - line += ' ' + padded + ' ' + borderPipe; + const primaryCode = getColorCode(theme.text.primary); + const recolored = primaryCode + ? padded.split(fgReset).join(fgReset + primaryCode) + : padded; + const styledCell = primaryCode + ? applyColor(recolored, theme.text.primary) + : recolored; + line += ' ' + styledCell + ' ' + borderPipe; } } result.push(line); @@ -447,7 +452,7 @@ export const TableRenderer: React.FC = ({ } // Normalize row to exactly colCount (consistent with horizontal format) for (let colIndex = 0; colIndex < colCount; colIndex++) { - const rawLabel = headers[colIndex] || `Column ${colIndex + 1}`; + const rawLabel = headers[colIndex] ?? `Column ${colIndex + 1}`; const label = renderMarkdownToAnsi(rawLabel); const value = getFormattedCellText(row[colIndex] || '') .trim() From 7d78a1f91bcce61c5d9278c9e00b2722fb2a623f Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 07:43:08 +0800 Subject: [PATCH 08/13] fix(cli): re-apply cell color after full ANSI reset (\x1b[0m) Add recolorAfterResets() helper that handles both \x1b[39m (foreground reset) and \x1b[0m (full SGR reset). Applies to both header and body cells so mixed ANSI content keeps consistent theme coloring. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 7a00248f80..0ab97f74fc 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -88,6 +88,20 @@ function getColorCode(color: string): string { return ''; } +/** + * 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`, @@ -412,12 +426,11 @@ export const TableRenderer: React.FC = ({ : 'left'; const padded = padAligned(lineText, displayWidth, width, align); - // Re-apply base color after inner foreground resets (\x1b[39m) - const fgReset = '\x1b[39m'; + // Re-apply base color after any SGR reset (\x1b[39m or \x1b[0m) if (isHeader) { const linkCode = getColorCode(theme.text.link); const recolored = linkCode - ? padded.split(fgReset).join(fgReset + linkCode) + ? recolorAfterResets(padded, linkCode) : padded; const styledPadded = applyColor( ansiFmt.bold(recolored), @@ -427,7 +440,7 @@ export const TableRenderer: React.FC = ({ } else { const primaryCode = getColorCode(theme.text.primary); const recolored = primaryCode - ? padded.split(fgReset).join(fgReset + primaryCode) + ? recolorAfterResets(padded, primaryCode) : padded; const styledCell = primaryCode ? applyColor(recolored, theme.text.primary) From 98bb9fcda2cbd42c831d859e87491913d5113e49 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 08:04:02 +0800 Subject: [PATCH 09/13] fix(cli): apply recolorAfterResets to vertical format labels Vertical fallback labels with inline markdown (code, URLs) now re-apply link color after SGR resets, consistent with horizontal header/body cell behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 0ab97f74fc..b7bad6296e 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -473,8 +473,12 @@ export const TableRenderer: React.FC = ({ .replace(/\s+/g, ' ') .trim(); + const linkCode = getColorCode(theme.text.link); + const recoloredLabel = linkCode + ? recolorAfterResets(`${label}:`, linkCode) + : `${label}:`; lines.push( - `${applyColor(ansiFmt.bold(`${label}:`), theme.text.link)} ${value}`, + `${applyColor(ansiFmt.bold(recoloredLabel), theme.text.link)} ${value}`, ); } }); From b5f768998a14680b4034be1041ef2f0875f8be57 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 08:18:28 +0800 Subject: [PATCH 10/13] fix(cli): apply primary color to vertical format values Vertical fallback values now get theme.text.primary color with recolorAfterResets, consistent with horizontal body cell styling. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index b7bad6296e..b05418ffa2 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -477,8 +477,15 @@ export const TableRenderer: React.FC = ({ 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)} ${value}`, + `${applyColor(ansiFmt.bold(recoloredLabel), theme.text.link)} ${styledValue}`, ); } }); From bfebeac58930206d569947ac99927aaf94174520 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 08:30:44 +0800 Subject: [PATCH 11/13] fix(cli): preserve internal blank lines in wrapped cell content wrapText now only trims trailing empty lines (wrap-ansi artifacts) instead of filtering all empty lines, preserving intentional blank lines within multi-paragraph cell content. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index b05418ffa2..e8aa51cb06 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -228,7 +228,11 @@ function wrapText( trim: false, wordWrap: true, }); - const lines = wrapped.split('\n').filter((line) => line.length > 0); + 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 : ['']; } From cc819aff623bda7b8643e45dfcdaf3c34822f568 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 11:30:10 +0800 Subject: [PATCH 12/13] fix(cli): validate hex colors and deduplicate applyColor/getColorCode - Add HEX_COLOR_RE validation; invalid hex like #ff00 or #gg0000 now returns unchanged text instead of producing NaN in ANSI escapes - Refactor applyColor to delegate to getColorCode, eliminating duplicated hex parsing logic Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 26 +++++++-------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index e8aa51cb06..cdea1f71e1 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -52,28 +52,13 @@ const INK_COLOR_TO_ANSI: Record = { whitebright: 97, }; -/** Apply an Ink-compatible color (hex or named) to text via raw ANSI codes */ -function applyColor(text: string, color: string): string { - if (!color) return text; - if (color.startsWith('#')) { - 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${text}\x1b[39m`; - } - const code = INK_COLOR_TO_ANSI[color.toLowerCase()]; - if (code !== undefined) return `\x1b[${code}m${text}\x1b[39m`; - return text; -} +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]! @@ -88,6 +73,13 @@ function getColorCode(color: string): string { 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`; +} + /** * Re-apply a color code after any SGR sequence that resets foreground: * \x1b[39m (default foreground) and \x1b[0m (full reset). From a70e44e44cbe8df227b9943430fff1aa61b954f8 Mon Sep 17 00:00:00 2001 From: wenshao Date: Mon, 6 Apr 2026 11:40:21 +0800 Subject: [PATCH 13/13] fix(cli): precompute cell metrics and fix column width overflow - Precompute per-cell rendered text, visible width, and min word width once via computeMetrics(), eliminating repeated renderMarkdownToAnsi calls across width calculation, max-row-lines check, and rendering - Add post-pass in totalMin > availableWidth branch: shave wider columns until sum(columnWidths) <= availableWidth, preventing MIN_COLUMN_WIDTH floor from causing unnecessary vertical fallback - Remove now-unused getMinWordWidth standalone function Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/utils/TableRenderer.tsx | 134 +++++++++++--------- 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index cdea1f71e1..6d647980a1 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -228,21 +228,6 @@ function wrapText( return lines.length > 0 ? lines : ['']; } -/** - * Get the visual width of the longest word in a cell text. - * This determines the minimum column width to avoid breaking words. - */ -function getMinWordWidth(text: string): number { - // Use rendered text so link URLs are included as unbreakable tokens - const clean = stripAnsi(renderMarkdownToAnsi(text)); - const words = clean.split(/\s+/).filter((w) => w.length > 0); - if (words.length === 0) return MIN_COLUMN_WIDTH; - return Math.max( - ...words.map((w) => getCachedStringWidth(w)), - MIN_COLUMN_WIDTH, - ); -} - /** * Custom table renderer for markdown tables. * @@ -269,23 +254,45 @@ export const TableRenderer: React.FC = ({ 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((header, colIndex) => { - let maxMin = getMinWordWidth(header); - for (const row of rows) { - maxMin = Math.max(maxMin, getMinWordWidth(row[colIndex] || '')); + const minColumnWidths = headers.map((_, colIndex) => { + let maxMin = headerMetrics[colIndex]!.minWordWidth; + for (const row of rowMetrics) { + maxMin = Math.max(maxMin, row[colIndex]!.minWordWidth); } return maxMin; }); - // Use rendered width (after markdown→ANSI) so link URLs etc. are accounted for - const getRenderedWidth = (text: string): number => - getCachedStringWidth(stripAnsi(renderMarkdownToAnsi(text))); - - const idealWidths = headers.map((header, colIndex) => { - let maxIdeal = Math.max(getRenderedWidth(header), MIN_COLUMN_WIDTH); - for (const row of rows) { - maxIdeal = Math.max(maxIdeal, getRenderedWidth(row[colIndex] || '')); + const idealWidths = headers.map((_, colIndex) => { + let maxIdeal = Math.max( + headerMetrics[colIndex]!.renderedWidth, + MIN_COLUMN_WIDTH, + ); + for (const row of rowMetrics) { + maxIdeal = Math.max(maxIdeal, row[colIndex]!.renderedWidth); } return maxIdeal; }); @@ -325,31 +332,33 @@ export const TableRenderer: React.FC = ({ 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; + } } - // Render inline markdown to ANSI-styled text; preserve existing ANSI codes. - const getFormattedCellText = (text: string): string => - renderMarkdownToAnsi(text); - // ── Step 4: Check max row lines to decide vertical fallback ── - // Use formatted text (same as renderRowLines) so row height estimate is accurate function calculateMaxRowLines(): number { let maxLines = 1; - for (let i = 0; i < headers.length; i++) { - const wrapped = wrapText( - getFormattedCellText(headers[i]!), - columnWidths[i]!, - { hard: needsHardWrap }, - ); + 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 rows) { + for (const row of rowMetrics) { for (let i = 0; i < colCount; i++) { - const wrapped = wrapText( - getFormattedCellText(row[i] || ''), - columnWidths[i]!, - { hard: needsHardWrap }, - ); + const wrapped = wrapText(row[i]!.rendered, columnWidths[i]!, { + hard: needsHardWrap, + }); maxLines = Math.max(maxLines, wrapped.length); } } @@ -380,18 +389,13 @@ export const TableRenderer: React.FC = ({ } // ── Build row lines as pure strings ── - function renderRowLines(cells: string[], isHeader: boolean): string[] { - // Normalize cells to exactly colCount (pad missing, truncate extras) - const normalizedCells = Array.from( - { length: colCount }, - (_, i) => cells[i] || '', - ); - - // Wrap each cell's formatted content. Preserve ANSI when possible. - const cellLines = normalizedCells.map((cell, colIndex) => - wrapText(getFormattedCellText(cell), columnWidths[colIndex]!, { - hard: needsHardWrap, - }), + // 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 }), ); const maxLines = Math.max(...cellLines.map((l) => l.length), 1); @@ -455,16 +459,14 @@ export const TableRenderer: React.FC = ({ const separatorWidth = Math.max(Math.min(contentWidth - 1, 40), 0); const separator = separatorWidth > 0 ? '─'.repeat(separatorWidth) : ''; - rows.forEach((row, rowIndex) => { + rowMetrics.forEach((row, rowIndex) => { if (rowIndex > 0) { lines.push(separator); } - // Normalize row to exactly colCount (consistent with horizontal format) for (let colIndex = 0; colIndex < colCount; colIndex++) { const rawLabel = headers[colIndex] ?? `Column ${colIndex + 1}`; const label = renderMarkdownToAnsi(rawLabel); - const value = getFormattedCellText(row[colIndex] || '') - .trim() + const value = row[colIndex]!.rendered.trim() .replace(/\n+/g, ' ') .replace(/\s+/g, ' ') .trim(); @@ -498,12 +500,18 @@ export const TableRenderer: React.FC = ({ } // ── Build the complete horizontal table as strings ── + const headerRendered = headerMetrics.map((m) => m.rendered); const tableLines: string[] = []; tableLines.push(renderBorderLine('top')); - tableLines.push(...renderRowLines(headers, true)); + tableLines.push(...renderRowLines(headerRendered, true)); tableLines.push(renderBorderLine('middle')); - rows.forEach((row, rowIndex) => { - tableLines.push(...renderRowLines(row, false)); + rowMetrics.forEach((row, rowIndex) => { + tableLines.push( + ...renderRowLines( + row.map((m) => m.rendered), + false, + ), + ); if (rowIndex < rows.length - 1) { tableLines.push(renderBorderLine('middle')); }