diff --git a/src/output.test.ts b/src/output.test.ts index 3f4be0c3..3486705f 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render } from './output.js'; +function stripAnsi(str: string): string { + return str.replace(/\u001B\[[0-9;]*m/g, ''); +} + describe('output TTY detection', () => { const originalIsTTY = process.stdout.isTTY; + const originalColumns = process.stdout.columns; let logSpy: ReturnType; beforeEach(() => { @@ -11,6 +16,7 @@ describe('output TTY detection', () => { afterEach(() => { Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); + Object.defineProperty(process.stdout, 'columns', { value: originalColumns, writable: true }); logSpy.mockRestore(); }); @@ -45,4 +51,45 @@ describe('output TTY detection', () => { expect(out).not.toContain('name: alice'); expect(out).toContain('alice'); }); + + it('renders single-row table output as key/value pairs', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + render( + [{ name: 'alice', score: 10, description: 'single row detail' }], + { fmt: 'table', columns: ['name', 'score', 'description'], title: 'Sample' }, + ); + const out = stripAnsi(logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n')); + expect(out).toContain('Sample'); + expect(out).toContain(' Name alice'); + expect(out).toContain(' Score 10'); + expect(out).toContain(' Description single row detail'); + expect(out).toContain('1 items'); + }); + + it('caps wide table columns to terminal width and truncates long values', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + Object.defineProperty(process.stdout, 'columns', { value: 40, writable: true }); + render( + [ + { + name: 'alpha', + status: 'ok', + description: 'This is a very long description that should wrap cleanly in a narrow terminal width.', + }, + { + name: 'beta', + status: 'warn', + description: 'Another long description that should also wrap instead of making the table extremely wide.', + }, + ], + { fmt: 'table', columns: ['name', 'status', 'description'] }, + ); + const out = stripAnsi(logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n')); + expect(out).toContain('This is a very l...'); + expect(out).toContain('Another long des...'); + expect(out).not.toContain('terminal width.'); + + const maxLineLength = out.split('\n').reduce((max: number, line: string) => Math.max(max, line.length), 0); + expect(maxLineLength).toBeLessThanOrEqual(40); + }); }); diff --git a/src/output.ts b/src/output.ts index 746f8796..4d90a89d 100644 --- a/src/output.ts +++ b/src/output.ts @@ -47,31 +47,166 @@ 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; +} + +function truncateToWidth(str: string, maxWidth: number): string { + if (maxWidth <= 0 || displayWidth(str) <= maxWidth) return str; + + const ellipsis = '...'; + const ellipsisWidth = displayWidth(ellipsis); + if (maxWidth <= ellipsisWidth) return ellipsis.slice(0, maxWidth); + + let out = ''; + let width = 0; + for (const ch of str) { + const nextWidth = displayWidth(ch); + if (width + nextWidth + ellipsisWidth > maxWidth) break; + out += ch; + width += nextWidth; + } + + return out + ellipsis; +} + +// ── 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; + } + + const cells: string[][] = rows.map(row => + columns.map(c => { + const v = (row as Record)[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(colCount).fill(0); + const totalCounts = new Array(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); + } + } + + 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); + } + } + const table = new Table({ head: header.map(h => styleText('bold', h)), style: { head: [], border: [] }, - wordWrap: true, - wrapOnWordBoundary: true, + colWidths: colContentWidths, + colAligns, }); - for (const row of rows) { - table.push(columns.map(c => { - const v = (row as Record)[c]; - return v === null || v === undefined ? '' : String(v); - })); + for (const row of cells) { + table.push(row.map((cell, ci) => truncateToWidth(cell, colContentWidths[ci] - 2))); } console.log(); if (opts.title) console.log(styleText('dim', ` ${opts.title}`)); console.log(table.toString()); + printFooter(rows.length, opts); +} + +function renderKeyValue(row: Record, 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);