diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 0af272c0c..1f9b080e9 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1368,10 +1368,12 @@ function formatUnlimitedReportLabel(report: UsageReport, index: number): string } function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined { - if (limit.window?.resetsAt !== undefined) { - return formatDuration(limit.window.resetsAt - nowMs); - } - return undefined; + const resetsAt = limit.window?.resetsAt; + if (resetsAt === undefined) return undefined; + // Codex returns the prior window's reset_at until a new request opens a fresh window — + // rendering a negative delta is meaningless, so drop the suffix in that case. + if (resetsAt <= nowMs) return undefined; + return formatDuration(resetsAt - nowMs); } function formatAccountHeaderRow( diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts index 9d737ce9f..bbef96730 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -56,7 +56,9 @@ function renderUsageReports(reports: UsageReport[], nowMs: number): string { lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`); lines.push(` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}`); lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`); - if (limit.window?.resetsAt) lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`); + if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) { + lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`); + } if (limit.notes && limit.notes.length > 0) lines.push(` ${limit.notes.join(" • ")}`); } } diff --git a/packages/utils/src/format.ts b/packages/utils/src/format.ts index 8e6a83a22..7e294e8a6 100644 --- a/packages/utils/src/format.ts +++ b/packages/utils/src/format.ts @@ -8,6 +8,7 @@ const DAY = 24 * HOUR; * Examples: "123ms", "1.5s", "30m15s", "2h30m", "3d2h" */ export function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return "0ms"; if (ms < SEC) return `${ms}ms`; if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`; if (ms < HOUR) { diff --git a/packages/utils/test/format.test.ts b/packages/utils/test/format.test.ts new file mode 100644 index 000000000..ab81942a0 --- /dev/null +++ b/packages/utils/test/format.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; +import { formatDuration } from "../src/format"; + +describe("formatDuration", () => { + // Codex's wham/usage endpoint returns the prior window's reset_at until the + // next request opens a fresh window, so the `resetsAt - now` delta can land + // in the recent past. The util must defend against that — older builds + // rendered "-612090ms", which leaked straight into the /usage TUI. + it("clamps non-positive, NaN, and Infinity inputs to 0ms", () => { + expect(formatDuration(-612_090)).toBe("0ms"); + expect(formatDuration(-1)).toBe("0ms"); + expect(formatDuration(0)).toBe("0ms"); + expect(formatDuration(Number.NaN)).toBe("0ms"); + expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("0ms"); + expect(formatDuration(Number.NEGATIVE_INFINITY)).toBe("0ms"); + }); + + it("formats sub-second, sub-minute, sub-hour, sub-day, and multi-day ranges", () => { + expect(formatDuration(500)).toBe("500ms"); + expect(formatDuration(1_500)).toBe("1.5s"); + expect(formatDuration(90_000)).toBe("1m30s"); + expect(formatDuration(3_600_000)).toBe("1h"); + expect(formatDuration(3_660_000)).toBe("1h1m"); + expect(formatDuration(2 * 86_400_000 + 3_600_000)).toBe("2d1h"); + }); +});