mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
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:
parent
beeeda4437
commit
50ffebd98b
5 changed files with 52 additions and 50 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue