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

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