mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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
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:
parent
9a636ef812
commit
ec1787b846
6 changed files with 1230 additions and 145 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
486
packages/cli/src/ui/utils/TableRenderer.test.tsx
Normal file
486
packages/cli/src/ui/utils/TableRenderer.test.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`] = `
|
||||
"
|
||||
[38;2;108;112;134m┌───────┐[39m
|
||||
[38;2;108;112;134m│[39m [38;2;137;180;250m[1mName [22m[39m [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├───────┤[39m
|
||||
[38;2;108;112;134m│[39m Alice [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├───────┤[39m
|
||||
[38;2;108;112;134m│[39m Bob [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m└───────┘[39m
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders a single-column table with center alignment 1`] = `
|
||||
"
|
||||
[38;2;108;112;134m┌───────┐[39m
|
||||
[38;2;108;112;134m│[39m [38;2;137;180;250m[1mName [22m[39m [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├───────┤[39m
|
||||
[38;2;108;112;134m│[39m Alice [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m└───────┘[39m
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<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 │
|
||||
└──────────┴──────────┘
|
||||
[38;2;108;112;134m┌──────────┬──────────┐[39m
|
||||
[38;2;108;112;134m│[39m [38;2;137;180;250m[1mHeader 1[22m[39m [38;2;108;112;134m│[39m [38;2;137;180;250m[1mHeader 2[22m[39m [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├──────────┼──────────┤[39m
|
||||
[38;2;108;112;134m│[39m Cell 1 [38;2;108;112;134m│[39m Cell 2 [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├──────────┼──────────┤[39m
|
||||
[38;2;108;112;134m│[39m Cell 3 [38;2;108;112;134m│[39m Cell 4 [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m└──────────┴──────────┘[39m
|
||||
"
|
||||
`;
|
||||
|
||||
|
|
@ -136,6 +159,28 @@ exports[`<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`] = `
|
||||
"
|
||||
[38;2;108;112;134m┌───────┐[39m
|
||||
[38;2;108;112;134m│[39m [38;2;137;180;250m[1mName [22m[39m [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├───────┤[39m
|
||||
[38;2;108;112;134m│[39m Alice [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├───────┤[39m
|
||||
[38;2;108;112;134m│[39m Bob [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m└───────┘[39m
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders a single-column table with center alignment 1`] = `
|
||||
"
|
||||
[38;2;108;112;134m┌───────┐[39m
|
||||
[38;2;108;112;134m│[39m [38;2;137;180;250m[1mName [22m[39m [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├───────┤[39m
|
||||
[38;2;108;112;134m│[39m Alice [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m└───────┘[39m
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<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 │
|
||||
└──────────┴──────────┘
|
||||
[38;2;108;112;134m┌──────────┬──────────┐[39m
|
||||
[38;2;108;112;134m│[39m [38;2;137;180;250m[1mHeader 1[22m[39m [38;2;108;112;134m│[39m [38;2;137;180;250m[1mHeader 2[22m[39m [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├──────────┼──────────┤[39m
|
||||
[38;2;108;112;134m│[39m Cell 1 [38;2;108;112;134m│[39m Cell 2 [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m├──────────┼──────────┤[39m
|
||||
[38;2;108;112;134m│[39m Cell 3 [38;2;108;112;134m│[39m Cell 4 [38;2;108;112;134m│[39m
|
||||
[38;2;108;112;134m└──────────┴──────────┘[39m
|
||||
"
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue