From 70109e11c31c509d63de81e30d4fdcd44a3e2d0f Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Fri, 20 Mar 2026 16:30:03 -0300 Subject: [PATCH 1/5] feat: terminal-width-aware table rendering with card layout fallback renderTable now detects terminal width and either proportionally shrinks columns or switches to a vertical card layout when columns would become too narrow to be useful (below 12 chars). ID columns are marked as fixed to prevent truncation. When piped, tables render at full natural width. --- src/commands/api-keys/utils.ts | 6 +- src/commands/broadcasts/utils.ts | 1 + src/commands/contact-properties/utils.ts | 1 + src/commands/contacts/utils.ts | 2 + src/commands/domains/utils.ts | 32 +++- src/commands/emails/list.ts | 1 + src/commands/emails/receiving/utils.ts | 2 + src/commands/segments/utils.ts | 6 +- src/commands/templates/utils.ts | 1 + src/commands/topics/utils.ts | 1 + src/commands/webhooks/utils.ts | 1 + src/lib/table.ts | 81 +++++++- tests/commands/domains/utils.test.ts | 118 ++++++++++++ tests/lib/table.test.ts | 230 ++++++++++++++++++++++- 14 files changed, 470 insertions(+), 13 deletions(-) create mode 100644 tests/commands/domains/utils.test.ts diff --git a/src/commands/api-keys/utils.ts b/src/commands/api-keys/utils.ts index 4b04aea0..a80eb696 100644 --- a/src/commands/api-keys/utils.ts +++ b/src/commands/api-keys/utils.ts @@ -4,5 +4,9 @@ export function renderApiKeysTable( keys: Array<{ id: string; name: string; created_at: string }>, ): string { const rows = keys.map((k) => [k.name, k.id, k.created_at]); - return renderTable(['Name', 'ID', 'Created'], rows, '(no API keys)'); + return renderTable(['Name', 'ID', 'Created'], rows, '(no API keys)', [ + {}, + { fixed: true }, + {}, + ]); } diff --git a/src/commands/broadcasts/utils.ts b/src/commands/broadcasts/utils.ts index f738de80..89b36a6c 100644 --- a/src/commands/broadcasts/utils.ts +++ b/src/commands/broadcasts/utils.ts @@ -31,5 +31,6 @@ export function renderBroadcastsTable( ['Name', 'Status', 'Created', 'ID'], rows, '(no broadcasts)', + [{}, {}, {}, { fixed: true }], ); } diff --git a/src/commands/contact-properties/utils.ts b/src/commands/contact-properties/utils.ts index 3cb9f22d..136c91f9 100644 --- a/src/commands/contact-properties/utils.ts +++ b/src/commands/contact-properties/utils.ts @@ -13,5 +13,6 @@ export function renderContactPropertiesTable(props: ContactProperty[]): string { ['Key', 'Type', 'Fallback Value', 'ID', 'Created'], rows, '(no contact properties)', + [{}, {}, {}, { fixed: true }, {}], ); } diff --git a/src/commands/contacts/utils.ts b/src/commands/contacts/utils.ts index 86c121df..b5899ca5 100644 --- a/src/commands/contacts/utils.ts +++ b/src/commands/contacts/utils.ts @@ -25,6 +25,7 @@ export function renderContactsTable( ['Email', 'First Name', 'Last Name', 'Unsubscribed', 'ID'], rows, '(no contacts)', + [{}, {}, {}, {}, { fixed: true }], ); } @@ -39,6 +40,7 @@ export function renderContactTopicsTable(topics: ContactTopic[]): string { ['Name', 'Subscription', 'ID', 'Description'], rows, '(no topic subscriptions)', + [{}, {}, { fixed: true }, {}], ); } diff --git a/src/commands/domains/utils.ts b/src/commands/domains/utils.ts index 75f3b15b..cd55c6db 100644 --- a/src/commands/domains/utils.ts +++ b/src/commands/domains/utils.ts @@ -1,30 +1,46 @@ import type { DomainRecords } from 'resend'; import { renderTable } from '../../lib/table'; +import { isUnicodeSupported } from '../../lib/tty'; + +const h = isUnicodeSupported ? String.fromCodePoint(0x2500) : '-'; export function renderDnsRecordsTable( records: DomainRecords[], domainName: string, ): string { - const rows = records.map((r) => { + if (records.length === 0) { + return '(no DNS records)'; + } + + const cards = records.map((r) => { const displayName = r.name ? r.name.includes('.') ? r.name : `${r.name}.${domainName}` : domainName; - return [r.type, displayName, r.ttl, r.value]; + + const separator = `${h}${h} ${r.type} ${h.repeat(40)}`; + return [ + separator, + ` Name ${displayName}`, + ` TTL ${r.ttl}`, + ` Value ${r.value}`, + ].join('\n'); }); - return renderTable( - ['Type', 'Name', 'TTL', 'Value'], - rows, - '(no DNS records)', - ); + + return cards.join('\n\n'); } export function renderDomainsTable( domains: Array<{ id: string; name: string; status: string; region: string }>, ): string { const rows = domains.map((d) => [d.name, d.status, d.region, d.id]); - return renderTable(['Name', 'Status', 'Region', 'ID'], rows, '(no domains)'); + return renderTable(['Name', 'Status', 'Region', 'ID'], rows, '(no domains)', [ + {}, + {}, + {}, + { fixed: true }, + ]); } export function statusIndicator(status: string): string { diff --git a/src/commands/emails/list.ts b/src/commands/emails/list.ts index 40aea7aa..4d3565b6 100644 --- a/src/commands/emails/list.ts +++ b/src/commands/emails/list.ts @@ -31,6 +31,7 @@ function renderSentEmailsTable(emails: SentEmail[]): string { ['From', 'To', 'Subject', 'Status', 'Created', 'ID'], rows, '(no sent emails)', + [{}, {}, {}, {}, {}, { fixed: true }], ); } diff --git a/src/commands/emails/receiving/utils.ts b/src/commands/emails/receiving/utils.ts index a346e078..dcae6874 100644 --- a/src/commands/emails/receiving/utils.ts +++ b/src/commands/emails/receiving/utils.ts @@ -18,6 +18,7 @@ export function renderReceivingEmailsTable( ['From', 'To', 'Subject', 'Created At', 'ID'], rows, '(no received emails)', + [{}, {}, {}, {}, { fixed: true }], ); } @@ -34,5 +35,6 @@ export function renderAttachmentsTable( ['Filename', 'Content-Type', 'Size (bytes)', 'ID'], rows, '(no attachments)', + [{}, {}, {}, { fixed: true }], ); } diff --git a/src/commands/segments/utils.ts b/src/commands/segments/utils.ts index 2f97e0fb..7f09b424 100644 --- a/src/commands/segments/utils.ts +++ b/src/commands/segments/utils.ts @@ -3,5 +3,9 @@ import { renderTable } from '../../lib/table'; export function renderSegmentsTable(segments: Segment[]): string { const rows = segments.map((s) => [s.name, s.id, s.created_at]); - return renderTable(['Name', 'ID', 'Created'], rows, '(no segments)'); + return renderTable(['Name', 'ID', 'Created'], rows, '(no segments)', [ + {}, + { fixed: true }, + {}, + ]); } diff --git a/src/commands/templates/utils.ts b/src/commands/templates/utils.ts index e4364ab0..a7eb24f0 100644 --- a/src/commands/templates/utils.ts +++ b/src/commands/templates/utils.ts @@ -67,5 +67,6 @@ export function renderTemplatesTable( ['Name', 'Status', 'Alias', 'ID', 'Created'], rows, '(no templates)', + [{}, {}, {}, { fixed: true }, {}], ); } diff --git a/src/commands/topics/utils.ts b/src/commands/topics/utils.ts index deea9a47..18f85b8a 100644 --- a/src/commands/topics/utils.ts +++ b/src/commands/topics/utils.ts @@ -12,5 +12,6 @@ export function renderTopicsTable(topics: Topic[]): string { ['Name', 'Description', 'ID', 'Created'], rows, '(no topics)', + [{}, {}, { fixed: true }, {}], ); } diff --git a/src/commands/webhooks/utils.ts b/src/commands/webhooks/utils.ts index 2de005a2..e20c5c08 100644 --- a/src/commands/webhooks/utils.ts +++ b/src/commands/webhooks/utils.ts @@ -39,5 +39,6 @@ export function renderWebhooksTable(webhooks: Webhook[]): string { ['Endpoint', 'Events', 'Status', 'ID'], rows, '(no webhooks)', + [{}, {}, {}, { fixed: true }], ); } diff --git a/src/lib/table.ts b/src/lib/table.ts index 0ee838c1..7d613c0a 100644 --- a/src/lib/table.ts +++ b/src/lib/table.ts @@ -30,10 +30,35 @@ const BOX = isUnicodeSupported mm: '+', }; +export type ColumnOption = { fixed?: boolean }; + +const MIN_USEFUL_WIDTH = 12; + +export function getTerminalWidth(): number | undefined { + return process.stdout.columns; +} + +function renderCards(headers: string[], rows: string[][]): string { + const labelWidth = Math.max(...headers.map((h) => h.length)); + const sepWidth = Math.max(20, Math.min(getTerminalWidth() ?? 40, 40)); + + return rows + .map((row, idx) => { + const label = String(idx + 1); + const sep = `${BOX.h}${BOX.h} ${label} ${BOX.h.repeat(Math.max(0, sepWidth - label.length - 4))}`; + const fields = headers.map( + (h, i) => ` ${h.padEnd(labelWidth)} ${row[i]}`, + ); + return [sep, ...fields].join('\n'); + }) + .join('\n\n'); +} + export function renderTable( headers: string[], rows: string[][], emptyMessage = '(no results)', + columns?: ColumnOption[], ): string { if (rows.length === 0) { return emptyMessage; @@ -41,6 +66,50 @@ export function renderTable( const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)), ); + + const termWidth = getTerminalWidth(); + if (termWidth !== undefined) { + const N = widths.length; + const totalWidth = widths.reduce((s, w) => s + w, 0) + 3 * N + 1; + if (totalWidth > termWidth) { + const excess = totalWidth - termWidth; + const capacities = widths.map((w, i) => { + if (columns?.[i]?.fixed) { + return 0; + } + return Math.max(0, w - headers[i].length); + }); + const totalCapacity = capacities.reduce((s, c) => s + c, 0); + + let useCards = false; + if (totalCapacity < excess) { + useCards = true; + } else { + for (let i = 0; i < N; i++) { + if (!columns?.[i]?.fixed && capacities[i] > 0) { + const share = Math.round((capacities[i] / totalCapacity) * excess); + if (widths[i] - share < MIN_USEFUL_WIDTH) { + useCards = true; + break; + } + } + } + } + + if (useCards) { + return renderCards(headers, rows); + } + + const reduction = Math.min(excess, totalCapacity); + for (let i = 0; i < N; i++) { + if (capacities[i] > 0) { + const share = Math.round((capacities[i] / totalCapacity) * reduction); + widths[i] = widths[i] - share; + } + } + } + } + const top = BOX.tl + widths.map((w) => BOX.h.repeat(w + 2)).join(BOX.tm) + BOX.tr; const mid = @@ -50,7 +119,17 @@ export function renderTable( const row = (cells: string[]) => BOX.v + ' ' + - cells.map((c, i) => c.padEnd(widths[i])).join(` ${BOX.v} `) + + cells + .map((c, i) => { + const display = + c.length > widths[i] + ? widths[i] >= 4 + ? `${c.slice(0, widths[i] - 3)}...` + : c.slice(0, widths[i]) + : c; + return display.padEnd(widths[i]); + }) + .join(` ${BOX.v} `) + ' ' + BOX.v; return [top, row(headers), mid, ...rows.map(row), bot].join('\n'); diff --git a/tests/commands/domains/utils.test.ts b/tests/commands/domains/utils.test.ts new file mode 100644 index 00000000..b81370ad --- /dev/null +++ b/tests/commands/domains/utils.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'vitest'; +import { renderDnsRecordsTable } from '../../../src/commands/domains/utils'; + +describe('renderDnsRecordsTable', () => { + test('returns empty message when no records', () => { + expect(renderDnsRecordsTable([], 'example.com')).toBe('(no DNS records)'); + }); + + test('renders a card per record with type separator', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'SPF', + type: 'MX', + name: 'send', + ttl: 'Auto', + status: 'verified', + value: 'feedback-smtp.us-east-1.amazonses.com', + priority: 10, + }, + ], + 'example.com', + ); + + expect(output).toContain('MX'); + expect(output).toContain('Name send.example.com'); + expect(output).toContain('TTL Auto'); + expect(output).toContain('Value feedback-smtp.us-east-1.amazonses.com'); + }); + + test('expands short name with domain suffix', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'DKIM', + type: 'TXT', + name: 'dkim', + ttl: 'Auto', + status: 'pending', + value: 'p=MIIG...', + priority: 0, + }, + ], + 'send.example.com', + ); + + expect(output).toContain('Name dkim.send.example.com'); + }); + + test('keeps FQDN name as-is', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'SPF', + type: 'TXT', + name: 'send.example.com', + ttl: 'Auto', + status: 'verified', + value: 'v=spf1 include:amazonses.com ~all', + priority: 0, + }, + ], + 'example.com', + ); + + expect(output).toContain('Name send.example.com'); + }); + + test('uses domain name when record name is empty', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'SPF', + type: 'TXT', + name: '', + ttl: 'Auto', + status: 'verified', + value: 'v=spf1', + priority: 0, + }, + ], + 'example.com', + ); + + expect(output).toContain('Name example.com'); + }); + + test('separates multiple records with blank line', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'SPF', + type: 'MX', + name: 'send', + ttl: 'Auto', + status: 'verified', + value: 'mx.example.com', + priority: 10, + }, + { + record: 'DKIM', + type: 'TXT', + name: 'resend._domainkey', + ttl: 'Auto', + status: 'pending', + value: 'p=MIIG...', + priority: 0, + }, + ], + 'example.com', + ); + + const cards = output.split('\n\n'); + expect(cards).toHaveLength(2); + expect(cards[0]).toContain('MX'); + expect(cards[1]).toContain('TXT'); + }); +}); diff --git a/tests/lib/table.test.ts b/tests/lib/table.test.ts index 99568efc..92440c55 100644 --- a/tests/lib/table.test.ts +++ b/tests/lib/table.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test } from 'vitest'; import { renderTable } from '../../src/lib/table'; describe('renderTable', () => { @@ -37,7 +37,6 @@ describe('renderTable', () => { ['much longer key', 'v'], ], ); - // All rows should have the same line length const lines = output.split('\n'); const lengths = lines.map((l) => l.length); expect(new Set(lengths).size).toBe(1); @@ -61,3 +60,230 @@ describe('renderTable', () => { expect(output).toContain('┤'); }); }); + +describe('renderTable terminal-width truncation', () => { + const originalColumns = process.stdout.columns; + + afterEach(() => { + Object.defineProperty(process.stdout, 'columns', { + value: originalColumns, + writable: true, + configurable: true, + }); + }); + + function setTerminalWidth(width: number | undefined) { + Object.defineProperty(process.stdout, 'columns', { + value: width, + writable: true, + configurable: true, + }); + } + + test('no truncation when terminal width is undefined', () => { + setTerminalWidth(undefined); + const output = renderTable( + ['Name', 'ID'], + [['a-very-long-domain-name.example.com', 'abc-123-def-456']], + ); + expect(output).toContain('a-very-long-domain-name.example.com'); + expect(output).toContain('abc-123-def-456'); + }); + + test('no truncation when table fits within terminal width', () => { + setTerminalWidth(undefined); + const output = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); + const lineWidth = output.split('\n')[0].length; + + setTerminalWidth(lineWidth + 10); + const output2 = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); + expect(output2).toContain('Alice'); + expect(output2).toContain('abc-123'); + expect(output2).toBe(output); + }); + + test('truncates with ... when table overflows — fixed column intact', () => { + setTerminalWidth(35); + const output = renderTable( + ['Name', 'ID'], + [['a-very-long-domain-name.example.com', 'abc-123']], + '(no results)', + [{}, { fixed: true }], + ); + expect(output).toContain('abc-123'); + expect(output).not.toContain('a-very-long-domain-name.example.com'); + expect(output).toContain('...'); + }); + + test('proportional distribution — wider shrinkable column loses more', () => { + setTerminalWidth(55); + const output = renderTable( + ['A', 'B', 'C'], + [ + [ + 'short-val', + 'a-medium-length-value', + 'a-very-very-very-long-value-here', + ], + ], + '(no results)', + [{ fixed: true }, {}, {}], + ); + const lines = output.split('\n'); + const dataLine = lines[3]; + const cells = dataLine + .split('│') + .slice(1, -1) + .map((c) => c.trim()); + expect(cells[0]).toBe('short-val'); + expect(cells[2]).toContain('...'); + }); + + test('minimum width — shrinkable column never goes below header length', () => { + setTerminalWidth(30); + const output = renderTable( + ['Name', 'ID'], + [['a-very-long-domain-name.example.com', 'abc-123-def-456-ghi-789']], + '(no results)', + [{}, { fixed: true }], + ); + const lines = output.split('\n'); + const headerLine = lines[1]; + expect(headerLine).toContain('Name'); + }); +}); + +describe('renderTable card layout fallback', () => { + const originalColumns = process.stdout.columns; + + afterEach(() => { + Object.defineProperty(process.stdout, 'columns', { + value: originalColumns, + writable: true, + configurable: true, + }); + }); + + function setTerminalWidth(width: number | undefined) { + Object.defineProperty(process.stdout, 'columns', { + value: width, + writable: true, + configurable: true, + }); + } + + test('switches to cards when columns would be too narrow', () => { + setTerminalWidth(60); + const output = renderTable( + ['From', 'To', 'Subject', 'Status', 'Created', 'ID'], + [ + [ + 'sender@example.com', + 'recipient@example.com', + 'Hello world', + 'delivered', + '2026-01-15', + 'dca22e90-1693-4b98-a531-ebb9aaf69a2d', + ], + ], + '(no emails)', + [{}, {}, {}, {}, {}, { fixed: true }], + ); + expect(output).not.toContain('│'); + expect(output).toContain('From'); + expect(output).toContain('sender@example.com'); + expect(output).toContain('dca22e90-1693-4b98-a531-ebb9aaf69a2d'); + }); + + test('card layout includes all values untruncated', () => { + setTerminalWidth(50); + const output = renderTable( + ['Name', 'Description', 'ID'], + [['my-widget', 'A very long description of the widget', 'abc-123']], + '(none)', + [{}, {}, { fixed: true }], + ); + expect(output).toContain('A very long description of the widget'); + expect(output).toContain('abc-123'); + expect(output).toContain('my-widget'); + }); + + test('card layout separates rows with blank lines', () => { + setTerminalWidth(40); + const output = renderTable( + ['Name', 'Description', 'ID'], + [ + ['widget-a', 'First widget description', 'id-111'], + ['widget-b', 'Second widget description', 'id-222'], + ], + '(none)', + [{}, {}, { fixed: true }], + ); + const cards = output.split('\n\n'); + expect(cards).toHaveLength(2); + expect(cards[0]).toContain('widget-a'); + expect(cards[1]).toContain('widget-b'); + }); + + test('card layout uses box-drawing separator with row number', () => { + setTerminalWidth(40); + const output = renderTable( + ['Name', 'ID'], + [ + ['test', 'abc-123-def-456-ghi-789-jkl-012-mno'], + ['test2', 'xyz-789-uvw-456-rst-123-opq-000-aaa'], + ], + '(none)', + [{}, { fixed: true }], + ); + expect(output).toContain('── 1 ─'); + expect(output).toContain('── 2 ─'); + }); + + test('no card layout when terminal width is undefined', () => { + setTerminalWidth(undefined); + const output = renderTable( + ['From', 'To', 'Subject', 'Status', 'Created', 'ID'], + [ + [ + 'sender@example.com', + 'recipient@example.com', + 'Hello world', + 'delivered', + '2026-01-15', + 'dca22e90-1693-4b98-a531-ebb9aaf69a2d', + ], + ], + '(no emails)', + [{}, {}, {}, {}, {}, { fixed: true }], + ); + expect(output).toContain('│'); + expect(output).toContain('┌'); + }); + + test('stays in table mode when columns fit after truncation', () => { + setTerminalWidth(undefined); + const natural = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); + const lineWidth = natural.split('\n')[0].length; + + setTerminalWidth(lineWidth); + const output = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); + expect(output).toContain('│'); + expect(output).toContain('Alice'); + }); + + test('extreme narrowness — renders cards, not a broken table', () => { + setTerminalWidth(10); + const output = renderTable( + ['Name', 'Description', 'ID'], + [['test', 'a long description here', 'abc-123']], + '(no results)', + [{}, {}, { fixed: true }], + ); + expect(output).not.toContain('│'); + expect(output).toContain('Name'); + expect(output).toContain('test'); + expect(output).toContain('a long description here'); + expect(output).toContain('abc-123'); + }); +}); From 434aee992eb12dfe8d5d7c3bcbd53be99786f5c9 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Fri, 20 Mar 2026 16:46:37 -0300 Subject: [PATCH 2/5] fix: don't trigger card fallback for naturally narrow columns Columns already below MIN_USEFUL_WIDTH (like Status at ~9 chars) were triggering card mode at normal terminal widths. Now only columns that were wide enough but got crushed below the threshold trigger the card fallback. --- src/lib/table.ts | 6 +++++- tests/lib/table.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/table.ts b/src/lib/table.ts index 7d613c0a..158bcea8 100644 --- a/src/lib/table.ts +++ b/src/lib/table.ts @@ -86,7 +86,11 @@ export function renderTable( useCards = true; } else { for (let i = 0; i < N; i++) { - if (!columns?.[i]?.fixed && capacities[i] > 0) { + if ( + !columns?.[i]?.fixed && + capacities[i] > 0 && + widths[i] >= MIN_USEFUL_WIDTH + ) { const share = Math.round((capacities[i] / totalCapacity) * excess); if (widths[i] - share < MIN_USEFUL_WIDTH) { useCards = true; diff --git a/tests/lib/table.test.ts b/tests/lib/table.test.ts index 92440c55..e2d1e1fd 100644 --- a/tests/lib/table.test.ts +++ b/tests/lib/table.test.ts @@ -196,7 +196,7 @@ describe('renderTable card layout fallback', () => { }); test('card layout includes all values untruncated', () => { - setTerminalWidth(50); + setTerminalWidth(30); const output = renderTable( ['Name', 'Description', 'ID'], [['my-widget', 'A very long description of the widget', 'abc-123']], @@ -209,7 +209,7 @@ describe('renderTable card layout fallback', () => { }); test('card layout separates rows with blank lines', () => { - setTerminalWidth(40); + setTerminalWidth(30); const output = renderTable( ['Name', 'Description', 'ID'], [ From 13b8e06f680883f41395bd54137bd5d9a238ce35 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Fri, 20 Mar 2026 16:53:24 -0300 Subject: [PATCH 3/5] fix: remove truncation entirely, switch directly to cards when table overflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of proportionally shrinking columns with "..." truncation (which still produces unreadable output), simply switch to the card layout whenever the table doesn't fit the terminal width. Table renders at full natural width or cards — no middle ground. --- src/lib/table.ts | 56 +-------------- tests/lib/table.test.ts | 149 +++++----------------------------------- 2 files changed, 20 insertions(+), 185 deletions(-) diff --git a/src/lib/table.ts b/src/lib/table.ts index 158bcea8..0f59658d 100644 --- a/src/lib/table.ts +++ b/src/lib/table.ts @@ -32,8 +32,6 @@ const BOX = isUnicodeSupported export type ColumnOption = { fixed?: boolean }; -const MIN_USEFUL_WIDTH = 12; - export function getTerminalWidth(): number | undefined { return process.stdout.columns; } @@ -58,7 +56,7 @@ export function renderTable( headers: string[], rows: string[][], emptyMessage = '(no results)', - columns?: ColumnOption[], + _columns?: ColumnOption[], ): string { if (rows.length === 0) { return emptyMessage; @@ -72,45 +70,7 @@ export function renderTable( const N = widths.length; const totalWidth = widths.reduce((s, w) => s + w, 0) + 3 * N + 1; if (totalWidth > termWidth) { - const excess = totalWidth - termWidth; - const capacities = widths.map((w, i) => { - if (columns?.[i]?.fixed) { - return 0; - } - return Math.max(0, w - headers[i].length); - }); - const totalCapacity = capacities.reduce((s, c) => s + c, 0); - - let useCards = false; - if (totalCapacity < excess) { - useCards = true; - } else { - for (let i = 0; i < N; i++) { - if ( - !columns?.[i]?.fixed && - capacities[i] > 0 && - widths[i] >= MIN_USEFUL_WIDTH - ) { - const share = Math.round((capacities[i] / totalCapacity) * excess); - if (widths[i] - share < MIN_USEFUL_WIDTH) { - useCards = true; - break; - } - } - } - } - - if (useCards) { - return renderCards(headers, rows); - } - - const reduction = Math.min(excess, totalCapacity); - for (let i = 0; i < N; i++) { - if (capacities[i] > 0) { - const share = Math.round((capacities[i] / totalCapacity) * reduction); - widths[i] = widths[i] - share; - } - } + return renderCards(headers, rows); } } @@ -123,17 +83,7 @@ export function renderTable( const row = (cells: string[]) => BOX.v + ' ' + - cells - .map((c, i) => { - const display = - c.length > widths[i] - ? widths[i] >= 4 - ? `${c.slice(0, widths[i] - 3)}...` - : c.slice(0, widths[i]) - : c; - return display.padEnd(widths[i]); - }) - .join(` ${BOX.v} `) + + cells.map((c, i) => c.padEnd(widths[i])).join(` ${BOX.v} `) + ' ' + BOX.v; return [top, row(headers), mid, ...rows.map(row), bot].join('\n'); diff --git a/tests/lib/table.test.ts b/tests/lib/table.test.ts index e2d1e1fd..8e34d79a 100644 --- a/tests/lib/table.test.ts +++ b/tests/lib/table.test.ts @@ -61,7 +61,7 @@ describe('renderTable', () => { }); }); -describe('renderTable terminal-width truncation', () => { +describe('renderTable card layout fallback', () => { const originalColumns = process.stdout.columns; afterEach(() => { @@ -80,135 +80,51 @@ describe('renderTable terminal-width truncation', () => { }); } - test('no truncation when terminal width is undefined', () => { + test('renders table at full width when terminal width is undefined', () => { setTerminalWidth(undefined); const output = renderTable( ['Name', 'ID'], [['a-very-long-domain-name.example.com', 'abc-123-def-456']], ); + expect(output).toContain('│'); expect(output).toContain('a-very-long-domain-name.example.com'); expect(output).toContain('abc-123-def-456'); }); - test('no truncation when table fits within terminal width', () => { + test('renders table when it fits within terminal width', () => { setTerminalWidth(undefined); const output = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); const lineWidth = output.split('\n')[0].length; setTerminalWidth(lineWidth + 10); const output2 = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); - expect(output2).toContain('Alice'); - expect(output2).toContain('abc-123'); + expect(output2).toContain('│'); expect(output2).toBe(output); }); - test('truncates with ... when table overflows — fixed column intact', () => { - setTerminalWidth(35); - const output = renderTable( - ['Name', 'ID'], - [['a-very-long-domain-name.example.com', 'abc-123']], - '(no results)', - [{}, { fixed: true }], - ); - expect(output).toContain('abc-123'); - expect(output).not.toContain('a-very-long-domain-name.example.com'); - expect(output).toContain('...'); - }); - - test('proportional distribution — wider shrinkable column loses more', () => { - setTerminalWidth(55); - const output = renderTable( - ['A', 'B', 'C'], - [ - [ - 'short-val', - 'a-medium-length-value', - 'a-very-very-very-long-value-here', - ], - ], - '(no results)', - [{ fixed: true }, {}, {}], - ); - const lines = output.split('\n'); - const dataLine = lines[3]; - const cells = dataLine - .split('│') - .slice(1, -1) - .map((c) => c.trim()); - expect(cells[0]).toBe('short-val'); - expect(cells[2]).toContain('...'); - }); - - test('minimum width — shrinkable column never goes below header length', () => { + test('switches to cards when table overflows terminal', () => { setTerminalWidth(30); const output = renderTable( ['Name', 'ID'], - [['a-very-long-domain-name.example.com', 'abc-123-def-456-ghi-789']], - '(no results)', - [{}, { fixed: true }], - ); - const lines = output.split('\n'); - const headerLine = lines[1]; - expect(headerLine).toContain('Name'); - }); -}); - -describe('renderTable card layout fallback', () => { - const originalColumns = process.stdout.columns; - - afterEach(() => { - Object.defineProperty(process.stdout, 'columns', { - value: originalColumns, - writable: true, - configurable: true, - }); - }); - - function setTerminalWidth(width: number | undefined) { - Object.defineProperty(process.stdout, 'columns', { - value: width, - writable: true, - configurable: true, - }); - } - - test('switches to cards when columns would be too narrow', () => { - setTerminalWidth(60); - const output = renderTable( - ['From', 'To', 'Subject', 'Status', 'Created', 'ID'], - [ - [ - 'sender@example.com', - 'recipient@example.com', - 'Hello world', - 'delivered', - '2026-01-15', - 'dca22e90-1693-4b98-a531-ebb9aaf69a2d', - ], - ], - '(no emails)', - [{}, {}, {}, {}, {}, { fixed: true }], + [['a-very-long-domain-name.example.com', 'abc-123']], ); expect(output).not.toContain('│'); - expect(output).toContain('From'); - expect(output).toContain('sender@example.com'); - expect(output).toContain('dca22e90-1693-4b98-a531-ebb9aaf69a2d'); + expect(output).toContain('a-very-long-domain-name.example.com'); + expect(output).toContain('abc-123'); }); - test('card layout includes all values untruncated', () => { + test('cards include all values untruncated', () => { setTerminalWidth(30); const output = renderTable( ['Name', 'Description', 'ID'], [['my-widget', 'A very long description of the widget', 'abc-123']], - '(none)', - [{}, {}, { fixed: true }], ); expect(output).toContain('A very long description of the widget'); expect(output).toContain('abc-123'); expect(output).toContain('my-widget'); }); - test('card layout separates rows with blank lines', () => { + test('cards separate rows with blank lines', () => { setTerminalWidth(30); const output = renderTable( ['Name', 'Description', 'ID'], @@ -216,8 +132,6 @@ describe('renderTable card layout fallback', () => { ['widget-a', 'First widget description', 'id-111'], ['widget-b', 'Second widget description', 'id-222'], ], - '(none)', - [{}, {}, { fixed: true }], ); const cards = output.split('\n\n'); expect(cards).toHaveLength(2); @@ -225,23 +139,21 @@ describe('renderTable card layout fallback', () => { expect(cards[1]).toContain('widget-b'); }); - test('card layout uses box-drawing separator with row number', () => { - setTerminalWidth(40); + test('cards use box-drawing separator with row number', () => { + setTerminalWidth(20); const output = renderTable( ['Name', 'ID'], [ ['test', 'abc-123-def-456-ghi-789-jkl-012-mno'], ['test2', 'xyz-789-uvw-456-rst-123-opq-000-aaa'], ], - '(none)', - [{}, { fixed: true }], ); expect(output).toContain('── 1 ─'); expect(output).toContain('── 2 ─'); }); - test('no card layout when terminal width is undefined', () => { - setTerminalWidth(undefined); + test('wide table with many columns switches to cards', () => { + setTerminalWidth(60); const output = renderTable( ['From', 'To', 'Subject', 'Status', 'Created', 'ID'], [ @@ -254,36 +166,9 @@ describe('renderTable card layout fallback', () => { 'dca22e90-1693-4b98-a531-ebb9aaf69a2d', ], ], - '(no emails)', - [{}, {}, {}, {}, {}, { fixed: true }], - ); - expect(output).toContain('│'); - expect(output).toContain('┌'); - }); - - test('stays in table mode when columns fit after truncation', () => { - setTerminalWidth(undefined); - const natural = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); - const lineWidth = natural.split('\n')[0].length; - - setTerminalWidth(lineWidth); - const output = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); - expect(output).toContain('│'); - expect(output).toContain('Alice'); - }); - - test('extreme narrowness — renders cards, not a broken table', () => { - setTerminalWidth(10); - const output = renderTable( - ['Name', 'Description', 'ID'], - [['test', 'a long description here', 'abc-123']], - '(no results)', - [{}, {}, { fixed: true }], ); expect(output).not.toContain('│'); - expect(output).toContain('Name'); - expect(output).toContain('test'); - expect(output).toContain('a long description here'); - expect(output).toContain('abc-123'); + expect(output).toContain('sender@example.com'); + expect(output).toContain('dca22e90-1693-4b98-a531-ebb9aaf69a2d'); }); }); From 9bddc0d3fa19653476c76d91a8f415ec2db021ae Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Fri, 20 Mar 2026 17:06:09 -0300 Subject: [PATCH 4/5] refactor: remove unused ColumnOption type and columns parameter The columns/fixed parameter was only needed for the truncation logic, which has been removed in favor of the card fallback. All callers reverted to the original 3-arg renderTable signature. --- src/commands/api-keys/utils.ts | 6 +----- src/commands/broadcasts/utils.ts | 1 - src/commands/contact-properties/utils.ts | 1 - src/commands/contacts/utils.ts | 2 -- src/commands/domains/utils.ts | 7 +------ src/commands/emails/list.ts | 1 - src/commands/emails/receiving/utils.ts | 2 -- src/commands/segments/utils.ts | 6 +----- src/commands/templates/utils.ts | 1 - src/commands/topics/utils.ts | 1 - src/commands/webhooks/utils.ts | 1 - src/lib/table.ts | 3 --- 12 files changed, 3 insertions(+), 29 deletions(-) diff --git a/src/commands/api-keys/utils.ts b/src/commands/api-keys/utils.ts index a80eb696..4b04aea0 100644 --- a/src/commands/api-keys/utils.ts +++ b/src/commands/api-keys/utils.ts @@ -4,9 +4,5 @@ export function renderApiKeysTable( keys: Array<{ id: string; name: string; created_at: string }>, ): string { const rows = keys.map((k) => [k.name, k.id, k.created_at]); - return renderTable(['Name', 'ID', 'Created'], rows, '(no API keys)', [ - {}, - { fixed: true }, - {}, - ]); + return renderTable(['Name', 'ID', 'Created'], rows, '(no API keys)'); } diff --git a/src/commands/broadcasts/utils.ts b/src/commands/broadcasts/utils.ts index 89b36a6c..f738de80 100644 --- a/src/commands/broadcasts/utils.ts +++ b/src/commands/broadcasts/utils.ts @@ -31,6 +31,5 @@ export function renderBroadcastsTable( ['Name', 'Status', 'Created', 'ID'], rows, '(no broadcasts)', - [{}, {}, {}, { fixed: true }], ); } diff --git a/src/commands/contact-properties/utils.ts b/src/commands/contact-properties/utils.ts index 136c91f9..3cb9f22d 100644 --- a/src/commands/contact-properties/utils.ts +++ b/src/commands/contact-properties/utils.ts @@ -13,6 +13,5 @@ export function renderContactPropertiesTable(props: ContactProperty[]): string { ['Key', 'Type', 'Fallback Value', 'ID', 'Created'], rows, '(no contact properties)', - [{}, {}, {}, { fixed: true }, {}], ); } diff --git a/src/commands/contacts/utils.ts b/src/commands/contacts/utils.ts index b5899ca5..86c121df 100644 --- a/src/commands/contacts/utils.ts +++ b/src/commands/contacts/utils.ts @@ -25,7 +25,6 @@ export function renderContactsTable( ['Email', 'First Name', 'Last Name', 'Unsubscribed', 'ID'], rows, '(no contacts)', - [{}, {}, {}, {}, { fixed: true }], ); } @@ -40,7 +39,6 @@ export function renderContactTopicsTable(topics: ContactTopic[]): string { ['Name', 'Subscription', 'ID', 'Description'], rows, '(no topic subscriptions)', - [{}, {}, { fixed: true }, {}], ); } diff --git a/src/commands/domains/utils.ts b/src/commands/domains/utils.ts index cd55c6db..bef87b3e 100644 --- a/src/commands/domains/utils.ts +++ b/src/commands/domains/utils.ts @@ -35,12 +35,7 @@ export function renderDomainsTable( domains: Array<{ id: string; name: string; status: string; region: string }>, ): string { const rows = domains.map((d) => [d.name, d.status, d.region, d.id]); - return renderTable(['Name', 'Status', 'Region', 'ID'], rows, '(no domains)', [ - {}, - {}, - {}, - { fixed: true }, - ]); + return renderTable(['Name', 'Status', 'Region', 'ID'], rows, '(no domains)'); } export function statusIndicator(status: string): string { diff --git a/src/commands/emails/list.ts b/src/commands/emails/list.ts index 4d3565b6..40aea7aa 100644 --- a/src/commands/emails/list.ts +++ b/src/commands/emails/list.ts @@ -31,7 +31,6 @@ function renderSentEmailsTable(emails: SentEmail[]): string { ['From', 'To', 'Subject', 'Status', 'Created', 'ID'], rows, '(no sent emails)', - [{}, {}, {}, {}, {}, { fixed: true }], ); } diff --git a/src/commands/emails/receiving/utils.ts b/src/commands/emails/receiving/utils.ts index dcae6874..a346e078 100644 --- a/src/commands/emails/receiving/utils.ts +++ b/src/commands/emails/receiving/utils.ts @@ -18,7 +18,6 @@ export function renderReceivingEmailsTable( ['From', 'To', 'Subject', 'Created At', 'ID'], rows, '(no received emails)', - [{}, {}, {}, {}, { fixed: true }], ); } @@ -35,6 +34,5 @@ export function renderAttachmentsTable( ['Filename', 'Content-Type', 'Size (bytes)', 'ID'], rows, '(no attachments)', - [{}, {}, {}, { fixed: true }], ); } diff --git a/src/commands/segments/utils.ts b/src/commands/segments/utils.ts index 7f09b424..2f97e0fb 100644 --- a/src/commands/segments/utils.ts +++ b/src/commands/segments/utils.ts @@ -3,9 +3,5 @@ import { renderTable } from '../../lib/table'; export function renderSegmentsTable(segments: Segment[]): string { const rows = segments.map((s) => [s.name, s.id, s.created_at]); - return renderTable(['Name', 'ID', 'Created'], rows, '(no segments)', [ - {}, - { fixed: true }, - {}, - ]); + return renderTable(['Name', 'ID', 'Created'], rows, '(no segments)'); } diff --git a/src/commands/templates/utils.ts b/src/commands/templates/utils.ts index a7eb24f0..e4364ab0 100644 --- a/src/commands/templates/utils.ts +++ b/src/commands/templates/utils.ts @@ -67,6 +67,5 @@ export function renderTemplatesTable( ['Name', 'Status', 'Alias', 'ID', 'Created'], rows, '(no templates)', - [{}, {}, {}, { fixed: true }, {}], ); } diff --git a/src/commands/topics/utils.ts b/src/commands/topics/utils.ts index 18f85b8a..deea9a47 100644 --- a/src/commands/topics/utils.ts +++ b/src/commands/topics/utils.ts @@ -12,6 +12,5 @@ export function renderTopicsTable(topics: Topic[]): string { ['Name', 'Description', 'ID', 'Created'], rows, '(no topics)', - [{}, {}, { fixed: true }, {}], ); } diff --git a/src/commands/webhooks/utils.ts b/src/commands/webhooks/utils.ts index e20c5c08..2de005a2 100644 --- a/src/commands/webhooks/utils.ts +++ b/src/commands/webhooks/utils.ts @@ -39,6 +39,5 @@ export function renderWebhooksTable(webhooks: Webhook[]): string { ['Endpoint', 'Events', 'Status', 'ID'], rows, '(no webhooks)', - [{}, {}, {}, { fixed: true }], ); } diff --git a/src/lib/table.ts b/src/lib/table.ts index 0f59658d..030dc927 100644 --- a/src/lib/table.ts +++ b/src/lib/table.ts @@ -30,8 +30,6 @@ const BOX = isUnicodeSupported mm: '+', }; -export type ColumnOption = { fixed?: boolean }; - export function getTerminalWidth(): number | undefined { return process.stdout.columns; } @@ -56,7 +54,6 @@ export function renderTable( headers: string[], rows: string[][], emptyMessage = '(no results)', - _columns?: ColumnOption[], ): string { if (rows.length === 0) { return emptyMessage; From 7252cb5ff0cd6769632fc344535c77151097ef22 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Fri, 20 Mar 2026 17:15:11 -0300 Subject: [PATCH 5/5] fix: unexport getTerminalWidth, pass termWidth to renderCards, widen separator cap --- src/lib/table.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/lib/table.ts b/src/lib/table.ts index 030dc927..f6f64aff 100644 --- a/src/lib/table.ts +++ b/src/lib/table.ts @@ -30,13 +30,17 @@ const BOX = isUnicodeSupported mm: '+', }; -export function getTerminalWidth(): number | undefined { +function getTerminalWidth(): number | undefined { return process.stdout.columns; } -function renderCards(headers: string[], rows: string[][]): string { +function renderCards( + headers: string[], + rows: string[][], + termWidth: number, +): string { const labelWidth = Math.max(...headers.map((h) => h.length)); - const sepWidth = Math.max(20, Math.min(getTerminalWidth() ?? 40, 40)); + const sepWidth = Math.max(20, Math.min(termWidth, 60)); return rows .map((row, idx) => { @@ -64,10 +68,10 @@ export function renderTable( const termWidth = getTerminalWidth(); if (termWidth !== undefined) { - const N = widths.length; - const totalWidth = widths.reduce((s, w) => s + w, 0) + 3 * N + 1; + const totalWidth = + widths.reduce((s, w) => s + w, 0) + 3 * widths.length + 1; if (totalWidth > termWidth) { - return renderCards(headers, rows); + return renderCards(headers, rows, termWidth); } }