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
125 changes: 99 additions & 26 deletions lib/tui-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "*****";
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down
179 changes: 151 additions & 28 deletions test/tui-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
formatPromptStatusText,
Expand All @@ -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(
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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,
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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}`);
});
});