mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: shared CLI style kit
One vocabulary for everything the codeman CLI prints: semantic palette, the glyph set the commands already used, heading/rule/kv, width-aware table layout, a stderr spinner and a y/N confirm. Color detection stays chalk's, so NO_COLOR and non-TTY degradation keep working with no second detector to disagree with it. The layout math and glyph selection are pure and exported, which is what lets the dependency report reuse them while staying color-free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @fileoverview One style vocabulary for everything the `codeman` CLI prints:
|
||||
* palette, glyphs, the small block helpers (heading/rule/kv), width-aware table
|
||||
* layout, a stderr spinner and a y/N confirm.
|
||||
*
|
||||
* Color detection is chalk's alone. It already honors NO_COLOR, FORCE_COLOR,
|
||||
* TERM=dumb and TTY-ness, and a second detector here would disagree with it on
|
||||
* some terminal with no way to tell which one was right.
|
||||
*
|
||||
* The layout math is pure and exported separately from anything that touches a
|
||||
* terminal, which is what lets it be unit-tested with no TTY and reused by
|
||||
* `utils/dependency-report.ts` while that file stays color-free.
|
||||
*
|
||||
* @module cli-style
|
||||
*/
|
||||
|
||||
import chalk, { type ChalkInstance } from 'chalk';
|
||||
import { createInterface } from 'node:readline';
|
||||
// Direct import, not the `utils` barrel: the barrel pulls in node-pty and every
|
||||
// CLI resolver, which a style module has no business loading.
|
||||
import { stripAnsi } from './utils/regex-patterns.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Palette and glyphs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Semantic roles, mirroring the web UI's status language (green fine, yellow waiting, red blocked). */
|
||||
export const palette = {
|
||||
ok: chalk.green,
|
||||
warn: chalk.yellow,
|
||||
err: chalk.red,
|
||||
info: chalk.cyan,
|
||||
muted: chalk.gray,
|
||||
emph: chalk.bold,
|
||||
accent: chalk.magenta,
|
||||
} as const satisfies Record<string, ChalkInstance>;
|
||||
|
||||
/** The glyph vocabulary the CLI already used, in one place. */
|
||||
export const GLYPH = {
|
||||
ok: '✓',
|
||||
fail: '✗',
|
||||
warn: '⚠',
|
||||
idle: '○',
|
||||
dot: '●',
|
||||
arrow: '→',
|
||||
} as const;
|
||||
|
||||
/** Spinner frames (braille, one cell wide in every terminal we support). */
|
||||
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
|
||||
/** What a line is reporting, independent of how it is painted. */
|
||||
export type Tone = 'ok' | 'warn' | 'err' | 'idle' | 'info';
|
||||
|
||||
const TONE_GLYPH: Record<Tone, string> = {
|
||||
ok: GLYPH.ok,
|
||||
warn: GLYPH.warn,
|
||||
err: GLYPH.fail,
|
||||
idle: GLYPH.idle,
|
||||
info: GLYPH.dot,
|
||||
};
|
||||
|
||||
const TONE_STYLE: Record<Tone, ChalkInstance> = {
|
||||
ok: palette.ok,
|
||||
warn: palette.warn,
|
||||
err: palette.err,
|
||||
idle: palette.muted,
|
||||
info: palette.info,
|
||||
};
|
||||
|
||||
/** Glyph for a tone. Pure, so the mapping is testable without a terminal. */
|
||||
export function glyphFor(tone: Tone): string {
|
||||
return TONE_GLYPH[tone];
|
||||
}
|
||||
|
||||
/** Paint text in a tone's color. */
|
||||
export function tint(tone: Tone, text: string): string {
|
||||
return TONE_STYLE[tone](text);
|
||||
}
|
||||
|
||||
/** Colored glyph for a tone, the `✓ ` / `✗ ` prefix most command output opens with. */
|
||||
export function mark(tone: Tone): string {
|
||||
return tint(tone, glyphFor(tone));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Blocks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Section heading. The blank line above it is part of the existing block idiom. */
|
||||
export function heading(text: string): string {
|
||||
return `\n${palette.emph(text)}`;
|
||||
}
|
||||
|
||||
/** Horizontal rule under a title. */
|
||||
export function rule(width = 40): string {
|
||||
return palette.muted('─'.repeat(Math.max(0, width)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indented `Label: value` line. `pad` aligns the values of a block by padding
|
||||
* the label column (including its colon), for blocks whose labels differ in
|
||||
* length.
|
||||
*/
|
||||
export function kv(label: string, value: string, pad = 0): string {
|
||||
const key = pad > 0 ? padCell(`${label}:`, pad) : `${label}:`;
|
||||
return ` ${key} ${value}`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Width-aware layout (pure)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Printed width of a cell: ANSI sequences take no columns. */
|
||||
export function displayWidth(text: string): number {
|
||||
return stripAnsi(text).length;
|
||||
}
|
||||
|
||||
export type CellAlign = 'left' | 'right';
|
||||
|
||||
/** Pad to `width` columns, measuring by display width so colored cells still align. */
|
||||
export function padCell(text: string, width: number, align: CellAlign = 'left'): string {
|
||||
const fill = ' '.repeat(Math.max(0, width - displayWidth(text)));
|
||||
return align === 'right' ? `${fill}${text}` : `${text}${fill}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad AFTER the paint, so the fill stays outside the color run and a trailing
|
||||
* empty column can be trimmed away instead of ending in a reset sequence with
|
||||
* invisible spaces before it.
|
||||
*/
|
||||
export function padStyled(text: string, width: number, paint: (t: string) => string): string {
|
||||
return `${paint(text)}${' '.repeat(Math.max(0, width - displayWidth(text)))}`;
|
||||
}
|
||||
|
||||
/** Widest cell per column. Short rows count as empty cells, never as narrower columns. */
|
||||
export function columnWidths(rows: readonly (readonly string[])[]): number[] {
|
||||
const widths: number[] = [];
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
widths[i] = Math.max(widths[i] ?? 0, displayWidth(row[i] ?? ''));
|
||||
}
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
export interface TableOptions {
|
||||
/** Per-column alignment; missing entries are left-aligned. */
|
||||
align?: readonly CellAlign[];
|
||||
/** Spaces between columns. */
|
||||
gap?: number;
|
||||
/** Prefix for every row. */
|
||||
indent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay rows out in columns sized to their widest cell. The last cell of a row is
|
||||
* never padded, so no line carries trailing whitespace.
|
||||
*/
|
||||
export function layoutTable(rows: readonly (readonly string[])[], options: TableOptions = {}): string[] {
|
||||
const { align = [], gap = 1, indent = '' } = options;
|
||||
const widths = columnWidths(rows);
|
||||
const separator = ' '.repeat(Math.max(0, gap));
|
||||
return rows.map((row) => {
|
||||
const cells = row.map((cell, i) => (i === row.length - 1 ? cell : padCell(cell, widths[i], align[i] ?? 'left')));
|
||||
return `${indent}${cells.join(separator)}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** `layoutTable()` as one printable block. */
|
||||
export function table(rows: readonly (readonly string[])[], options: TableOptions = {}): string {
|
||||
return layoutTable(rows, options).join('\n');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Spinner
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
const CLEAR_LINE = '\x1b[K';
|
||||
|
||||
/** The slice of a stream a spinner needs; `process.stderr` satisfies it. */
|
||||
export interface SpinnerStream {
|
||||
isTTY?: boolean;
|
||||
write(chunk: string): unknown;
|
||||
}
|
||||
|
||||
export interface Spinner {
|
||||
start(): Spinner;
|
||||
/** Change the text mid-flight. Silent on a non-TTY, which prints once and stops. */
|
||||
setText(text: string): void;
|
||||
/** Clear the line, restore the cursor and optionally print a final line. */
|
||||
stop(finalLine?: string): void;
|
||||
}
|
||||
|
||||
export interface SpinnerOptions {
|
||||
stream?: SpinnerStream;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place progress line on stderr, for the calls that block for tens of seconds
|
||||
* (daemon start, service install). Only a TTY gets the animation: piped output
|
||||
* and journald get the text once, so a log file never fills with `\r` frames.
|
||||
*/
|
||||
export function spinner(text: string, options: SpinnerOptions = {}): Spinner {
|
||||
const stream = options.stream ?? process.stderr;
|
||||
const intervalMs = options.intervalMs ?? 90;
|
||||
const animated = Boolean(stream.isTTY);
|
||||
let label = text;
|
||||
let frame = 0;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
|
||||
const restoreCursor = () => {
|
||||
if (animated) stream.write(SHOW_CURSOR);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
stream.write(`\r${palette.info(SPINNER_FRAMES[frame % SPINNER_FRAMES.length])} ${label}${CLEAR_LINE}`);
|
||||
frame++;
|
||||
};
|
||||
|
||||
const handle: Spinner = {
|
||||
start() {
|
||||
if (started || stopped) return handle;
|
||||
started = true;
|
||||
if (!animated) {
|
||||
stream.write(`${label}\n`);
|
||||
return handle;
|
||||
}
|
||||
stream.write(HIDE_CURSOR);
|
||||
// A hidden cursor left behind by a Ctrl+C outlives the process, so the
|
||||
// exit hook is not optional.
|
||||
process.once('exit', restoreCursor);
|
||||
render();
|
||||
// Unref'd: a spinner must never be the reason the process stays alive.
|
||||
timer = setInterval(render, intervalMs);
|
||||
timer.unref();
|
||||
return handle;
|
||||
},
|
||||
setText(next: string) {
|
||||
label = next;
|
||||
if (animated && started && !stopped) render();
|
||||
},
|
||||
stop(finalLine?: string) {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (animated && started) {
|
||||
stream.write(`\r${CLEAR_LINE}`);
|
||||
restoreCursor();
|
||||
process.off('exit', restoreCursor);
|
||||
}
|
||||
if (finalLine && animated) stream.write(`${finalLine}\n`);
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** Run `work` with a spinner up, stopping it however `work` ends. */
|
||||
export async function withSpinner<T>(text: string, work: () => Promise<T>, options?: SpinnerOptions): Promise<T> {
|
||||
const handle = spinner(text, options).start();
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
handle.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Confirm
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Is there a human on the other end of both halves of the terminal? */
|
||||
export function isInteractive(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
/**
|
||||
* y/N prompt. Answers `false` immediately when stdin is not a TTY (a script
|
||||
* piping into the CLI must never hang on an invisible question), so callers
|
||||
* that support a `--force` flag can branch on `isInteractive()` to keep printing
|
||||
* their "pass --force" hint instead.
|
||||
*/
|
||||
export async function confirm(question: string): Promise<boolean> {
|
||||
if (!isInteractive()) return false;
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.once('SIGINT', () => resolve(''));
|
||||
rl.question(`${question} ${palette.muted('[y/N]')} `, resolve);
|
||||
});
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
} finally {
|
||||
rl.close();
|
||||
// readline resumes stdin; a still-flowing stdin keeps the process alive.
|
||||
process.stdin.pause();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the pure half of the CLI style kit: display-width
|
||||
* math, column layout, kv padding, glyph selection, and the spinner's non-TTY
|
||||
* behavior. Nothing here needs a terminal.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
GLYPH,
|
||||
SPINNER_FRAMES,
|
||||
columnWidths,
|
||||
confirm,
|
||||
displayWidth,
|
||||
glyphFor,
|
||||
isInteractive,
|
||||
kv,
|
||||
layoutTable,
|
||||
padCell,
|
||||
padStyled,
|
||||
palette,
|
||||
spinner,
|
||||
table,
|
||||
tint,
|
||||
type SpinnerStream,
|
||||
} from '../src/cli-style.js';
|
||||
|
||||
/** Recording stand-in for `process.stderr`. */
|
||||
function fakeStream(isTTY: boolean): SpinnerStream & { writes: string[] } {
|
||||
const writes: string[] = [];
|
||||
return {
|
||||
isTTY,
|
||||
writes,
|
||||
write(chunk: string) {
|
||||
writes.push(chunk);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('displayWidth', () => {
|
||||
it('counts printable columns, not bytes', () => {
|
||||
expect(displayWidth('tmux')).toBe(4);
|
||||
expect(displayWidth('')).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores ANSI sequences', () => {
|
||||
expect(displayWidth('\x1b[32mok\x1b[39m')).toBe(2);
|
||||
expect(displayWidth(palette.ok('ok'))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('padCell', () => {
|
||||
it('pads to the requested column count', () => {
|
||||
expect(padCell('ab', 5)).toBe('ab ');
|
||||
expect(padCell('ab', 5, 'right')).toBe(' ab');
|
||||
});
|
||||
|
||||
it('never truncates a cell that is already too wide', () => {
|
||||
expect(padCell('Antigravity CLI', 4)).toBe('Antigravity CLI');
|
||||
});
|
||||
|
||||
it('pads a colored cell by its printed width', () => {
|
||||
const padded = padCell(palette.ok('ok'), 6);
|
||||
expect(displayWidth(padded)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('padStyled', () => {
|
||||
it('keeps the fill outside the paint so trailing space can be trimmed', () => {
|
||||
const cell = padStyled('ok', 6, (t) => `<${t}>`);
|
||||
expect(cell).toBe('<ok> ');
|
||||
expect(cell.trimEnd()).toBe('<ok>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('columnWidths', () => {
|
||||
it('measures the widest cell per column', () => {
|
||||
expect(
|
||||
columnWidths([
|
||||
['tmux', '3.4'],
|
||||
['Antigravity CLI', 'not found'],
|
||||
])
|
||||
).toEqual([15, 9]);
|
||||
});
|
||||
|
||||
it('treats missing cells as empty, never as a narrower column', () => {
|
||||
expect(columnWidths([['a', 'bbb'], ['a']])).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutTable', () => {
|
||||
// The bug this replaces: `padEnd(14)` with a 15-character label ("Antigravity
|
||||
// CLI") pushed that row's remaining columns one column right.
|
||||
const rows = [
|
||||
['✓', 'tmux', '3.4'],
|
||||
['✓', 'Antigravity CLI', '1.1.12'],
|
||||
['○', 'Pi CLI', 'not found'],
|
||||
];
|
||||
|
||||
it('starts every column at the same offset regardless of cell length', () => {
|
||||
const lines = layoutTable(rows, { indent: ' ' });
|
||||
expect(lines[0].indexOf('3.4')).toBe(lines[1].indexOf('1.1.12'));
|
||||
expect(lines[1].indexOf('1.1.12')).toBe(lines[2].indexOf('not found'));
|
||||
// Widest label (15) + indent (2) + glyph column (1) + two gaps.
|
||||
expect(lines[1].indexOf('1.1.12')).toBe(2 + 1 + 1 + 15 + 1);
|
||||
});
|
||||
|
||||
it('leaves no trailing whitespace on the last column', () => {
|
||||
for (const line of layoutTable(rows)) {
|
||||
expect(line).toBe(line.trimEnd());
|
||||
}
|
||||
});
|
||||
|
||||
it('honors indent, gap and right alignment', () => {
|
||||
const lines = layoutTable(
|
||||
[
|
||||
['a', '1'],
|
||||
['bbb', '22'],
|
||||
],
|
||||
{ indent: '> ', gap: 3, align: ['right'] }
|
||||
);
|
||||
expect(lines[0]).toBe('> a 1');
|
||||
expect(lines[1]).toBe('> bbb 22');
|
||||
});
|
||||
|
||||
it('aligns colored cells by printed width', () => {
|
||||
const lines = layoutTable([
|
||||
[palette.ok('✓'), palette.emph('tmux'), '3.4'],
|
||||
[palette.err('✗'), 'Antigravity CLI', 'not found'],
|
||||
]);
|
||||
expect(lines[0].indexOf('3.4')).toBe(lines[1].indexOf('not found'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('joins the laid-out rows', () => {
|
||||
expect(
|
||||
table([
|
||||
['a', 'b'],
|
||||
['cc', 'd'],
|
||||
])
|
||||
).toBe('a b\ncc d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kv', () => {
|
||||
it('indents and appends the colon', () => {
|
||||
expect(kv('Status', 'running')).toBe(' Status: running');
|
||||
});
|
||||
|
||||
it('aligns a block when a pad width is given', () => {
|
||||
const lines = [kv('Daemon pid', '42', 11), kv('Log', '/tmp/web.log', 11)];
|
||||
expect(lines[0].indexOf('42')).toBe(lines[1].indexOf('/tmp/web.log'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('glyphs', () => {
|
||||
it('maps tones to the CLI glyph vocabulary', () => {
|
||||
expect(glyphFor('ok')).toBe(GLYPH.ok);
|
||||
expect(glyphFor('err')).toBe(GLYPH.fail);
|
||||
expect(glyphFor('warn')).toBe(GLYPH.warn);
|
||||
expect(glyphFor('idle')).toBe(GLYPH.idle);
|
||||
expect(glyphFor('info')).toBe(GLYPH.dot);
|
||||
});
|
||||
|
||||
it('tints without changing the printed text', () => {
|
||||
expect(displayWidth(tint('err', 'nope'))).toBe(4);
|
||||
expect(tint('err', 'nope')).toContain('nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spinner', () => {
|
||||
it('prints the text once and stays silent when the stream is not a TTY', () => {
|
||||
const stream = fakeStream(false);
|
||||
const handle = spinner('waiting', { stream }).start();
|
||||
handle.setText('still waiting');
|
||||
handle.stop('done');
|
||||
expect(stream.writes).toEqual(['waiting\n']);
|
||||
});
|
||||
|
||||
it('animates in place on a TTY and restores the cursor on stop', () => {
|
||||
const stream = fakeStream(true);
|
||||
const handle = spinner('waiting', { stream, intervalMs: 60_000 }).start();
|
||||
expect(stream.writes[0]).toBe('\x1b[?25l');
|
||||
expect(stream.writes[1]).toContain(SPINNER_FRAMES[0]);
|
||||
expect(stream.writes[1]).toContain('waiting');
|
||||
|
||||
handle.stop();
|
||||
const tail = stream.writes.join('');
|
||||
expect(tail).toContain('\r\x1b[K');
|
||||
expect(tail).toContain('\x1b[?25h');
|
||||
});
|
||||
|
||||
it('ignores a second stop', () => {
|
||||
const stream = fakeStream(true);
|
||||
const handle = spinner('waiting', { stream, intervalMs: 60_000 }).start();
|
||||
handle.stop();
|
||||
const afterFirst = stream.writes.length;
|
||||
handle.stop();
|
||||
expect(stream.writes.length).toBe(afterFirst);
|
||||
});
|
||||
|
||||
it('does nothing at all when it was never started', () => {
|
||||
const stream = fakeStream(true);
|
||||
spinner('waiting', { stream }).stop();
|
||||
expect(stream.writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm', () => {
|
||||
it('answers no without blocking when stdin is not a TTY', async () => {
|
||||
const original = process.stdin.isTTY;
|
||||
try {
|
||||
process.stdin.isTTY = false;
|
||||
expect(isInteractive()).toBe(false);
|
||||
await expect(confirm('Reset all Codeman state?')).resolves.toBe(false);
|
||||
} finally {
|
||||
process.stdin.isTTY = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user