-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat(output): optimize table formatting with width capping and key/value layout #1081
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
|
||
| 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
|
||
|
|
||
| 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
|
||
|
|
||
| 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); | ||
|
|
||
There was a problem hiding this comment.
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.tscurrently 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).