diff --git a/src/cli-style.ts b/src/cli-style.ts new file mode 100644 index 00000000..b128ed7f --- /dev/null +++ b/src/cli-style.ts @@ -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; + +/** 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 = { + ok: GLYPH.ok, + warn: GLYPH.warn, + err: GLYPH.fail, + idle: GLYPH.idle, + info: GLYPH.dot, +}; + +const TONE_STYLE: Record = { + 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(text: string, work: () => Promise, options?: SpinnerOptions): Promise { + 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 { + if (!isInteractive()) return false; + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await new Promise((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(); + } +} diff --git a/test/cli-style.test.ts b/test/cli-style.test.ts new file mode 100644 index 00000000..0bef7851 --- /dev/null +++ b/test/cli-style.test.ts @@ -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(' '); + expect(cell.trimEnd()).toBe(''); + }); +}); + +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; + } + }); +});