From 50ffebd98b39ffb0369bf75263c7794189469cd0 Mon Sep 17 00:00:00 2001 From: Luca Martial <48870843+luca-martial@users.noreply.github.com> Date: Fri, 15 May 2026 08:54:36 -0400 Subject: [PATCH] refactor(cli): unify output formatting across commands (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(cli): unify output formatting across search and status commands Replace clack-style box borders (◇/│/└) and bullets (●/◆) in printList pretty mode with a clean status-style layout: bold headers, indented aligned rows, no decorative framing. Migrate status-project.ts from hand-rolled ANSI escape codes to shared symbols.ts color helpers. Remove dead clack symbols from SYMBOLS, add yellow() for warnings. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): update stale badge role docstring after dim removal Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/cli/src/connection.ts | 12 ++++----- packages/cli/src/io/print-list.test.ts | 35 +++++++++++++------------- packages/cli/src/io/print-list.ts | 25 +++++++++--------- packages/cli/src/io/symbols.ts | 9 +++---- packages/cli/src/status-project.ts | 21 ++++++++++------ 5 files changed, 52 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index c65fc3c3..06d02922 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -315,14 +315,14 @@ function padVisual(text: string, width: number): string { } function renderTestAll(io: KtxCliIo, rows: ReadonlyArray): void { - io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`); - io.stdout.write(`${SYMBOLS.bar}\n`); + io.stdout.write(`${bold('connection test --all')}\n`); if (rows.length === 0) { - io.stdout.write(`${SYMBOLS.barEnd} No connections configured. Run \`ktx setup\` to add one.\n`); + io.stdout.write(`\n No connections configured. Run \`ktx setup\` to add one.\n\n`); return; } + io.stdout.write('\n'); const okLabel = green('✓ ok'); const failLabel = red('✗ failed'); const idWidth = Math.max(...rows.map((r) => r.connectionId.length)); @@ -334,17 +334,17 @@ function renderTestAll(io: KtxCliIo, rows: ReadonlyArray): vo const driver = dim(padVisual(row.driver, driverWidth)); const status = padVisual(row.ok ? okLabel : failLabel, statusWidth); const detail = dim(row.detail); - io.stdout.write(`${SYMBOLS.bar} ${SYMBOLS.item} ${id} ${driver} ${status} ${detail}\n`); + io.stdout.write(` ${id} ${driver} ${status} ${detail}\n`); } const failed = rows.filter((r) => !r.ok).length; const passed = rows.length - failed; - io.stdout.write(`${SYMBOLS.bar}\n`); + io.stdout.write('\n'); const summary = failed === 0 ? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}` : `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`; - io.stdout.write(`${SYMBOLS.barEnd} ${summary}\n`); + io.stdout.write(`${summary}\n`); } async function runTestAll( diff --git a/packages/cli/src/io/print-list.test.ts b/packages/cli/src/io/print-list.test.ts index 543cc71e..cb6e7947 100644 --- a/packages/cli/src/io/print-list.test.ts +++ b/packages/cli/src/io/print-list.test.ts @@ -139,7 +139,7 @@ function stripAnsi(s: string): string { } describe('printList — pretty mode', () => { - it('renders a Clack-style header, grouped rows, and footer', () => { + it('renders a bold header, grouped rows, and footer', () => { const r = recorder(); printList({ rows: [ORDERS, USERS], @@ -152,13 +152,14 @@ describe('printList — pretty mode', () => { io: r.io, }); const out = stripAnsi(r.out()); - expect(out).toContain(`${SYMBOLS.barStart} sl list`); - expect(out).toContain(`${SYMBOLS.group} warehouse`); + expect(out).toContain('sl list'); + expect(out).toContain('warehouse'); expect(out).toContain('(2 sources)'); - expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} orders\\s+5 cols ${escapeRegExp(SYMBOLS.middot)} 3 measures ${escapeRegExp(SYMBOLS.middot)} 1 join\\b`)); - expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} users\\s+8 cols ${escapeRegExp(SYMBOLS.middot)} 2 measures ${escapeRegExp(SYMBOLS.middot)} 2 joins\\b`)); + expect(out).toMatch(/orders\s+5 cols/); + expect(out).toMatch(new RegExp(`3 measures ${escapeRegExp(SYMBOLS.middot)} 1 join\\b`)); + expect(out).toMatch(new RegExp(`2 measures ${escapeRegExp(SYMBOLS.middot)} 2 joins\\b`)); expect(out).toContain(`${SYMBOLS.emDash} User profile + auth`); - expect(out).toContain(`${SYMBOLS.barEnd} 2 sources`); + expect(out).toContain('2 sources'); }); it('renders an empty-state message when no rows', () => { @@ -174,11 +175,11 @@ describe('printList — pretty mode', () => { io: r.io, }); const out = stripAnsi(r.out()); - expect(out).toContain(`${SYMBOLS.barStart} sl list`); - expect(out).toContain(`${SYMBOLS.barEnd} No semantic-layer sources found in /tmp/proj`); + expect(out).toContain('sl list'); + expect(out).toContain('No semantic-layer sources found in /tmp/proj'); }); - it('renders empty-state with hint and zero-count footer when emptyHint is provided', () => { + it('renders empty-state with hint when emptyHint is provided', () => { const r = recorder(); printList({ rows: [], @@ -192,9 +193,8 @@ describe('printList — pretty mode', () => { io: r.io, }); const out = stripAnsi(r.out()); - expect(out).toContain(`${SYMBOLS.bar} No sources matched "foo"`); - expect(out).toContain(`${SYMBOLS.bar} Run \`ktx sl list\` to see available sources.`); - expect(out).toContain(`${SYMBOLS.barEnd} 0 sources`); + expect(out).toContain('No sources matched "foo"'); + expect(out).toContain('Run `ktx sl list` to see available sources.'); }); it('singularizes the footer when there is one row', () => { @@ -210,7 +210,7 @@ describe('printList — pretty mode', () => { io: r.io, }); const out = stripAnsi(r.out()); - expect(out).toContain(`${SYMBOLS.barEnd} 1 source`); + expect(out).toContain('1 source'); }); it('uses the provided unit in pluralization and group counts', () => { @@ -236,10 +236,10 @@ describe('printList — pretty mode', () => { }); const out = stripAnsi(r.out()); expect(out).toContain('(2 pages)'); - expect(out).toContain(`${SYMBOLS.barEnd} 2 pages`); + expect(out).toContain('2 pages'); }); - it('renders a leading dim badge column with prettyFormat in pretty mode', () => { + it('renders a leading badge column with prettyFormat in pretty mode', () => { const r = recorder(); interface SearchRow { score: number; scope: string; key: string; summary: string } const SEARCH_COLUMNS: ReadonlyArray> = [ @@ -270,9 +270,8 @@ describe('printList — pretty mode', () => { io: r.io, }); const out = stripAnsi(r.out()); - // Badge displays as right-padded percentage before the name column. - expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} 87%\\s+alpha\\s+`)); - expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} 4%\\s+beta\\s+`)); + expect(out).toMatch(/87%\s+alpha\s+/); + expect(out).toMatch(/4%\s+beta\s+/); }); it('emits the badge column in plain mode using its plain prefix', () => { diff --git a/packages/cli/src/io/print-list.ts b/packages/cli/src/io/print-list.ts index b05e12f2..3d8d1fba 100644 --- a/packages/cli/src/io/print-list.ts +++ b/packages/cli/src/io/print-list.ts @@ -18,7 +18,7 @@ export interface PrintListColumn { dim?: boolean; /** * Pretty-mode role override. When omitted, role is auto-detected: - * - `'badge'` — leading dim cell before the name column (right-padded across rows). + * - `'badge'` — leading cell before the name column (right-padded across rows). * - `'name'` — name column. Default: first non-grouped, non-metric, non-optional column. * - `'metric'` — `"N word"` cell. Default: any column with a non-empty `plain` prefix. * - `'suffix'` — trailing em-dash optional value. Default: any column with `optional: true`. @@ -202,20 +202,19 @@ function printListPretty(args: PrintListArgs): void { const { io, command, rows, columns, groupBy, emptyMessage, emptyHint } = args; const unit = args.unit ?? 'result'; - io.stdout.write(`${SYMBOLS.barStart} ${command}\n`); - io.stdout.write(`${SYMBOLS.bar}\n`); + io.stdout.write(`${bold(command)}\n`); if (rows.length === 0) { + io.stdout.write(`\n ${emptyMessage}\n`); if (emptyHint !== undefined && emptyHint !== '') { - io.stdout.write(`${SYMBOLS.bar} ${emptyMessage}\n`); - io.stdout.write(`${SYMBOLS.bar} ${dim(emptyHint)}\n`); - io.stdout.write(`${SYMBOLS.barEnd} ${dim(`0 ${unit}s`)}\n`); - } else { - io.stdout.write(`${SYMBOLS.barEnd} ${emptyMessage}\n`); + io.stdout.write(` ${dim(emptyHint)}\n`); } + io.stdout.write('\n'); return; } + io.stdout.write('\n'); + const resolved = resolveColumns(columns, groupBy); const buckets = groupBy ? groupRows(rows, groupBy) : new Map([['', [...rows]]]); @@ -231,14 +230,14 @@ function printListPretty(args: PrintListArgs): void { for (const [groupValue, groupRowList] of buckets) { if (groupBy) { io.stdout.write( - `${SYMBOLS.bar} ${SYMBOLS.group} ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, unit)})`)}\n`, + ` ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, unit)})`)}\n`, ); } for (const row of groupRowList) { const segments: string[] = []; resolved.badge.forEach((col, idx) => { - segments.push(dim(formatCellValue(col, row).padStart(badgeWidths[idx] ?? 0))); + segments.push(formatCellValue(col, row).padStart(badgeWidths[idx] ?? 0)); }); if (resolved.name) { @@ -265,10 +264,10 @@ function printListPretty(args: PrintListArgs): void { if (optionalSuffix.length > 0) segments.push(optionalSuffix); const indent = groupBy ? ' ' : ' '; - io.stdout.write(`${SYMBOLS.bar}${indent}${SYMBOLS.item} ${segments.join(' ')}\n`); + io.stdout.write(`${indent}${segments.join(' ')}\n`); } + io.stdout.write('\n'); } - io.stdout.write(`${SYMBOLS.bar}\n`); - io.stdout.write(`${SYMBOLS.barEnd} ${pluralize(rows.length, unit)}\n`); + io.stdout.write(`${pluralize(rows.length, unit)}\n`); } diff --git a/packages/cli/src/io/symbols.ts b/packages/cli/src/io/symbols.ts index f80c2b79..ba93a436 100644 --- a/packages/cli/src/io/symbols.ts +++ b/packages/cli/src/io/symbols.ts @@ -15,11 +15,6 @@ function detectUnicodeSupport(env: NodeJS.ProcessEnv = process.env): boolean { const unicode = detectUnicodeSupport(); export const SYMBOLS = { - bar: unicode ? '│' : '|', - barStart: unicode ? '◇' : 'o', - barEnd: unicode ? '└' : '—', - group: unicode ? '●' : '*', - item: unicode ? '◆' : '*', middot: unicode ? '·' : '-', emDash: unicode ? '—' : '--', } as const; @@ -43,3 +38,7 @@ export function green(text: string): string { export function red(text: string): string { return styleText('red', text); } + +export function yellow(text: string): string { + return styleText('yellow', text); +} diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts index 2aab1e5c..8c2f2445 100644 --- a/packages/cli/src/status-project.ts +++ b/packages/cli/src/status-project.ts @@ -9,6 +9,13 @@ import type { } from '@ktx/context/project'; import type { PostgresPgssProbeResult } from '@ktx/context/ingest'; import type { DoctorCheck } from './doctor.js'; +import { + bold as _bold, + dim as _dim, + green, + red, + yellow, +} from './io/symbols.js'; import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js'; type ProjectStatusLevel = 'ok' | 'warn' | 'fail'; @@ -694,13 +701,11 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil const SYMBOL: Record = { ok: '✓', warn: '⚠', fail: '✗' }; -function ansi(useColor: boolean, code: string, text: string, closer = '39'): string { - return useColor ? `\u001b[${code}m${text}\u001b[${closer}m` : text; +function colorForLevel(useColor: boolean, level: ProjectStatusLevel, text: string): string { + if (!useColor) return text; + return level === 'ok' ? green(text) : level === 'warn' ? yellow(text) : red(text); } -function colorFor(level: ProjectStatusLevel): string { - return level === 'ok' ? '32' : level === 'warn' ? '33' : '31'; -} function abbreviateHome(filePath: string, env: NodeJS.ProcessEnv): string { const home = env.HOME; @@ -722,9 +727,9 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec const verbose = options.verbose ?? false; const useColor = options.useColor ?? false; const env = options.env ?? process.env; - const dim = (s: string) => ansi(useColor, '2', s, '22'); - const bold = (s: string) => ansi(useColor, '1', s, '22'); - const color = (level: ProjectStatusLevel, s: string) => ansi(useColor, colorFor(level), s); + const dim = useColor ? _dim : (s: string) => s; + const bold = useColor ? _bold : (s: string) => s; + const color = (level: ProjectStatusLevel, s: string) => colorForLevel(useColor, level, s); const sym = (level: ProjectStatusLevel) => color(level, SYMBOL[level]); const lines: string[] = [];