Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/usage-reset-local-time.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-oauth": patch
---

Show the local reset time alongside the countdown in the /usage plan usage rows.
40 changes: 37 additions & 3 deletions packages/oauth/src/managed-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ function resetHintFrom(raw: Record<string, unknown>): 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;
Expand All @@ -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 {
Expand Down
79 changes: 75 additions & 4 deletions packages/oauth/test/managed-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
vi.restoreAllMocks();
});

describe('kimiCodeBaseUrl', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down