fix(cli): improve markdown table rendering in terminal (#2914)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* fix(cli): improve markdown table rendering in terminal

* 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~~, <u>underline</u>, [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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* fix(cli): address Copilot round-2 review on table rendering

- 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) <noreply@anthropic.com>

* fix(cli): address Copilot round-3 review on table rendering

- Early return empty <Box /> 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Shaojin Wen 2026-04-11 11:10:01 +08:00 committed by GitHub
parent 9a636ef812
commit ec1787b846
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1230 additions and 145 deletions

View file

@ -155,6 +155,149 @@ Some text before.
expect(lastFrame()).toMatchSnapshot();
});
it('renders a single-column table', () => {
const text = `
| Name |
|---|
| Alice |
| Bob |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('Name');
expect(output).toContain('Alice');
expect(output).toContain('Bob');
expect(output).toContain('┌');
expect(output).toContain('└');
expect(output).toMatchSnapshot();
});
it('renders a single-column table with center alignment', () => {
const text = `
| Name |
|:---:|
| Alice |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toContain('Alice');
expect(lastFrame()).toMatchSnapshot();
});
it('handles escaped pipes in table cells', () => {
const text = `
| Name | Value |
|---|---|
| A \\| B | C |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('A | B');
expect(output).toContain('C');
});
it('does not treat a lone table-like line as a table', () => {
const text = `
| just text |
next line
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('| just text |');
expect(output).not.toContain('┌');
});
it('does not treat invalid separator as a table separator', () => {
const text = `
| A | B |
| x | y |
| 1 | 2 |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('| A | B |');
expect(output).not.toContain('┌');
});
it('does not treat separator with mismatched column count as a table', () => {
const text = `
| A | B |
|---|
| 1 | 2 |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('| A | B |');
expect(output).not.toContain('┌');
});
it('does not treat a horizontal rule after a pipe line as a table separator', () => {
const text = `
| Header |
---
data
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
// `---` without any `|` is a horizontal rule, not a table separator
expect(output).toContain('| Header |');
expect(output).not.toContain('┌');
});
it('ends a table when a blank line appears', () => {
const text = `
| A | B |
|---|---|
| 1 | 2 |
After
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('┌');
expect(output).toContain('After');
});
it('does not treat separator-only text without header row as a table', () => {
const text = `
|---|---|
plain
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
expect(output).toContain('|---|---|');
expect(output).not.toContain('┌');
});
it('does not crash on uneven escaped pipes near row edges', () => {
const text = `
| A | B |
|---|---|
| \\| edge | ok |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toContain('| edge');
});
it('inserts a single space between paragraphs', () => {
const text = `Paragraph 1.

View file

@ -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<MarkdownDisplayProps> = ({
const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/;
const hrRegex = /^ *([-*_] *){3,} *$/;
const tableRowRegex = /^\s*\|(.+)\|\s*$/;
const tableSeparatorRegex = /^\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)+\|?\s*$/;
const tableSeparatorRegex =
/^(?=.*\|)\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)*\|?\s*$/;
/** Parse column alignments from a markdown table separator like `|:---|:---:|---:|` */
const parseTableAligns = (line: string): ColumnAlign[] =>
line
.split(/(?<!\\)\|/)
.map((cell) => cell.trim())
.filter((cell) => cell.length > 0)
.map((cell) => {
const startsWithColon = cell.startsWith(':');
const endsWithColon = cell.endsWith(':');
if (startsWithColon && endsWithColon) return 'center';
if (endsWithColon) return 'right';
return 'left';
});
const contentBlocks: React.ReactNode[] = [];
let inCodeBlock = false;
@ -54,6 +69,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
let inTable = false;
let tableRows: string[][] = [];
let tableHeaders: string[] = [];
let tableAligns: ColumnAlign[] = [];
function addContentBlock(block: React.ReactNode) {
if (block) {
@ -105,15 +121,22 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
codeBlockFence = codeFenceMatch[1];
codeBlockLang = codeFenceMatch[2] || null;
} else if (tableRowMatch && !inTable) {
// Potential table start - check if next line is separator
if (
index + 1 < lines.length &&
lines[index + 1].match(tableSeparatorRegex)
) {
// Potential table start - check if next line is separator with matching column count
const potentialHeaders = tableRowMatch[1]
.split(/(?<!\\)\|/)
.map((cell) => cell.trim().replaceAll('\\|', '|'));
const nextLine = index + 1 < lines.length ? lines[index + 1]! : '';
const sepMatch = nextLine.match(tableSeparatorRegex);
const sepColCount = sepMatch
? nextLine
.split(/(?<!\\)\|/)
.map((c) => c.trim())
.filter((c) => c.length > 0).length
: 0;
if (sepMatch && sepColCount === potentialHeaders.length) {
inTable = true;
tableHeaders = tableRowMatch[1]
.split(/(?<!\\)\|/)
.map((cell) => cell.trim().replaceAll('\\|', '|'));
tableHeaders = potentialHeaders;
tableRows = [];
} else {
// Not a table, treat as regular text
@ -126,7 +149,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
);
}
} else if (inTable && tableSeparatorMatch) {
// Skip separator line - already handled
// Parse alignment from separator line
tableAligns = parseTableAligns(line);
} else if (inTable && tableRowMatch) {
// Add table row
const cells = tableRowMatch[1]
@ -149,12 +173,14 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
headers={tableHeaders}
rows={tableRows}
contentWidth={contentWidth}
aligns={tableAligns}
/>,
);
}
inTable = false;
tableRows = [];
tableHeaders = [];
tableAligns = [];
// Process current line as normal
if (line.trim().length > 0) {
@ -283,6 +309,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
headers={tableHeaders}
rows={tableRows}
contentWidth={contentWidth}
aligns={tableAligns}
/>,
);
}
@ -412,14 +439,21 @@ interface RenderTableProps {
headers: string[];
rows: string[][];
contentWidth: number;
aligns?: ColumnAlign[];
}
const RenderTableInternal: React.FC<RenderTableProps> = ({
headers,
rows,
contentWidth,
aligns,
}) => (
<TableRenderer headers={headers} rows={rows} contentWidth={contentWidth} />
<TableRenderer
headers={headers}
rows={rows}
contentWidth={contentWidth}
aligns={aligns}
/>
);
const RenderTable = React.memo(RenderTableInternal);

View file

@ -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('<TableRenderer />', () => {
const renderTable = (
headers: string[],
rows: string[][],
contentWidth = 80,
aligns?: ColumnAlign[],
) => {
const { lastFrame } = renderWithProviders(
<TableRenderer
headers={headers}
rows={rows}
contentWidth={contentWidth}
aligns={aligns}
/>,
);
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'], [['<u>underlined</u> 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);
}
}
});
});

View file

@ -4,159 +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<string, number> = {
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>.*?<\/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('<u>') &&
fullMatch.endsWith('</u>') &&
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<TableRendererProps> = ({
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;
// 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),
// Empty table — nothing to render
if (colCount === 0) {
return <Box />;
}
// ── 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] || '')),
);
// 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);
// ── 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;
});
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;
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;
});
// 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);
// ── 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,
);
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);
cellContent = bestTruncated + '...';
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;
}
}
// ── 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 (
<Text>
{isHeader ? (
<Text bold color={theme.text.link}>
<RenderInline text={cellContent} />
</Text>
) : (
<RenderInline text={cellContent} />
)}
{' '.repeat(paddingNeeded)}
</Text>
);
};
// ── Helper: Get alignment for a column ──
const getAlign = (colIndex: number): ColumnAlign =>
aligns?.[colIndex] ?? 'left';
// 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: '─' },
};
// ── 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];
const char = chars[type];
const borderParts = adjustedWidths.map((w) => char.horizontal.repeat(w));
const border = char.left + borderParts.join(char.middle) + char.right;
return <Text color={theme.border.default}>{border}</Text>;
};
// 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);
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);
}
return (
<Text color={theme.text.primary}>
{' '}
{renderedCells.map((cell, index) => (
<React.Fragment key={index}>
{cell}
{index < renderedCells.length - 1 ? ' │ ' : ''}
</React.Fragment>
))}{' '}
</Text>
// ── 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 }),
);
};
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 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]!
: '';
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);
// 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 (
<Box marginY={1}>
<Text>{renderVerticalFormat()}</Text>
</Box>
);
}
// ── 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'));
// ── 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 (
<Box marginY={1}>
<Text>{renderVerticalFormat()}</Text>
</Box>
);
}
// Render as a single Text block to prevent Ink wrapping mid-row
return (
<Box flexDirection="column" marginY={1}>
{/* Top border */}
{renderBorder('top')}
{/* Header row */}
{renderRow(headers, true)}
{/* Middle border */}
{renderBorder('middle')}
{/* Data rows */}
{rows.map((row, index) => (
<React.Fragment key={index}>{renderRow(row)}</React.Fragment>
))}
{/* Bottom border */}
{renderBorder('bottom')}
<Text>{tableLines.join('\n')}</Text>
</Box>
);
};

View file

@ -48,6 +48,28 @@ exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders a fenced code
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders a fenced code block without a language 1`] = `" 1 plain text"`;
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders a single-column table 1`] = `
"
┌───────┐
│ Name  │
├───────┤
│ Alice │
├───────┤
│ Bob │
└───────┘
"
`;
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders a single-column table with center alignment 1`] = `
"
┌───────┐
│ Name  │
├───────┤
│ Alice │
└───────┘
"
`;
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders headers with correct levels 1`] = `
"Header 1
Header 2
@ -80,12 +102,13 @@ exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders ordered lists
exports[`<MarkdownDisplay /> > 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[`<MarkdownDisplay /> > with 'Windows' line endings > renders a fenced co
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders a fenced code block without a language 1`] = `" 1 plain text"`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders a single-column table 1`] = `
"
┌───────┐
│ Name  │
├───────┤
│ Alice │
├───────┤
│ Bob │
└───────┘
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders a single-column table with center alignment 1`] = `
"
┌───────┐
│ Name  │
├───────┤
│ Alice │
└───────┘
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders headers with correct levels 1`] = `
"Header 1
Header 2
@ -168,12 +213,13 @@ exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders ordered lis
exports[`<MarkdownDisplay /> > 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 │
└──────────┴──────────┘
"
`;

View file

@ -182,9 +182,7 @@ export async function persistPermissionOutcome(
}
const scope =
outcome === ToolConfirmationOutcome.ProceedAlwaysUser
? 'user'
: 'project';
outcome === ToolConfirmationOutcome.ProceedAlwaysUser ? 'user' : 'project';
// Read permissionRules from the stored confirmation details first,
// falling back to payload for backward compatibility.