Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 124 additions & 6 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,31 +47,149 @@ export function render(data: unknown, opts: RenderOptions = {}): void {
}
}

// ── CJK-aware string width ──

function isWideCodePoint(cp: number): boolean {
return (
(cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs
(cp >= 0x3400 && cp <= 0x4DBF) || // CJK Extension A
(cp >= 0x20000 && cp <= 0x2A6DF) || // CJK Extension B
(cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
(cp >= 0xFF01 && cp <= 0xFF60) || // Fullwidth Forms
(cp >= 0xFFE0 && cp <= 0xFFE6) || // Fullwidth Signs
(cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables
(cp >= 0x3000 && cp <= 0x303F) || // CJK Symbols
(cp >= 0x3040 && cp <= 0x309F) || // Hiragana
(cp >= 0x30A0 && cp <= 0x30FF) // Katakana
);
}

function displayWidth(str: string): number {
let w = 0;
for (const ch of str) {
w += isWideCodePoint(ch.codePointAt(0)!) ? 2 : 1;
}
return w;
}

// ── Table rendering ──

// Fits typical date, status, and ID columns without truncation.
const SHORT_COL_THRESHOLD = 15;

const NUMERIC_RE = /^-?[\d,]+\.?\d*$/;

function renderTable(data: unknown, opts: RenderOptions): void {
const rows = normalizeRows(data);
if (!rows.length) { console.log(styleText('dim', '(no data)')); return; }
const columns = resolveColumns(rows, opts);

if (rows.length === 1) {
renderKeyValue(rows[0], columns, opts);
return;
}
Comment on lines +87 to +90
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR introduces several new output behaviors (single-row key/value rendering, numeric alignment inference, terminal-width capping + truncation) but src/output.test.ts currently only asserts TTY auto-downgrade behavior. Adding focused tests for these new formatting rules would help prevent regressions (e.g., a snapshot-style assertion for truncation/ellipsis and for the key/value layout).

Copilot uses AI. Check for mistakes.

const cells: string[][] = rows.map(row =>
columns.map(c => {
const v = (row as Record<string, unknown>)[c];
return v === null || v === undefined ? '' : String(v);
}),
);

const header = columns.map(c => capitalize(c));
const colCount = columns.length;

// Single pass: measure column widths + detect numeric columns
const colContentWidths = header.map(h => displayWidth(h));
const numericCounts = new Array<number>(colCount).fill(0);
const totalCounts = new Array<number>(colCount).fill(0);

for (const row of cells) {
for (let ci = 0; ci < colCount; ci++) {
const w = displayWidth(row[ci]);
if (w > colContentWidths[ci]) colContentWidths[ci] = w;
const v = row[ci].trim();
if (v) {
totalCounts[ci]++;
if (NUMERIC_RE.test(v)) numericCounts[ci]++;
}
}
}

const colAligns: Array<'left' | 'right'> = columns.map((_, ci) =>
totalCounts[ci] > 0 && numericCounts[ci] / totalCounts[ci] > 0.8 ? 'right' : 'left',
);

// Calculate column widths to fit terminal.
// cli-table3 colWidths includes cell padding (1 space each side).
const termWidth = process.stdout.columns || 120;
// Border chars: '│' between every column + edges = colCount + 1
const borderOverhead = colCount + 1;
const availableWidth = Math.max(termWidth - borderOverhead, colCount * 5);

let shortTotal = 0;
const longIndices: number[] = [];

for (let i = 0; i < colCount; i++) {
// +2 for cell padding (1 space each side)
const padded = colContentWidths[i] + 2;
if (colContentWidths[i] <= SHORT_COL_THRESHOLD) {
colContentWidths[i] = padded;
shortTotal += padded;
} else {
longIndices.push(i);
}
}
Comment on lines +133 to +142
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The width allocation logic only caps columns whose measured width is above SHORT_COL_THRESHOLD; “short” columns always keep their full content width. With many columns (or a narrow terminal), the sum of short columns alone can exceed the available width, so the table will still overflow even when there are no “long” columns to shrink. Consider adding a fallback pass that enforces a global width budget (e.g., iteratively shrink the widest columns above a minimum width until the sum fits).

Copilot uses AI. Check for mistakes.

const remainingWidth = availableWidth - shortTotal;
if (longIndices.length > 0) {
const perLong = Math.max(Math.floor(remainingWidth / longIndices.length), 12);
for (const i of longIndices) {
colContentWidths[i] = Math.min(colContentWidths[i] + 2, perLong);
}
}
Comment on lines +144 to +150
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

colWidths is only assigned for the “long” columns when remainingWidth > 0. If remainingWidth <= 0, the entries for longIndices remain undefined, and later cellWidths[ri][ci] > colWidths[ci] will never be true (because undefined becomes NaN in the comparison), so no truncation happens for those columns. Ensure every column gets an explicit width (even under tight width budgets), and then truncate based on that.

Copilot uses AI. Check for mistakes.

const table = new Table({
head: header.map(h => styleText('bold', h)),
style: { head: [], border: [] },
colWidths: colContentWidths,
colAligns,
wordWrap: true,
wrapOnWordBoundary: true,
});

for (const row of rows) {
table.push(columns.map(c => {
const v = (row as Record<string, unknown>)[c];
return v === null || v === undefined ? '' : String(v);
}));
for (const row of cells) {
table.push(row);
}

console.log();
if (opts.title) console.log(styleText('dim', ` ${opts.title}`));
console.log(table.toString());
printFooter(rows.length, opts);
}

function renderKeyValue(row: Record<string, unknown>, columns: string[], opts: RenderOptions): void {
const entries = columns.map(c => ({
key: capitalize(c),
value: row[c] === null || row[c] === undefined ? '' : String(row[c]),
}));

const maxKeyWidth = Math.max(...entries.map(e => displayWidth(e.key)));

console.log();
if (opts.title) console.log(styleText('dim', ` ${opts.title}`));
console.log();
for (const { key, value } of entries) {
const padding = ' '.repeat(maxKeyWidth - displayWidth(key));
console.log(` ${styleText('bold', key)}${padding} ${value}`);
}
console.log();
printFooter(1, opts);
}

function printFooter(count: number, opts: RenderOptions): void {
const footer: string[] = [];
footer.push(`${rows.length} items`);
footer.push(`${count} items`);
if (opts.elapsed) footer.push(`${opts.elapsed.toFixed(1)}s`);
if (opts.source) footer.push(opts.source);
if (opts.footerExtra) footer.push(opts.footerExtra);
Expand Down