diff --git a/packages/cli/README.md b/packages/cli/README.md index b2ec31e2..b9ec41b2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -108,6 +108,14 @@ ai-devkit skill list --global --env claude codex ai-devkit memory store ``` +## UI Formatting Standards + +Shared CLI presentation helpers live in `src/util/`. Commands should use +`status.ts` for status labels and chalk colors, `time-format.ts` for relative +or `Mon D · HH:mm` time labels with an injectable `now` clock, and +`pluralize.ts` for count labels. Keep JSON output independent from these +terminal-only helpers so machine-readable shapes stay stable. + Template example: ```yaml diff --git a/packages/cli/src/__tests__/util/pluralize.test.ts b/packages/cli/src/__tests__/util/pluralize.test.ts new file mode 100644 index 00000000..59745082 --- /dev/null +++ b/packages/cli/src/__tests__/util/pluralize.test.ts @@ -0,0 +1,16 @@ +import { pluralize } from "../../util/pluralize.js"; + +describe("pluralize util", () => { + it("uses the singular word for exactly one item", () => { + expect(pluralize(1, "provider")).toBe("1 provider"); + }); + + it("appends s for other counts by default", () => { + expect(pluralize(0, "provider")).toBe("0 providers"); + expect(pluralize(2, "provider")).toBe("2 providers"); + }); + + it("uses an explicit plural word when provided", () => { + expect(pluralize(2, "entry", "entries")).toBe("2 entries"); + }); +}); diff --git a/packages/cli/src/__tests__/util/status.test.ts b/packages/cli/src/__tests__/util/status.test.ts new file mode 100644 index 00000000..e81fc526 --- /dev/null +++ b/packages/cli/src/__tests__/util/status.test.ts @@ -0,0 +1,46 @@ +import chalk from "chalk"; +import { + colorStatus, + getStatusDisplay, + getStatusKeyByLabel, + STATUS_DISPLAY, +} from "../../util/status.js"; + +describe("status util", () => { + it("maps status keys to labels and colors", () => { + expect(STATUS_DISPLAY.ok).toEqual({ label: "OK", color: "green" }); + expect(STATUS_DISPLAY.limited).toEqual({ + label: "LIMITED", + color: "yellow", + }); + expect(STATUS_DISPLAY["not-authenticated"]).toEqual({ + label: "NOT AUTHENTICATED", + color: "yellow", + }); + expect(STATUS_DISPLAY.exhausted).toEqual({ + label: "EXHAUSTED", + color: "red", + }); + expect(STATUS_DISPLAY.unknown).toEqual({ + label: "UNKNOWN", + color: "dim", + }); + }); + + it("falls back to unknown display values for unsupported keys", () => { + expect(getStatusDisplay("missing")).toEqual(STATUS_DISPLAY.unknown); + }); + + it("finds a status key by its display label", () => { + expect(getStatusKeyByLabel("LIMITED")).toBe("limited"); + expect(getStatusKeyByLabel(" NOT AUTHENTICATED ")).toBe( + "not-authenticated", + ); + expect(getStatusKeyByLabel("missing")).toBe("unknown"); + }); + + it("colors labels with the mapped chalk color", () => { + expect(colorStatus("ok")).toBe(chalk.green("OK")); + expect(colorStatus("missing")).toBe(chalk.dim("UNKNOWN")); + }); +}); diff --git a/packages/cli/src/__tests__/util/time-format.test.ts b/packages/cli/src/__tests__/util/time-format.test.ts new file mode 100644 index 00000000..2aa49564 --- /dev/null +++ b/packages/cli/src/__tests__/util/time-format.test.ts @@ -0,0 +1,69 @@ +import { + formatClockTime, + formatRelativeOrAbsoluteTime, +} from "../../util/time-format.js"; + +const now = new Date("2026-08-09T10:00:00.000Z"); +const localClock = (date: Date) => + `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; + +describe("time format util", () => { + it("formats local clock time as HH:mm", () => { + expect(formatClockTime(new Date(2026, 0, 5, 3, 4))).toBe("03:04"); + expect(formatClockTime(new Date(2026, 0, 5, 23, 59))).toBe("23:59"); + }); + + it("formats nearby future times with relative labels and clock time", () => { + expect( + formatRelativeOrAbsoluteTime("2026-08-09T10:05:00.000Z", { + now: () => now, + }), + ).toBe(`in 5m · ${localClock(new Date("2026-08-09T10:05:00.000Z"))}`); + expect( + formatRelativeOrAbsoluteTime("2026-08-09T14:53:00.000Z", { + now: () => now, + }), + ).toBe(`in 4h 53m · ${localClock(new Date("2026-08-09T14:53:00.000Z"))}`); + expect( + formatRelativeOrAbsoluteTime("2026-08-09T12:00:00.000Z", { + now: () => now, + }), + ).toBe(`in 2h · ${localClock(new Date("2026-08-09T12:00:00.000Z"))}`); + }); + + it("formats nearby past times with relative labels and clock time", () => { + expect( + formatRelativeOrAbsoluteTime("2026-08-09T09:55:00.000Z", { + now: () => now, + }), + ).toBe(`5m ago · ${localClock(new Date("2026-08-09T09:55:00.000Z"))}`); + expect( + formatRelativeOrAbsoluteTime("2026-08-09T07:07:00.000Z", { + now: () => now, + }), + ).toBe(`2h 53m ago · ${localClock(new Date("2026-08-09T07:07:00.000Z"))}`); + }); + + it("formats distant times as Mon D plus clock time", () => { + expect( + formatRelativeOrAbsoluteTime("2026-09-01T00:52:00.000Z", { + now: () => now, + }), + ).toBe(`Sep 1 · ${localClock(new Date("2026-09-01T00:52:00.000Z"))}`); + expect( + formatRelativeOrAbsoluteTime("2026-08-01T00:52:00.000Z", { + now: () => now, + }), + ).toBe(`Aug 1 · ${localClock(new Date("2026-08-01T00:52:00.000Z"))}`); + }); + + it("returns fallback text for missing, invalid, or effectively current times", () => { + expect(formatRelativeOrAbsoluteTime(null, { now: () => now })).toBe("—"); + expect(formatRelativeOrAbsoluteTime("nope", { now: () => now })).toBe("—"); + expect( + formatRelativeOrAbsoluteTime("2026-08-09T10:00:10.000Z", { + now: () => now, + }), + ).toBe("now"); + }); +}); diff --git a/packages/cli/src/commands/capacity/render.ts b/packages/cli/src/commands/capacity/render.ts index 46f263fc..609562db 100644 --- a/packages/cli/src/commands/capacity/render.ts +++ b/packages/cli/src/commands/capacity/render.ts @@ -1,24 +1,18 @@ import chalk from "chalk"; import { ui } from "../../util/terminal-ui.js"; +import { + colorStatus, + getStatusDisplay, + getStatusKeyByLabel, + type StatusKey, +} from "../../util/status.js"; +import { formatRelativeOrAbsoluteTime } from "../../util/time-format.js"; +import { pluralize } from "../../util/pluralize.js"; import type { CapacityReport, CapacityWindow } from "@ai-devkit/agent-manager"; const BAR_WIDTH = 10; const ELEVATED_USAGE = 70; const HIGH_USAGE = 90; -const MONTHS = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -]; const PROVIDER_LABELS: Record = { zai: "z.ai", openai: "OpenAI", @@ -35,30 +29,19 @@ function percent(value: number | null): string { return `${Number.isInteger(value) ? value : value.toFixed(1)}%`; } -type ReportStatus = keyof typeof STATUS_STYLES; - -const STATUS_STYLES = { - OK: chalk.green, - LIMITED: chalk.yellow, - "NOT AUTHENTICATED": chalk.yellow, - EXHAUSTED: chalk.red, - UNKNOWN: chalk.dim, -} as const; - -function reportStatus(report: CapacityReport): ReportStatus { - if (report.authenticated === false) return "NOT AUTHENTICATED"; - if (report.available === "no") return "EXHAUSTED"; - if (report.available === "unknown") return "UNKNOWN"; +function reportStatus(report: CapacityReport): StatusKey { + if (report.authenticated === false) return "not-authenticated"; + if (report.available === "no") return "exhausted"; + if (report.available === "unknown") return "unknown"; const maxUsed = report.windows.reduce( (max, window) => Math.max(max, window.usedPercent ?? 0), 0, ); - return maxUsed >= HIGH_USAGE ? "LIMITED" : "OK"; + return maxUsed >= HIGH_USAGE ? "limited" : "ok"; } function statusStyle(text: string): string { - const style = STATUS_STYLES[text.trim() as ReportStatus] ?? chalk.dim; - return style(text); + return colorStatus(getStatusKeyByLabel(text), text); } function usageStyle(text: string): string { @@ -90,24 +73,10 @@ function usageCell(window: CapacityWindow): string { return cell; } -function localClock(date: Date): string { - return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; -} - function resetLabel(resetsAt: string | null | undefined, now: Date): string { if (!resetsAt) return "—"; - const target = new Date(resetsAt); - if (Number.isNaN(target.getTime())) return "—"; - const remainingMs = target.getTime() - now.getTime(); - if (remainingMs <= 0) return "now"; - const minutes = Math.round(remainingMs / 60000); - const clock = localClock(target); - if (minutes < 60) return `in ${minutes}m · ${clock}`; - const hours = Math.floor(minutes / 60); - const rest = minutes % 60; - if (minutes < 24 * 60) - return `in ${hours}h${rest ? ` ${rest}m` : ""} · ${clock}`; - return `${MONTHS[target.getMonth()]} ${target.getDate()} · ${clock}`; + if (new Date(resetsAt).getTime() <= now.getTime()) return "now"; + return formatRelativeOrAbsoluteTime(resetsAt, { now: () => now }); } function sortedWindows(windows: CapacityWindow[]): CapacityWindow[] { @@ -122,16 +91,24 @@ function sortedWindows(windows: CapacityWindow[]): CapacityWindow[] { function reportRows(report: CapacityReport, now: Date): string[][] { const status = reportStatus(report); + const statusLabel = getStatusDisplay(status).label; const windows = sortedWindows(report.windows); if (windows.length === 0) { return [ - [report.harness, providerLabel(report.provider), status, "—", "—", "—"], + [ + report.harness, + providerLabel(report.provider), + statusLabel, + "—", + "—", + "—", + ], ]; } return windows.map((window) => [ report.harness, providerLabel(report.provider), - status, + statusLabel, window.label, usageCell(window), resetLabel(window.resetsAt, now), @@ -172,7 +149,12 @@ export function renderCapacityReports( `${report.harness} · ${providerLabel(report.provider)}`, ); ui.breakline(); - ui.text(`${identity} capacity · ${STATUS_STYLES[status](status)}`); + ui.text( + `${identity} capacity · ${colorStatus( + status, + getStatusDisplay(status).label, + )}`, + ); if (report.windows.length === 0) { ui.text(chalk.dim(" No usage windows reported.")); } else { @@ -196,7 +178,7 @@ export function renderCapacityReports( } ui.breakline(); - ui.text(chalk.bold(`Capacity · ${reports.length} providers`)); + ui.text(chalk.bold(`Capacity · ${pluralize(reports.length, "provider")}`)); ui.table({ headers: ["Harness", "Provider", "Status", "Quota", "Usage", "Resets"], rows: reports.flatMap((report) => reportRows(report, now)), diff --git a/packages/cli/src/util/pluralize.ts b/packages/cli/src/util/pluralize.ts new file mode 100644 index 00000000..7fd31661 --- /dev/null +++ b/packages/cli/src/util/pluralize.ts @@ -0,0 +1,7 @@ +export function pluralize( + count: number, + singular: string, + plural = `${singular}s`, +): string { + return `${count} ${count === 1 ? singular : plural}`; +} diff --git a/packages/cli/src/util/status.ts b/packages/cli/src/util/status.ts new file mode 100644 index 00000000..69f2d07f --- /dev/null +++ b/packages/cli/src/util/status.ts @@ -0,0 +1,35 @@ +import chalk from "chalk"; + +export type StatusColor = "green" | "yellow" | "red" | "dim"; + +export interface StatusDisplay { + label: string; + color: StatusColor; +} + +export const STATUS_DISPLAY = { + ok: { label: "OK", color: "green" }, + limited: { label: "LIMITED", color: "yellow" }, + "not-authenticated": { label: "NOT AUTHENTICATED", color: "yellow" }, + exhausted: { label: "EXHAUSTED", color: "red" }, + unknown: { label: "UNKNOWN", color: "dim" }, +} as const satisfies Record; + +export type StatusKey = keyof typeof STATUS_DISPLAY; + +export function getStatusDisplay(status: string): StatusDisplay { + return STATUS_DISPLAY[status as StatusKey] ?? STATUS_DISPLAY.unknown; +} + +export function getStatusKeyByLabel(label: string): StatusKey { + return ( + (Object.entries(STATUS_DISPLAY).find( + ([, display]) => display.label === label.trim(), + )?.[0] as StatusKey | undefined) ?? "unknown" + ); +} + +export function colorStatus(status: string, text?: string): string { + const display = getStatusDisplay(status); + return chalk[display.color](text ?? display.label); +} diff --git a/packages/cli/src/util/time-format.ts b/packages/cli/src/util/time-format.ts new file mode 100644 index 00000000..76c89c2e --- /dev/null +++ b/packages/cli/src/util/time-format.ts @@ -0,0 +1,70 @@ +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const MINUTE_MS = 60_000; +const HOUR_MINUTES = 60; +const DAY_MINUTES = 24 * HOUR_MINUTES; + +export interface RelativeOrAbsoluteTimeOptions { + now?: () => Date; + fallback?: string; +} + +export function formatClockTime(date: Date): string { + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +export function formatRelativeOrAbsoluteTime( + value: string | Date | null | undefined, + options: RelativeOrAbsoluteTimeOptions = {}, +): string { + const fallback = options.fallback ?? "—"; + if (!value) return fallback; + + const target = value instanceof Date ? value : new Date(value); + if (Number.isNaN(target.getTime())) return fallback; + + const now = options.now?.() ?? new Date(); + const deltaMinutes = Math.round( + (target.getTime() - now.getTime()) / MINUTE_MS, + ); + const absoluteMinutes = Math.abs(deltaMinutes); + const clock = formatClockTime(target); + + if (absoluteMinutes === 0) return "now"; + + if (absoluteMinutes < HOUR_MINUTES) { + return relativeLabel(deltaMinutes, `${absoluteMinutes}m`, clock); + } + + if (absoluteMinutes < DAY_MINUTES) { + const hours = Math.floor(absoluteMinutes / HOUR_MINUTES); + const minutes = absoluteMinutes % HOUR_MINUTES; + const duration = `${hours}h${minutes ? ` ${minutes}m` : ""}`; + return relativeLabel(deltaMinutes, duration, clock); + } + + return `${MONTHS[target.getMonth()]} ${target.getDate()} · ${clock}`; +} + +function relativeLabel( + deltaMinutes: number, + duration: string, + clock: string, +): string { + return deltaMinutes > 0 + ? `in ${duration} · ${clock}` + : `${duration} ago · ${clock}`; +}