diff --git a/lib/tui-status.ts b/lib/tui-status.ts index afc5cb4c..7a76dcc4 100644 --- a/lib/tui-status.ts +++ b/lib/tui-status.ts @@ -65,6 +65,7 @@ const variantSuffixes: ReasoningVariant[] = [ "none", ]; const STATUS_SEPARATOR = ` ${String.fromCharCode(183)} `; +const MS_PER_DAY = 24 * 60 * 60 * 1000; const WARNING_LIMIT_LEFT_PERCENT = 25; const DANGER_LIMIT_LEFT_PERCENT = 10; const MASKED_EMAIL = "*****"; @@ -301,12 +302,27 @@ function formatQuota(quota: CompactQuotaStatus): string | undefined { return undefined; } +/** + * Return the character budget for the prompt status line at a given terminal + * width. Budgets scale to about 54% of each tier's minimum width so the + * day-context reset labels and typical account hints fit. + */ function maxStatusChars(width: number | undefined): number { + // Budgets are ~54% of each named tier's minimum width (up from ~40%): the + // day-context reset labels and typical account hints no longer fit at + // 40%, which degraded informative candidates on mid-width terminals. The + // last branch is the exception and stays at 12, because 54% of a + // 40-column terminal leaves nothing for the prompt itself. + // + // An unknown width cannot be scaled at all, so it takes the narrowest + // tier budget rather than a mid-tier one: a 42-character line on the + // 40-column terminal this branch also covers wraps and pushes the prompt, + // and there is nothing here to detect that it happened. if (!width || !Number.isFinite(width)) return 32; - if (width >= 120) return 48; - if (width >= 96) return 40; - if (width >= 78) return 32; - if (width >= 60) return 22; + if (width >= 120) return 64; + if (width >= 96) return 52; + if (width >= 78) return 42; + if (width >= 60) return 32; return 12; } @@ -371,41 +387,98 @@ export function resolveQuotaPromptTone( return "warning"; } -function formatReset(resetAtMs: number | undefined): string | undefined { +type ResetParts = { + date: Date; + /** Locale-formatted 24-hour clock time, e.g. `02:25`. */ + time: string; + sameDay: boolean; + /** + * Calendar days from today, not elapsed milliseconds: a DST transition + * makes a seven-calendar-day gap span 167 or 169 hours, which a fixed 24h + * division would misclassify and repeat today's weekday. + */ + dayDiff: number; +}; + +/** + * Decompose a reset timestamp once for both renderings below. + * + * The compact status line and the quota details dialog word the same instant + * differently - `Sep 15 02:25` against `02:25 on Sep 15` - but they agree on + * every decision behind it: the same validity guard, the same 24-hour clock, + * the same same-day test. Keeping those in one place is what stops the two + * surfaces drifting apart on which reset is "today". + */ +function describeReset(resetAtMs: number | undefined): ResetParts | undefined { if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) { return undefined; } const date = new Date(resetAtMs); if (!Number.isFinite(date.getTime())) return undefined; const now = new Date(); - const sameDay = - now.getFullYear() === date.getFullYear() && - now.getMonth() === date.getMonth() && - now.getDate() === date.getDate(); - const time = date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); - if (sameDay) return time; - const day = date.toLocaleDateString(undefined, { + return { + date, + time: date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }), + sameDay: + now.getFullYear() === date.getFullYear() && + now.getMonth() === date.getMonth() && + now.getDate() === date.getDate(), + dayDiff: calendarDayDiff(now, date), + }; +} + +function formatResetDay(date: Date): string { + return date.toLocaleDateString(undefined, { month: "short", day: "2-digit", }); - return `${time} on ${day}`; } +function formatReset(resetAtMs: number | undefined): string | undefined { + const parts = describeReset(resetAtMs); + if (!parts) return undefined; + if (parts.sameDay) return parts.time; + return `${parts.time} on ${formatResetDay(parts.date)}`; +} + +/** + * Format a reset timestamp for the compact status line. Same-day resets keep + * the time only (`02:25`); resets within the coming week add the weekday + * (`Tue 02:25`); later resets use the absolute date (`Sep 15 02:25`). The + * time is always kept so short windows such as the 5h limit stay meaningful. + */ function formatResetTime(resetAtMs: number | undefined): string | undefined { - if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) { - return undefined; + const parts = describeReset(resetAtMs); + if (!parts) return undefined; + if (parts.sameDay) return parts.time; + // Within a week each weekday occurs exactly once, so the weekday alone + // disambiguates weekly windows; beyond that the absolute date does. + if (parts.dayDiff > 0 && parts.dayDiff < 7) { + const weekday = parts.date.toLocaleDateString(undefined, { + weekday: "short", + }); + return `${weekday} ${parts.time}`; } - const date = new Date(resetAtMs); - if (!Number.isFinite(date.getTime())) return undefined; - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); + return `${formatResetDay(parts.date)} ${parts.time}`; +} + +/** + * Count calendar days between two dates, ignoring wall-clock length. Comparing + * UTC-normalized year/month/day makes the count immune to DST transitions, + * which make a seven-day gap span 167 or 169 hours. + */ +function calendarDayDiff(from: Date, to: Date): number { + const fromDay = Date.UTC( + from.getFullYear(), + from.getMonth(), + from.getDate(), + ); + const toDay = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()); + return Math.round((toDay - fromDay) / MS_PER_DAY); } function formatUpdatedAge(fetchedAt: number | undefined, now: number): string { diff --git a/test/tui-status.test.ts b/test/tui-status.test.ts index 664c36c8..3568b305 100644 --- a/test/tui-status.test.ts +++ b/test/tui-status.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatPromptStatusText, @@ -10,16 +10,17 @@ import { type PromptStatusMessage, } from "../lib/tui-status.js"; +const sep = ` ${String.fromCharCode(183)} `; +const quota: CompactQuotaStatus = { + type: "ready", + limits: [ + { label: "5h", leftPercent: 88 }, + { label: "7d", leftPercent: 83 }, + ], + stale: false, +}; + describe("TUI prompt status helpers", () => { - const sep = ` ${String.fromCharCode(183)} `; - const quota: CompactQuotaStatus = { - type: "ready", - limits: [ - { label: "5h", leftPercent: 88 }, - { label: "7d", leftPercent: 83 }, - ], - stale: false, - }; it("formats prompt status text from supplied quota labels", () => { expect( @@ -181,25 +182,35 @@ describe("TUI prompt status helpers", () => { }); it("adds reset time to compact status only when quota is low", () => { - const resetStatus = formatPromptStatusText({ - quota: { - ...quota, - limits: [ - { label: "5h", leftPercent: 8, resetAtMs: Date.now() + 60_000 }, - { label: "7d", leftPercent: 83 }, - ], - }, - width: 120, - }); - - expect(resetStatus).toMatch(/5h 8% resets \d{2}:\d{2}/); - expect(resetStatus).toContain("7d 83%"); - expect( - formatPromptStatusText({ - quota, + // Pinned to midday. On the real clock a reset one minute out lands on + // tomorrow whenever the suite runs in the last minute before local + // midnight, and the day-context formatter then renders "Sat 00:00", + // which the leading digits below reject. + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 5, 12, 0)); + try { + const resetStatus = formatPromptStatusText({ + quota: { + ...quota, + limits: [ + { label: "5h", leftPercent: 8, resetAtMs: Date.now() + 60_000 }, + { label: "7d", leftPercent: 83 }, + ], + }, width: 120, - }), - ).not.toContain("resets"); + }); + + expect(resetStatus).toMatch(/5h 8% resets \d{2}:\d{2}/); + expect(resetStatus).toContain("7d 83%"); + expect( + formatPromptStatusText({ + quota, + width: 120, + }), + ).not.toContain("resets"); + } finally { + vi.useRealTimers(); + } }); it("formats quota details for the command dialog", () => { @@ -335,3 +346,115 @@ describe("TUI prompt status helpers", () => { expect(resolvePromptReasoningVariant({ config })).toBe("xhigh"); }); }); + +describe("formatResetTime day context", () => { + // Fake timers freeze both Date.now() and new Date() so the formatter's + // "now" and the fixed reset timestamps land on deterministic dates. + const now = new Date(2026, 8, 5, 12, 0); // 2026-09-05T12:00 local + // Expected labels are derived from the runtime's own Intl formatting so + // the assertions hold under any default locale. + const weekdayLabel = new Date(2026, 8, 8, 2, 25).toLocaleDateString( + undefined, + { weekday: "short" }, + ); + const dateLabel = new Date(2026, 8, 15, 2, 25).toLocaleDateString(undefined, { + month: "short", + day: "2-digit", + }); + const timeLabel = (h: number, m: number) => + new Date(2026, 8, 5, h, m) + .toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }) + .replace(/^24/, "00"); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps an unknown width inside the narrowest terminal it stands in for", () => { + // The renderer reports no width during an early render or from a + // detached renderer, and this branch also covers a 40-column + // terminal. A budget wider than that wraps the line and pushes the + // prompt, with nothing here able to detect it happened. + const out = formatPromptStatusText({ + quota: { + ...quota, + accountIndex: 1, + accountCount: 3, + accountEmail: "someone@student.university.edu", + limits: [ + { label: "5h", leftPercent: 8, resetAtMs: new Date(2026, 8, 15, 2, 25).getTime() }, + { label: "7d", leftPercent: 83 }, + ], + }, + }); + + expect(out.length).toBeLessThanOrEqual(32); + expect(out).not.toBe(""); + }); + + it("keeps time-only format for same-day resets", () => { + const out = formatPromptStatusText({ + quota: { + ...quota, + limits: [ + { label: "5h", leftPercent: 8, resetAtMs: new Date(2026, 8, 5, 18, 30).getTime() }, + ], + }, + width: 120, + }); + expect(out).toBe(`5h 8% resets ${timeLabel(18, 30)}`); + }); + + it("adds weekday for resets within the coming week", () => { + const out = formatPromptStatusText({ + quota: { + ...quota, + limits: [ + { label: "7d", leftPercent: 0, resetAtMs: new Date(2026, 8, 8, 2, 25).getTime() }, + ], + }, + width: 120, + }); + expect(out).toBe(`7d 0% resets ${weekdayLabel} ${timeLabel(2, 25)}`); + }); + + it("uses absolute date beyond a week", () => { + const out = formatPromptStatusText({ + quota: { + ...quota, + limits: [ + { label: "7d", leftPercent: 0, resetAtMs: new Date(2026, 8, 15, 2, 25).getTime() }, + ], + }, + width: 120, + }); + expect(out).toBe(`7d 0% resets ${dateLabel} ${timeLabel(2, 25)}`); + }); + + it("uses absolute date at exactly seven calendar days over a DST weekend", () => { + // 2026-03-02 -> 2026-03-09 is seven calendar days. In a zone that + // springs forward that weekend (America/New_York among them) the same + // gap is 167 hours, and a millisecond division reads it as six days + // and renders "Mon", repeating today's weekday. The assertion holds in + // any zone; it only exercises the DST path when the runner is in one. + vi.setSystemTime(new Date(2026, 2, 2, 12, 0)); + const reset = new Date(2026, 2, 9, 2, 25); + const expected = `${reset.toLocaleDateString(undefined, { month: "short", day: "2-digit" })} ${reset.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`; + const out = formatPromptStatusText({ + quota: { + ...quota, + limits: [{ label: "7d", leftPercent: 0, resetAtMs: reset.getTime() }], + }, + width: 120, + }); + expect(out).toBe(`7d 0% resets ${expected}`); + }); +});