diff --git a/.changeset/usage-reset-local-time.md b/.changeset/usage-reset-local-time.md new file mode 100644 index 0000000000..a35ee1de2e --- /dev/null +++ b/.changeset/usage-reset-local-time.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-oauth": patch +--- + +Show the local reset time alongside the countdown in the /usage plan usage rows. diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 3869e2b11b..7ffe4939e7 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -227,7 +227,10 @@ function resetHintFrom(raw: Record): string | undefined { for (const key of ['reset_in', 'resetIn', 'ttl', 'window']) { const seconds = toInt(raw[key]); if (seconds !== null && seconds > 0) { - return `resets in ${formatDuration(seconds)}`; + // Read the clock once so the derived epoch and the countdown below agree; + // a second read would floor away a whole second of the supplied duration. + const now = Date.now(); + return resetHintForEpoch(now + seconds * 1000, now); } } return undefined; @@ -244,9 +247,40 @@ export function formatResetTime(val: string): string { } const parsed = Date.parse(normalised); if (!Number.isFinite(parsed)) return `resets at ${val}`; - const diffSec = Math.floor((parsed - Date.now()) / 1000); + return resetHintForEpoch(parsed); +} + +/** + * Builds the combined reset hint: relative duration plus the absolute reset + * moment rendered in the terminal's local timezone. `Date` getters are + * inherently local-time, so no explicit timezone conversion is needed. + */ +function resetHintForEpoch(epochMs: number, nowMs = Date.now()): string { + const diffSec = Math.floor((epochMs - nowMs) / 1000); if (diffSec <= 0) return 'reset'; - return `resets in ${formatDuration(diffSec)}`; + return `resets in ${formatDuration(diffSec)} (at ${formatLocalResetClock(epochMs, nowMs)})`; +} + +/** + * Local wall-clock rendering of the reset moment. The further the moment is + * from now, the more date context is included so a bare `14:30` is never + * ambiguous: + * same day → `14:30` + * same year → `01-21 14:30` + * different year → `2027-01-21 14:30` + */ +function formatLocalResetClock(epochMs: number, nowMs: number): string { + const d = new Date(epochMs); + const now = new Date(nowMs); + const pad = (n: number): string => String(n).padStart(2, '0'); + const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + if (d.getFullYear() !== now.getFullYear()) { + return `${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`; + } + if (d.getMonth() !== now.getMonth() || d.getDate() !== now.getDate()) { + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`; + } + return time; } export function formatDuration(totalSeconds: number): string { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index f3c493040c..8b626c97e5 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -14,6 +14,8 @@ import { afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); + vi.useRealTimers(); + vi.restoreAllMocks(); }); describe('kimiCodeBaseUrl', () => { @@ -119,7 +121,43 @@ describe('parseManagedUsagePayload', () => { it('surfaces reset hints from resetAt timestamps', () => { const future = new Date(Date.now() + 3600_000).toISOString(); const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, resetAt: future } }); - expect(parsed.summary?.resetHint).toMatch(/resets in/); + expect(parsed.summary?.resetHint).toMatch(/^resets in .+ \(at .+\)$/); + }); + + it('surfaces reset hints from relative reset_in seconds', () => { + // Frozen so the supplied duration survives verbatim: the epoch and the + // countdown must read the same clock, otherwise 120s renders as "1m". + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 21, 14, 0, 0)); + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10, reset_in: 120 }, + }); + expect(parsed.summary?.resetHint).toBe('resets in 2m (at 14:02)'); + }); + + // A frozen clock cannot catch the regression these two guard against: the + // epoch and the countdown must come from a *single* read, so the failure only + // appears once the clock ticks between them. Advance `Date.now` by 1ms per + // call to reproduce that in a deterministic way. + const tickingClock = (base: number): void => { + let calls = 0; + vi.spyOn(Date, 'now').mockImplementation(() => base + calls++); + }; + + it('keeps the supplied reset_in duration when the clock ticks mid-parse', () => { + tickingClock(new Date(2026, 0, 21, 14, 0, 0).getTime()); + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10, reset_in: 120 }, + }); + expect(parsed.summary?.resetHint).toBe('resets in 2m (at 14:02)'); + }); + + it('keeps a one-second reset_in window from collapsing to "reset"', () => { + tickingClock(new Date(2026, 0, 21, 14, 0, 0).getTime()); + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10, reset_in: 1 }, + }); + expect(parsed.summary?.resetHint).toBe('resets in 1s (at 14:00)'); }); it('extracts extra usage from boosterWallet.balance', () => { @@ -288,9 +326,42 @@ describe('formatResetTime', () => { expect(formatResetTime(past)).toBe('reset'); }); - it('returns "resets in X" for future timestamps', () => { - const future = new Date(Date.now() + 3600_000).toISOString(); - expect(formatResetTime(future)).toMatch(/^resets in /); + // The clock is frozen for the boundary cases below so each one asserts an + // exact string. Targets are built with the local-time `Date` constructor, so + // the expectations hold in any timezone the suite runs under. + const freezeAt = (local: Date): void => { + vi.useFakeTimers(); + vi.setSystemTime(local); + }; + + it('returns "resets in X" plus a local clock time for future timestamps', () => { + freezeAt(new Date(2026, 0, 21, 14, 0, 0)); + const future = new Date(2026, 0, 21, 15, 0, 0).toISOString(); + expect(formatResetTime(future)).toBe('resets in 1h (at 15:00)'); + }); + + it('shows bare HH:MM when the reset lands on the same local day', () => { + freezeAt(new Date(2026, 0, 21, 14, 0, 0)); + const target = new Date(2026, 0, 21, 14, 30, 0).toISOString(); + expect(formatResetTime(target)).toBe('resets in 30m (at 14:30)'); + }); + + it('includes the local date when the reset lands on another day of the same year', () => { + freezeAt(new Date(2026, 0, 21, 14, 0, 0)); + const target = new Date(2026, 0, 24, 9, 5, 0).toISOString(); + expect(formatResetTime(target)).toBe('resets in 2d 19h 5m (at 01-24 09:05)'); + }); + + it('includes the local date when the reset crosses midnight into the next day', () => { + freezeAt(new Date(2026, 0, 21, 23, 50, 0)); + const target = new Date(2026, 0, 22, 0, 20, 0).toISOString(); + expect(formatResetTime(target)).toBe('resets in 30m (at 01-22 00:20)'); + }); + + it('includes the year when the reset lands in a different year', () => { + freezeAt(new Date(2026, 11, 31, 23, 50, 0)); + const target = new Date(2027, 0, 1, 0, 20, 0).toISOString(); + expect(formatResetTime(target)).toBe('resets in 30m (at 2027-01-01 00:20)'); }); it('falls back when parsing fails', () => {