refactor(cli): unify output formatting across commands (#111)

* 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) <noreply@anthropic.com>

* fix(cli): update stale badge role docstring after dim removal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luca Martial 2026-05-15 08:54:36 -04:00 committed by GitHub
parent beeeda4437
commit 50ffebd98b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 50 deletions

View file

@ -315,14 +315,14 @@ function padVisual(text: string, width: number): string {
}
function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): 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<ConnectionTestRow>): 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(

View file

@ -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<SlRow>({
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<SlRow>({
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<PrintListColumn<SearchRow>> = [
@ -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', () => {

View file

@ -18,7 +18,7 @@ export interface PrintListColumn<Row> {
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<Row extends object>(args: PrintListArgs<Row>): 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<string, Row[]>([['', [...rows]]]);
@ -231,14 +230,14 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): 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<Row extends object>(args: PrintListArgs<Row>): 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`);
}

View file

@ -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);
}

View file

@ -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<ProjectStatusLevel, string> = { 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[] = [];