Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/__tests__/util/pluralize.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
46 changes: 46 additions & 0 deletions packages/cli/src/__tests__/util/status.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/__tests__/util/time-format.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
84 changes: 33 additions & 51 deletions packages/cli/src/commands/capacity/render.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
zai: "z.ai",
openai: "OpenAI",
Expand All @@ -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 {
Expand Down Expand Up @@ -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[] {
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand All @@ -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)),
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/util/pluralize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function pluralize(
count: number,
singular: string,
plural = `${singular}s`,
): string {
return `${count} ${count === 1 ? singular : plural}`;
}
35 changes: 35 additions & 0 deletions packages/cli/src/util/status.ts
Original file line number Diff line number Diff line change
@@ -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<string, StatusDisplay>;

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