From 4c14eb6b1a2befafcacd0ab97e85ecce34333761 Mon Sep 17 00:00:00 2001 From: Mikhail Koniakhin Date: Wed, 26 Aug 2026 16:31:59 +0300 Subject: [PATCH 1/2] fix: preserve reset countdown minutes --- docs/readme/configuration.md | 2 +- src/lib/format-utils.ts | 16 +++---- src/lib/format.ts | 59 ++++++++++++++++++------- src/lib/quota-command-format.ts | 20 ++------- src/lib/toast-format-grouped.ts | 40 ++++++++++------- src/tui.tsx | 16 ++++--- tests/format.test.ts | 37 ++++++++++++---- tests/providers.cursor.surfaces.test.ts | 2 +- tests/providers.kilo.surfaces.test.ts | 2 +- tests/quota-command-format.test.ts | 19 ++++---- tests/tui-sidebar-format.test.ts | 6 +-- 11 files changed, 134 insertions(+), 85 deletions(-) diff --git a/docs/readme/configuration.md b/docs/readme/configuration.md index f5e3687c..c2efbbcd 100644 --- a/docs/readme/configuration.md +++ b/docs/readme/configuration.md @@ -350,7 +350,7 @@ Existing `experimental.quotaToast` settings remain supported. Quota settings do | `formatStyle` | `singleWindow` | Shared quota reset-period display for TUI popup toasts, the Sidebar panel, and Compact status line unless a TUI surface override is set: `singleWindow` shows one reset period per provider; `allWindows` shows all reset periods per provider. Legacy `classic`/`grouped` aliases are still accepted. | | `percentDisplayMode` | `remaining` | Percentage/bar direction across human surfaces: `remaining` shows the percentage left; `used` shows the percentage consumed. It does not rename literal basis facts. | | `accountingDetail` | `summary` | Provider-neutral accounting detail across human surfaces: `summary` keeps primary rows; `detailed` also admits supplementary rows and fuller basis detail when width allows. Independent of `formatStyle` and `percentDisplayMode`. | -| `resetTimeDecimals` | unset | Decimal places for compact reset countdowns in popup toasts, the Sidebar panel, and terminal `show`. Accepts integers `0`–`4`; unset preserves the default integer-day and half-hour-step display. | +| `resetTimeDecimals` | unset | Optional legacy compact reset display in popup toasts, the Sidebar panel, and terminal `show`. Accepts integers `0`–`4`; when unset, the default shows the exact remaining days, hours, and minutes as `DdHhMm`. | | `onlyCurrentModel` | `false` | Filter quota rows to the current model/provider when that session selection can be resolved. | | `showSessionTokens` | `true` | Show the `Session input/output tokens` section when session token data is available. When cached input is present, the section keeps the legacy `in/out` layout and appends cached input in parentheses next to the input amount. | | `sessionTokenScope` | `"current"` | Choose `current` for the active session only or `tree` for the active session plus recursive descendants/subagents, counted once. Applies to `/quota`, popup toasts, the Sidebar panel, and the compact input line when `showSessionTokens` is enabled. Does not change `/tokens_session` or `/tokens_session_all`. | diff --git a/src/lib/format-utils.ts b/src/lib/format-utils.ts index e993622a..0ba11eb6 100644 --- a/src/lib/format-utils.ts +++ b/src/lib/format-utils.ts @@ -142,10 +142,8 @@ export interface FormatResetCountdownOptions { */ missing?: string; /** - * When true, rounds down to the largest active unit. - * - 13d 5h -> 13d - * - 2h 14m -> 2h - * - 14m -> 14m + * Opt into the legacy compact display instead of the default exact-to-minute + * countdown. */ compactRounded?: boolean; /** @@ -161,7 +159,7 @@ const MS_PER_HOUR = 3_600_000; /** * Format a reset countdown for toast display. * - * Returns human-readable time like "2d 5h" or "3h 45m". + * Returns a precise-to-minute value like "2d5h14m", "3h45m", or "14m". * When reset time is in the past or invalid, returns "reset". */ export function formatResetCountdown(iso?: string, opts?: FormatResetCountdownOptions): string { @@ -171,7 +169,8 @@ export function formatResetCountdown(iso?: string, opts?: FormatResetCountdownOp const diffMs = resetDate.getTime() - now.getTime(); if (!Number.isFinite(diffMs) || diffMs <= 0) return "reset"; - const diffMinutes = Math.floor(diffMs / 60000); + // Round up partial minutes so the countdown never understates the time left. + const diffMinutes = Math.ceil(diffMs / 60_000); const days = Math.floor(diffMinutes / 1440); const hours = Math.floor((diffMinutes % 1440) / 60); const minutes = diffMinutes % 60; @@ -192,8 +191,9 @@ export function formatResetCountdown(iso?: string, opts?: FormatResetCountdownOp return `0.5h`; } - if (days > 0) return `${days}d ${hours}h`; - return `${hours}h ${minutes}m`; + if (days > 0) return `${days}d${hours}h${minutes}m`; + if (hours > 0) return `${hours}h${minutes}m`; + return `${minutes}m`; } export const MAX_RESET_TIME_DECIMALS = 4; diff --git a/src/lib/format.ts b/src/lib/format.ts index 0ef11921..2594acd7 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -166,18 +166,21 @@ export function formatQuotaRows(params: { // (i.e., any usage at all, or depleted) const timeStr = remaining < 100 - ? formatResetCountdown(resetIso, { - missing: "-", - compactRounded: true, - decimals: params.resetTimeDecimals, - }) + ? formatResetCountdown( + resetIso, + isResetTimeDecimals(params.resetTimeDecimals) + ? { + missing: "-", + compactRounded: true, + decimals: params.resetTimeDecimals, + } + : { missing: "-" }, + ) : ""; if (isTiny) { // In tiny mode: single line with name + time + percent - const timeWidth = isResetTimeDecimals(params.resetTimeDecimals) - ? Math.max(timeCol, timeStr.length) - : timeCol; + const timeWidth = Math.max(timeCol, timeStr.length); const tinyNameCol = Math.max( 1, maxWidth - separator.length - timeWidth - separator.length - percentValueCol, @@ -219,14 +222,29 @@ export function formatQuotaRows(params: { const timeStr = atomicValue && !resetIso ? "" - : formatResetCountdown(resetIso, { - missing: "-", - compactRounded: true, - decimals: params.resetTimeDecimals, - }); + : formatResetCountdown( + resetIso, + isResetTimeDecimals(params.resetTimeDecimals) + ? { + missing: "-", + compactRounded: true, + decimals: params.resetTimeDecimals, + } + : { missing: "-" }, + ); if (atomicValue) { const suffix = [value, timeStr].filter(Boolean).join(separator); + const nameAndValue = [name, value].filter(Boolean).join(separator); + if ( + timeStr && + nameAndValue.length <= maxWidth && + nameAndValue.length + separator.length + timeStr.length > maxWidth + ) { + lines.push(nameAndValue); + lines.push(padLeft(timeStr, maxWidth)); + return; + } if (suffix.length > maxWidth) { const visibleValue = value.length <= maxWidth @@ -249,11 +267,20 @@ export function formatQuotaRows(params: { return; } + const nameAndValue = [name, value].filter(Boolean).join(separator); + if ( + timeStr && + nameAndValue.length <= maxWidth && + nameAndValue.length + separator.length + timeStr.length > maxWidth + ) { + lines.push(nameAndValue); + lines.push(padLeft(timeStr, maxWidth)); + return; + } + if (isTiny) { // Tiny: single line without percent; keep time col alignment. - const timeWidth = isResetTimeDecimals(params.resetTimeDecimals) - ? Math.max(timeCol, timeStr.length) - : timeCol; + const timeWidth = Math.max(timeCol, timeStr.length); const valueCol = Math.min(value.length, Math.max(6, percentCol + 2)); const tinyNameCol = maxWidth - separator.length - timeWidth - separator.length - valueCol; const nameCol = Math.max(1, tinyNameCol); diff --git a/src/lib/quota-command-format.ts b/src/lib/quota-command-format.ts index 001b2796..68c46a9f 100644 --- a/src/lib/quota-command-format.ts +++ b/src/lib/quota-command-format.ts @@ -14,6 +14,7 @@ import { bar, formatDisplayedPercentLabel, formatLocalCallTimestamp, + formatResetCountdown, formatTokenCount, padLeft, padRight, @@ -30,24 +31,9 @@ import { import { SESSION_TOKEN_SECTION_HEADING } from "./session-tokens-format.js"; import type { QuotaToastConfig } from "./types.js"; -/** - * Format reset time in compact form (different from toast countdown). - * Uses seconds/minutes/hours/days format for /quota command. - */ -function formatResetTimeSeconds(diffSeconds: number): string { - if (!Number.isFinite(diffSeconds) || diffSeconds <= 0) return "now"; - if (diffSeconds < 60) return `${Math.ceil(diffSeconds)}s`; - if (diffSeconds < 3600) return `${Math.ceil(diffSeconds / 60)}m`; - if (diffSeconds < 86400) return `${Math.round(diffSeconds / 3600)}h`; - return `${Math.round(diffSeconds / 86400)}d`; -} - function formatResetsIn(iso?: string): string { - if (!iso) return ""; - const t = new Date(iso).getTime(); - if (!Number.isFinite(t)) return ""; - const diffSeconds = (t - Date.now()) / 1000; - return ` | resets in ${formatResetTimeSeconds(diffSeconds)}`; + if (!iso || !Number.isFinite(new Date(iso).getTime())) return ""; + return ` | resets in ${formatResetCountdown(iso)}`; } export const QUOTA_COMMAND_BAR_WIDTH = 10; diff --git a/src/lib/toast-format-grouped.ts b/src/lib/toast-format-grouped.ts index 0c7e0260..6421b8bf 100644 --- a/src/lib/toast-format-grouped.ts +++ b/src/lib/toast-format-grouped.ts @@ -138,15 +138,28 @@ export function formatQuotaRowsGrouped(params: { const isAtomicValue = interpretation.display.entryKind !== "value"; const label = entry.semantic ? interpretation.label : entry.label?.trim() || entry.name; const timeStr = entry.resetTimeIso - ? formatResetCountdown(entry.resetTimeIso, { - compactRounded: true, - decimals: params.resetTimeDecimals, - }) + ? formatResetCountdown( + entry.resetTimeIso, + isResetTimeDecimals(params.resetTimeDecimals) + ? { compactRounded: true, decimals: params.resetTimeDecimals } + : undefined, + ) : ""; const value = interpretation.display.entryKind === "value" ? interpretation.display.text.trim() : interpretation.display.text; + const leftText = right ? `${label} ${right}` : label; + const labelAndValue = [leftText, value].filter(Boolean).join(separator); + if ( + timeStr && + labelAndValue.length <= maxWidth && + labelAndValue.length + separator.length + timeStr.length > maxWidth + ) { + lines.push(labelAndValue); + lines.push(padLeft(timeStr, maxWidth)); + continue; + } if (isAtomicValue) { const suffix = [value, timeStr].filter(Boolean).join(separator); @@ -168,9 +181,7 @@ export function formatQuotaRowsGrouped(params: { if (isTiny) { // Tiny: "label time value" - const timeWidth = isResetTimeDecimals(params.resetTimeDecimals) - ? Math.max(timeCol, timeStr.length) - : timeCol; + const timeWidth = Math.max(timeCol, timeStr.length); const valueCol = Math.min(value.length, Math.max(6, percentCol + 2)); const tinyNameCol = Math.max( 1, @@ -193,7 +204,6 @@ export function formatQuotaRowsGrouped(params: { 1, barWidth - separator.length - valueWidth - separator.length - timeWidth, ); - const leftText = right ? `${label} ${right}` : label; lines.push( ( padRight(leftText, leftMax) + @@ -226,17 +236,17 @@ export function formatQuotaRowsGrouped(params: { // (i.e., any usage at all, or depleted) const timeStr = interpretation.display.percentRemaining < 100 - ? formatResetCountdown(entry.resetTimeIso, { - compactRounded: true, - decimals: params.resetTimeDecimals, - }) + ? formatResetCountdown( + entry.resetTimeIso, + isResetTimeDecimals(params.resetTimeDecimals) + ? { compactRounded: true, decimals: params.resetTimeDecimals } + : undefined, + ) : ""; if (isTiny) { // Tiny: single line with name/time/percent (or just the right summary) - const timeWidth = isResetTimeDecimals(params.resetTimeDecimals) - ? Math.max(timeCol, timeStr.length) - : timeCol; + const timeWidth = Math.max(timeCol, timeStr.length); const visibleBarSuffix = percentLabel.slice(0, percentValueCol); if (isValueRow) { const tinyNameCol = Math.max( diff --git a/src/tui.tsx b/src/tui.tsx index e1f93ec6..aa1894b0 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -8,7 +8,11 @@ import type { } from "@opencode-ai/plugin/tui"; import type { JSX } from "@opentui/solid"; import { createEffect, createSignal, onCleanup, Show } from "solid-js"; -import { formatDisplayedPercentLabel, formatResetCountdown } from "./lib/format-utils.js"; +import { + formatDisplayedPercentLabel, + formatResetCountdown, + isResetTimeDecimals, +} from "./lib/format-utils.js"; import { buildQuotaDialogCommandOutput, QUOTA_DIALOG_COMMANDS, @@ -516,10 +520,12 @@ function buildPromptBarParts(params: { const entry = bar.entry; if (!entry) return undefined; const reset = entry.resetTimeIso - ? formatResetCountdown(entry.resetTimeIso, { - compactRounded: true, - decimals: bar.resetTimeDecimals, - }) + ? formatResetCountdown( + entry.resetTimeIso, + isResetTimeDecimals(bar.resetTimeDecimals) + ? { compactRounded: true, decimals: bar.resetTimeDecimals } + : undefined, + ) : ""; const hasPercent = Number.isFinite(entry.percentRemaining); diff --git a/tests/format.test.ts b/tests/format.test.ts index b3daa6a6..87483ca0 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -168,7 +168,7 @@ describe("formatQuotaRows", () => { expect(out).not.toMatch(/\d+[dhms]/); }); - it("uses compact rounded reset labels for single-window rows", () => { + it("uses precise reset labels for single-window rows by default", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); @@ -184,11 +184,11 @@ describe("formatQuotaRows", () => { ], }); - expect(out).toContain("2.5h"); - expect(out).not.toContain("2h 14m"); + expect(out).toContain("2h14m"); + expect(out).not.toContain("2.5h"); }); - it("uses compact rounded reset labels for grouped rows", () => { + it("uses precise reset labels for grouped rows by default", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); @@ -207,8 +207,28 @@ describe("formatQuotaRows", () => { ], }); - expect(out).toContain("0.5h"); - expect(out).not.toContain("0h 14m"); + expect(out).toContain("14m"); + expect(out).not.toContain("0.5h"); + }); + + it("rounds partial minutes up instead of understating the remaining time", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + const out = formatQuotaRows({ + version: "1.0.0", + layout: { maxWidth: 50, narrowAt: 42, tinyAt: 32 }, + entries: [ + { + name: "OpenAI 5h", + percentRemaining: 50, + resetTimeIso: "2026-01-15T10:00:01.000Z", + }, + ], + }); + + expect(out).toContain("1m"); + expect(out).not.toContain("0m"); }); it("renders fractional reset countdowns when resetTimeDecimals is set (single-window)", () => { @@ -344,7 +364,7 @@ describe("formatQuotaRows", () => { } }); - it("keeps the default compact rounding when resetTimeDecimals is unset", () => { + it("keeps days, hours, and minutes when resetTimeDecimals is unset", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); @@ -360,7 +380,8 @@ describe("formatQuotaRows", () => { ], }); - expect(out).toContain("5d"); + expect(out).toContain("5d16h48m"); + expect(out).not.toMatch(/5d\s*$/mu); expect(out).not.toContain("5.7d"); }); diff --git a/tests/providers.cursor.surfaces.test.ts b/tests/providers.cursor.surfaces.test.ts index 652f06ad..5ba8fdde 100644 --- a/tests/providers.cursor.surfaces.test.ts +++ b/tests/providers.cursor.surfaces.test.ts @@ -80,7 +80,7 @@ describe("Cursor structured four-surface formatting", () => { for (const output of [outputs.command, outputs.toast, outputs.sidebar]) { expect(output).toContain("Auto+Composer spend"); expect(output).toContain("USD 1.25"); - expect(output).toMatch(/\b\d+d\b/u); + expect(output).toMatch(/\b\d+d\d+h\d+m\b/u); } expect(outputs.command).toContain("Used: USD 5.00"); expect(outputs.command).toContain("Limit: USD 20.00"); diff --git a/tests/providers.kilo.surfaces.test.ts b/tests/providers.kilo.surfaces.test.ts index 984e0969..e6c65759 100644 --- a/tests/providers.kilo.surfaces.test.ts +++ b/tests/providers.kilo.surfaces.test.ts @@ -116,7 +116,7 @@ describe("Kilo Gateway structured four-surface formatting", () => { expect(output).not.toContain("$"); } for (const output of [outputs.command, outputs.toast, outputs.sidebar]) { - expect(output).toMatch(/\b\d+d\b/u); + expect(output).toMatch(/\b\d+d\d+h\d+m\b/u); } }); diff --git a/tests/quota-command-format.test.ts b/tests/quota-command-format.test.ts index 38640f01..60fc4e37 100644 --- a/tests/quota-command-format.test.ts +++ b/tests/quota-command-format.test.ts @@ -93,17 +93,17 @@ describe("formatQuotaCommand", () => { expect(out.match(/[█░]{10}/gu)).toHaveLength(4); expect(lines.slice(2).join("\n")).toMatchInlineSnapshot(` "→ [Copilot] (personal) - Quota █████████░ 86% left | 42/300 | reset 12h + Quota █████████░ 86% left | 42/300 | reset 12h0m → [Copilot] (business) - Usage 9 used | 2026-01 | org=acme-corp | user=alice | reset 17d + Usage 9 used | 2026-01 | org=acme-corp | user=alice | reset 16d12h0m → [OpenAI] (Pro) - 5h quota ████░░░░░░ 42% left | reset 2h - Week quota ████████░░ 81% left | reset 3d + 5h quota ████░░░░░░ 42% left | reset 2h0m + Week quota ████████░░ 81% left | reset 3d0h0m → [Antigravity (acct)] - Claude ███████░░░ 67% left | reset 3h + Claude ███████░░░ 67% left | reset 3h0m Session input/output tokens openai/gpt-5: 1.2K in | 456 cached | 567 out @@ -350,8 +350,8 @@ describe("formatQuotaCommand", () => { const rows = output.split("\n").filter((line) => line.includes(" | reset ")); expect(rows).toEqual([ - " 5h quota ██████░░░░ 60% left | 2/5 | reset 5h", - " Day quota ████████░░ 80% left | 2/10 | reset 11h", + " 5h quota ██████░░░░ 60% left | 2/5 | reset 5h0m", + " Day quota ████████░░ 80% left | 2/10 | reset 11h0m", ]); expect(output).not.toContain("```"); expect(output).not.toMatch(/^## /mu); @@ -368,7 +368,7 @@ describe("formatQuotaCommand", () => { expect(output).toContain("Example: secondary source failed"); }); - it("keeps /quota reset formatting independent from compact toast resets", () => { + it("uses the shared precise-to-minute reset formatter", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); @@ -385,8 +385,7 @@ describe("formatQuotaCommand", () => { errors: [], }); - // /quota keeps its own formatter (hour-rounded here), not toast compact rounding. - expect(out).toContain("reset 3h"); + expect(out).toContain("reset 2h40m"); }); it("aligns reset columns when usage values have different widths", () => { diff --git a/tests/tui-sidebar-format.test.ts b/tests/tui-sidebar-format.test.ts index 5a67ce5c..fcc5f3f0 100644 --- a/tests/tui-sidebar-format.test.ts +++ b/tests/tui-sidebar-format.test.ts @@ -320,7 +320,7 @@ describe("buildSidebarQuotaPanelLines", () => { expect(rendered).not.toContain("0% used"); }); - it("uses compact rounded reset text in sidebar rows", () => { + it("uses precise reset text in sidebar rows by default", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); @@ -342,8 +342,8 @@ describe("buildSidebarQuotaPanelLines", () => { }, }); - expect(lines.join("\n")).toContain("2.5h"); - expect(lines.join("\n")).not.toContain("2h 14m"); + expect(lines.join("\n")).toContain("2h14m"); + expect(lines.join("\n")).not.toContain("2.5h"); }); it("uses fractional reset text in sidebar rows when resetTimeDecimals is set", () => { From 865ed410a512677fa9dadecec0158719454be139 Mon Sep 17 00:00:00 2001 From: Mikhail Koniakhin Date: Thu, 3 Sep 2026 18:44:54 +0300 Subject: [PATCH 2/2] feat: add spaced reset countdowns and bare percent labels Add two opt-in display settings surfaced from the PR #239 discussion: - resetTimeSpaced joins compound exact-minute reset countdown units with spaces (2d 5h 14m instead of 2d5h14m) in popup toasts, the Sidebar panel, /quota, and terminal show. Single-unit values and the legacy resetTimeDecimals compact display are unchanged. - percentLabelStyle: "bare" drops the direction word from percent labels (68% instead of 68% left), widens Sidebar bars via the narrower percent column floor, and marks the Sidebar header as Quota [Remaining] or Quota [Used] so the percent meaning stays visible. Both default to the existing compact display. --- README.md | 2 +- docs/readme/configuration.md | 22 ++++++ src/lib/cli-show.ts | 2 + src/lib/config.ts | 34 ++++++++++ src/lib/format-utils.ts | 22 +++++- src/lib/format.ts | 24 +++++-- src/lib/quota-command-format.ts | 19 ++++-- src/lib/quota-dialog-commands.ts | 2 + src/lib/quota-toast-runtime.ts | 2 + src/lib/toast-format-grouped.ts | 17 +++-- src/lib/tui-panel-state.ts | 3 + src/lib/tui-runtime.ts | 7 +- src/lib/tui-sidebar-format.ts | 11 ++- src/lib/types.ts | 12 ++++ src/tui.tsx | 14 ++-- tests/format-utils.test.ts | 92 +++++++++++++++++++++++++ tests/format.test.ts | 104 +++++++++++++++++++++++++++++ tests/lib.config.test.ts | 40 +++++++++++ tests/quota-command-format.test.ts | 26 ++++++++ tests/tui-runtime.test.ts | 54 +++++++++++++++ tests/tui-sidebar-format.test.ts | 71 ++++++++++++++++++++ tests/tui-smoke.test.ts | 54 +++++++++++++++ 22 files changed, 606 insertions(+), 28 deletions(-) create mode 100644 tests/format-utils.test.ts diff --git a/README.md b/README.md index 5639112a..6872003a 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ More ways to use it: - Check quota anywhere: use `opencode-quota show` in a terminal or the same slash commands in the TUI, Web, and Desktop. - Automate quota checks with JSON output for scripts, status bars, and CI. Optional OpenTelemetry metrics support monitoring tools. -- Customize the display with [`tuiPromptBar.enabled`](docs/readme/configuration.md#tui-settings), OpenCode Go's preferred collapsed-sidebar window, reset precision, and [`accountingDetail`](docs/readme/configuration.md#show-accounting-detail). +- Customize the display with [`tuiPromptBar.enabled`](docs/readme/configuration.md#tui-settings), OpenCode Go's preferred collapsed-sidebar window, reset precision, spaced reset countdowns, plain percent labels, and [`accountingDetail`](docs/readme/configuration.md#show-accounting-detail). - Choose current-session or descendant-tree token totals. Get reset popups for selected windows with [`resetNotifications`](docs/readme/configuration.md#notify-when-quota-becomes-available-again). - Troubleshoot authentication, quota sources, pricing, and maintainer notices. diff --git a/docs/readme/configuration.md b/docs/readme/configuration.md index c2efbbcd..95b3148b 100644 --- a/docs/readme/configuration.md +++ b/docs/readme/configuration.md @@ -24,6 +24,8 @@ Strict `.json` files also work. Run `/quota_status` if you are unsure which file | Show every reset period | `formatStyle: "allWindows"` | | Show one quota window per provider | `formatStyle: "singleWindow"` | | Show quota used instead of left | `percentDisplayMode: "used"` | +| Space out compound reset countdowns | `resetTimeSpaced: true` | +| Show plain percent labels | `percentLabelStyle: "bare"` | | Show supplementary accounting facts | `accountingDetail: "detailed"` | | Show slash results with messages | `tuiCommandDisplay: "inline"` | | Show slash results in a TUI popup | `tuiCommandDisplay: "dialog"` | @@ -274,6 +276,24 @@ Leave it unset to preserve the default display exactly. +
+Space out reset countdowns and simplify percent labels + +Set `resetTimeSpaced` to `true` to join compound exact-minute countdowns with spaces (`2d 5h 14m` instead of `2d5h14m`) in popup toasts, the Sidebar panel, `/quota`, and terminal `show`. Single-unit values stay unchanged. + +Set `percentLabelStyle` to `"bare"` to drop the direction word from percent labels (`81%` instead of `81% left`). The Sidebar panel widens its bars with the freed columns and marks the header as `Quota [Remaining]` or `Quota [Used]` so the percent meaning stays visible. + +```jsonc +{ + "resetTimeSpaced": true, + "percentLabelStyle": "bare", +} +``` + +Both settings are unset by default and preserve the compact display exactly. + +
+
Change maintainer notices @@ -351,6 +371,8 @@ Existing `experimental.quotaToast` settings remain supported. Quota settings do | `percentDisplayMode` | `remaining` | Percentage/bar direction across human surfaces: `remaining` shows the percentage left; `used` shows the percentage consumed. It does not rename literal basis facts. | | `accountingDetail` | `summary` | Provider-neutral accounting detail across human surfaces: `summary` keeps primary rows; `detailed` also admits supplementary rows and fuller basis detail when width allows. Independent of `formatStyle` and `percentDisplayMode`. | | `resetTimeDecimals` | unset | Optional legacy compact reset display in popup toasts, the Sidebar panel, and terminal `show`. Accepts integers `0`–`4`; when unset, the default shows the exact remaining days, hours, and minutes as `DdHhMm`. | +| `resetTimeSpaced` | unset | When `true`, exact-minute reset countdowns join compound units with spaces (`2d 5h 14m` instead of `2d5h14m`) in popup toasts, the Sidebar panel, `/quota`, and terminal `show`. Ignored while `resetTimeDecimals` selects the legacy compact display. | +| `percentLabelStyle` | `"full"` | Percent label wording on human surfaces: `"full"` appends the direction word (`81% left` / `19% used`); `"bare"` shows the plain percent (`81%`), widens Sidebar bars, and marks the Sidebar header as `Quota [Remaining]` or `Quota [Used]`. | | `onlyCurrentModel` | `false` | Filter quota rows to the current model/provider when that session selection can be resolved. | | `showSessionTokens` | `true` | Show the `Session input/output tokens` section when session token data is available. When cached input is present, the section keeps the legacy `in/out` layout and appends cached input in parentheses next to the input amount. | | `sessionTokenScope` | `"current"` | Choose `current` for the active session only or `tree` for the active session plus recursive descendants/subagents, counted once. Applies to `/quota`, popup toasts, the Sidebar panel, and the compact input line when `showSessionTokens` is enabled. Does not change `/tokens_session` or `/tokens_session_all`. | diff --git a/src/lib/cli-show.ts b/src/lib/cli-show.ts index 84622bd6..1a0a560c 100644 --- a/src/lib/cli-show.ts +++ b/src/lib/cli-show.ts @@ -359,8 +359,10 @@ export async function runCliShowCommand(options: RunCliShowCommandOptions = {}): errors: data.errors, style: resolveQuotaFormatStyle(config.formatStyle), percentDisplayMode: config.percentDisplayMode, + percentLabelStyle: config.percentLabelStyle, accountingDetail: config.accountingDetail, resetTimeDecimals: config.resetTimeDecimals, + resetTimeSpaced: config.resetTimeSpaced, }); if (!output.trim()) { diff --git a/src/lib/config.ts b/src/lib/config.ts index ed5cd3af..b0f3055b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -23,6 +23,7 @@ import type { CursorQuotaPlan, GoogleModelId, PercentDisplayMode, + PercentLabelStyle, PricingSnapshotSource, QuotaResetWindow, QuotaToastConfig, @@ -47,6 +48,8 @@ export const QUOTA_TOAST_SETTING_SOURCE_KEYS = [ "percentDisplayMode", "accountingDetail", "resetTimeDecimals", + "resetTimeSpaced", + "percentLabelStyle", "minIntervalMs", "requestTimeoutMs", "debug", @@ -159,6 +162,8 @@ type ValidatedQuotaToastPatch = { percentDisplayMode?: PercentDisplayMode; accountingDetail?: QuotaToastConfig["accountingDetail"]; resetTimeDecimals?: number; + resetTimeSpaced?: boolean; + percentLabelStyle?: PercentLabelStyle; minIntervalMs?: number; requestTimeoutMs?: number; debug?: boolean; @@ -245,6 +250,10 @@ function isValidPercentDisplayMode(value: unknown): value is PercentDisplayMode return value === "remaining" || value === "used"; } +function isValidPercentLabelStyle(value: unknown): value is PercentLabelStyle { + return value === "full" || value === "bare"; +} + function isValidAccountingDetail(value: unknown): value is QuotaToastConfig["accountingDetail"] { return value === "summary" || value === "detailed"; } @@ -692,6 +701,21 @@ function extractValidatedQuotaToastPatch( patch.resetTimeDecimals = quotaToastConfig.resetTimeDecimals; } + if ( + hasOwnKey(quotaToastConfig, "resetTimeSpaced") && + typeof quotaToastConfig.resetTimeSpaced === "boolean" + ) { + patch.resetTimeSpaced = quotaToastConfig.resetTimeSpaced; + } + + if (hasOwnKey(quotaToastConfig, "percentLabelStyle")) { + if (isValidPercentLabelStyle(quotaToastConfig.percentLabelStyle)) { + patch.percentLabelStyle = quotaToastConfig.percentLabelStyle; + } else { + reportIssue?.("percentLabelStyle", 'expected "full" or "bare"'); + } + } + if ( hasOwnKey(quotaToastConfig, "minIntervalMs") && isPositiveNumber(quotaToastConfig.minIntervalMs) @@ -957,6 +981,16 @@ function applyValidatedQuotaToastPatch( applySettingSource(settingSources, "resetTimeDecimals", sourcePath); } + if (hasOwnKey(patch, "resetTimeSpaced")) { + config.resetTimeSpaced = patch.resetTimeSpaced; + applySettingSource(settingSources, "resetTimeSpaced", sourcePath); + } + + if (hasOwnKey(patch, "percentLabelStyle")) { + config.percentLabelStyle = patch.percentLabelStyle; + applySettingSource(settingSources, "percentLabelStyle", sourcePath); + } + if (hasOwnKey(patch, "minIntervalMs")) { config.minIntervalMs = patch.minIntervalMs!; applySettingSource(settingSources, "minIntervalMs", sourcePath); diff --git a/src/lib/format-utils.ts b/src/lib/format-utils.ts index 0ba11eb6..dfb6ea76 100644 --- a/src/lib/format-utils.ts +++ b/src/lib/format-utils.ts @@ -7,7 +7,7 @@ * - quota-command-format.ts (/quota command) */ -import type { PercentDisplayMode } from "./types.js"; +import type { PercentDisplayMode, PercentLabelStyle } from "./types.js"; /** * Clamp a number to an integer within [min, max]. @@ -67,13 +67,23 @@ export function resolveDisplayedPercent( export function formatDisplayedPercentLabel( percentRemaining: number, mode: PercentDisplayMode = "remaining", + labelStyle?: PercentLabelStyle, ): string { const displayedPercent = resolveDisplayedPercent(percentRemaining, mode); + if (labelStyle === "bare") return `${displayedPercent}%`; return `${displayedPercent}% ${mode === "used" ? "used" : "left"}`; } export const DISPLAYED_PERCENT_LABEL_WIDTH = "100% used".length; +/** + * Column floor for displayed percent labels: the full word-suffixed label by + * default, or the bare percent value when labelStyle is "bare". + */ +export function displayedPercentLabelWidth(labelStyle?: PercentLabelStyle): number { + return labelStyle === "bare" ? "100%".length : DISPLAYED_PERCENT_LABEL_WIDTH; +} + /** * Format a token count with K/M suffix for compactness. * @@ -151,6 +161,11 @@ export interface FormatResetCountdownOptions { * many decimal places instead of the default integer-day / half-hour steps. */ decimals?: number; + /** + * Join compound exact-to-minute units with spaces ("2d 5h 14m") instead of + * the compact token form ("2d5h14m"). Single-unit output is unchanged. + */ + spaced?: boolean; } const MS_PER_DAY = 86_400_000; @@ -191,8 +206,9 @@ export function formatResetCountdown(iso?: string, opts?: FormatResetCountdownOp return `0.5h`; } - if (days > 0) return `${days}d${hours}h${minutes}m`; - if (hours > 0) return `${hours}h${minutes}m`; + const unitSeparator = opts?.spaced ? " " : ""; + if (days > 0) return `${days}d${unitSeparator}${hours}h${unitSeparator}${minutes}m`; + if (hours > 0) return `${hours}h${unitSeparator}${minutes}m`; return `${minutes}m`; } diff --git a/src/lib/format.ts b/src/lib/format.ts index 2594acd7..ec113f35 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -7,7 +7,7 @@ import type { QuotaToastEntry, QuotaToastError, SessionTokensData } from "./entr import { isPercentEntry } from "./entries.js"; import { bar, - DISPLAYED_PERCENT_LABEL_WIDTH, + displayedPercentLabelWidth, formatDisplayedPercentLabel, formatResetCountdown, isResetTimeDecimals, @@ -100,8 +100,10 @@ export function formatQuotaRows(params: { errors?: QuotaToastError[]; style?: QuotaFormatStyle; percentDisplayMode?: QuotaToastConfig["percentDisplayMode"]; + percentLabelStyle?: QuotaToastConfig["percentLabelStyle"]; accountingDetail?: QuotaToastConfig["accountingDetail"]; resetTimeDecimals?: number; + resetTimeSpaced?: boolean; sessionTokens?: SessionTokensData; }): string { const styleDefinition = getQuotaFormatStyleDefinition(params.style); @@ -112,8 +114,10 @@ export function formatQuotaRows(params: { entries: params.entries, errors: params.errors, percentDisplayMode: params.percentDisplayMode, + percentLabelStyle: params.percentLabelStyle, accountingDetail: params.accountingDetail, resetTimeDecimals: params.resetTimeDecimals, + resetTimeSpaced: params.resetTimeSpaced, sessionTokens: params.sessionTokens, }); } @@ -130,12 +134,16 @@ export function formatQuotaRows(params: { const separator = " "; const percentCol = Math.max( - DISPLAYED_PERCENT_LABEL_WIDTH, + displayedPercentLabelWidth(params.percentLabelStyle), ...(params.entries ?? []) .filter(isPercentEntry) .map( (entry) => - formatDisplayedPercentLabel(entry.percentRemaining, params.percentDisplayMode).length, + formatDisplayedPercentLabel( + entry.percentRemaining, + params.percentDisplayMode, + params.percentLabelStyle, + ).length, ), ); @@ -157,7 +165,11 @@ export function formatQuotaRows(params: { rightSummary?: string, ) => { const displayedPercent = resolveDisplayedPercent(remaining, params.percentDisplayMode); - const percentLabel = formatDisplayedPercentLabel(remaining, params.percentDisplayMode); + const percentLabel = formatDisplayedPercentLabel( + remaining, + params.percentDisplayMode, + params.percentLabelStyle, + ); const visibleBarSuffix = percentLabel.slice(0, percentValueCol); const summary = rightSummary?.trim() || ""; const leftText = summary ? `${name} ${summary}` : name; @@ -174,7 +186,7 @@ export function formatQuotaRows(params: { compactRounded: true, decimals: params.resetTimeDecimals, } - : { missing: "-" }, + : { missing: "-", spaced: params.resetTimeSpaced }, ) : ""; @@ -230,7 +242,7 @@ export function formatQuotaRows(params: { compactRounded: true, decimals: params.resetTimeDecimals, } - : { missing: "-" }, + : { missing: "-", spaced: params.resetTimeSpaced }, ); if (atomicValue) { diff --git a/src/lib/quota-command-format.ts b/src/lib/quota-command-format.ts index 68c46a9f..616ecf70 100644 --- a/src/lib/quota-command-format.ts +++ b/src/lib/quota-command-format.ts @@ -31,9 +31,9 @@ import { import { SESSION_TOKEN_SECTION_HEADING } from "./session-tokens-format.js"; import type { QuotaToastConfig } from "./types.js"; -function formatResetsIn(iso?: string): string { +function formatResetsIn(iso?: string, spaced?: boolean): string { if (!iso || !Number.isFinite(new Date(iso).getTime())) return ""; - return ` | resets in ${formatResetCountdown(iso)}`; + return ` | resets in ${formatResetCountdown(iso, { spaced })}`; } export const QUOTA_COMMAND_BAR_WIDTH = 10; @@ -88,9 +88,13 @@ function getCommandMetricLabel(entry: QuotaToastEntry, semanticLabel: string): s return explicit || (isValueEntry(entry) ? "Value" : "Quota"); } -function formatCommandDetails(entry: QuotaToastEntry, rightWidth: number): string { +function formatCommandDetails( + entry: QuotaToastEntry, + rightWidth: number, + spaced?: boolean, +): string { const right = entry.right?.trim(); - const reset = formatResetsIn(entry.resetTimeIso).replace(/^ \| resets in /u, "reset "); + const reset = formatResetsIn(entry.resetTimeIso, spaced).replace(/^ \| resets in /u, "reset "); if (right && reset) return ` | ${padRight(right, rightWidth)} | ${reset}`; if (right) return ` | ${right}`; if (reset) return ` | ${reset}`; @@ -114,7 +118,9 @@ function buildQuotaCommandDocument(params: { sessionTokens?: SessionTokensData; generatedAtMs?: number; percentDisplayMode?: QuotaToastConfig["percentDisplayMode"]; + percentLabelStyle?: QuotaToastConfig["percentLabelStyle"]; accountingDetail?: QuotaToastConfig["accountingDetail"]; + resetTimeSpaced?: boolean; }): ReportDocument { const groups = groupQuotaEntries(params.entries, "quota"); @@ -144,7 +150,7 @@ function buildQuotaCommandDocument(params: { ); for (const { entry: row, interpretation } of interpretedRows) { const label = padRight(getCommandMetricLabel(row, interpretation.label), labelWidth); - const details = formatCommandDetails(row, rightWidth); + const details = formatCommandDetails(row, rightWidth, params.resetTimeSpaced); if (interpretation.display.kind === "value") { lines.push(` ${label} ${interpretation.display.text}${details}`); @@ -154,6 +160,7 @@ function buildQuotaCommandDocument(params: { const pctLabel = formatDisplayedPercentLabel( interpretation.display.percentRemaining, params.percentDisplayMode, + params.percentLabelStyle, ); const displayedPercent = resolveDisplayedPercent( interpretation.display.percentRemaining, @@ -226,7 +233,9 @@ export function formatQuotaCommand(params: { sessionTokens?: SessionTokensData; generatedAtMs?: number; percentDisplayMode?: QuotaToastConfig["percentDisplayMode"]; + percentLabelStyle?: QuotaToastConfig["percentLabelStyle"]; accountingDetail?: QuotaToastConfig["accountingDetail"]; + resetTimeSpaced?: boolean; }): string { return renderPlainTextReport(buildQuotaCommandDocument(params)); } diff --git a/src/lib/quota-dialog-commands.ts b/src/lib/quota-dialog-commands.ts index ab2a7279..15612572 100644 --- a/src/lib/quota-dialog-commands.ts +++ b/src/lib/quota-dialog-commands.ts @@ -944,7 +944,9 @@ export async function buildQuotaDialogCommandOutput(params: { ...reportData.data, generatedAtMs, percentDisplayMode: runtime.config.percentDisplayMode, + percentLabelStyle: runtime.config.percentLabelStyle, accountingDetail: runtime.config.accountingDetail, + resetTimeSpaced: runtime.config.resetTimeSpaced, }), }); } diff --git a/src/lib/quota-toast-runtime.ts b/src/lib/quota-toast-runtime.ts index 916d9cee..a3550266 100644 --- a/src/lib/quota-toast-runtime.ts +++ b/src/lib/quota-toast-runtime.ts @@ -917,8 +917,10 @@ export async function collectQuotaToastMessage(params: { errors: data?.errors ?? [], style: resolveQuotaFormatStyle(runtimeConfig.formatStyle), percentDisplayMode: runtimeConfig.percentDisplayMode, + percentLabelStyle: runtimeConfig.percentLabelStyle, accountingDetail: runtimeConfig.accountingDetail, resetTimeDecimals: runtimeConfig.resetTimeDecimals, + resetTimeSpaced: runtimeConfig.resetTimeSpaced, sessionTokens: data?.sessionTokens, }); diff --git a/src/lib/toast-format-grouped.ts b/src/lib/toast-format-grouped.ts index 6421b8bf..74edf8d6 100644 --- a/src/lib/toast-format-grouped.ts +++ b/src/lib/toast-format-grouped.ts @@ -10,7 +10,7 @@ import type { QuotaToastEntry, QuotaToastError, SessionTokensData } from "./entr import { isPercentEntry } from "./entries.js"; import { bar, - DISPLAYED_PERCENT_LABEL_WIDTH, + displayedPercentLabelWidth, formatDisplayedPercentLabel, formatResetCountdown, isResetTimeDecimals, @@ -73,8 +73,10 @@ export function formatQuotaRowsGrouped(params: { entries?: QuotaToastEntry[]; errors?: QuotaToastError[]; percentDisplayMode?: QuotaToastConfig["percentDisplayMode"]; + percentLabelStyle?: QuotaToastConfig["percentLabelStyle"]; accountingDetail?: QuotaToastConfig["accountingDetail"]; resetTimeDecimals?: number; + resetTimeSpaced?: boolean; sessionTokens?: SessionTokensData; }): string { const layout = params.layout ?? { maxWidth: 50, narrowAt: 42, tinyAt: 32 }; @@ -84,12 +86,16 @@ export function formatQuotaRowsGrouped(params: { const separator = " "; const percentCol = Math.max( - DISPLAYED_PERCENT_LABEL_WIDTH, + displayedPercentLabelWidth(params.percentLabelStyle), ...(params.entries ?? []) .filter(isPercentEntry) .map( (entry) => - formatDisplayedPercentLabel(entry.percentRemaining, params.percentDisplayMode).length, + formatDisplayedPercentLabel( + entry.percentRemaining, + params.percentDisplayMode, + params.percentLabelStyle, + ).length, ), ); const percentValueCol = percentCol; @@ -142,7 +148,7 @@ export function formatQuotaRowsGrouped(params: { entry.resetTimeIso, isResetTimeDecimals(params.resetTimeDecimals) ? { compactRounded: true, decimals: params.resetTimeDecimals } - : undefined, + : { spaced: params.resetTimeSpaced }, ) : ""; const value = @@ -229,6 +235,7 @@ export function formatQuotaRowsGrouped(params: { const percentLabel = formatDisplayedPercentLabel( interpretation.display.percentRemaining, params.percentDisplayMode, + params.percentLabelStyle, ); // Percent entries @@ -240,7 +247,7 @@ export function formatQuotaRowsGrouped(params: { entry.resetTimeIso, isResetTimeDecimals(params.resetTimeDecimals) ? { compactRounded: true, decimals: params.resetTimeDecimals } - : undefined, + : { spaced: params.resetTimeSpaced }, ) : ""; diff --git a/src/lib/tui-panel-state.ts b/src/lib/tui-panel-state.ts index 0e5ee1c6..d9ca5457 100644 --- a/src/lib/tui-panel-state.ts +++ b/src/lib/tui-panel-state.ts @@ -12,6 +12,8 @@ export type SidebarPanelState = { lines: string[]; linesExpanded?: string[]; providerCount?: number; + /** Header mode indicator shown when percent labels are bare. */ + headerPercentMode?: PercentDisplayMode; }; export type CompactStatusState = @@ -40,6 +42,7 @@ export type PromptBarState = entry?: PromptBarEntry; percentDisplayMode?: PercentDisplayMode; resetTimeDecimals?: number; + resetTimeSpaced?: boolean; }; export function shouldRenderSidebarPanel(panel: SidebarPanelState): boolean { diff --git a/src/lib/tui-runtime.ts b/src/lib/tui-runtime.ts index 94ba53d0..211419d5 100644 --- a/src/lib/tui-runtime.ts +++ b/src/lib/tui-runtime.ts @@ -406,6 +406,9 @@ function buildSidebarPanelFromData(params: { lines, ...(providerCount > 0 ? { providerCount } : {}), ...(linesExpanded ? { linesExpanded } : {}), + ...(params.runtime.config.percentLabelStyle === "bare" + ? { headerPercentMode: params.runtime.config.percentDisplayMode } + : {}), }; } @@ -433,8 +436,7 @@ function buildSemanticPromptBarEntry( const value = isPercentEntry(entry) ? Number.isFinite(entry.percentRemaining) - ? (formatDisplayedPercentLabel(entry.percentRemaining, percentDisplayMode).split(" ")[0] ?? - "0%") + ? formatDisplayedPercentLabel(entry.percentRemaining, percentDisplayMode, "bare") : null : isQuantityEntry(entry) ? formatAccountingQuantity(entry.quantity) @@ -516,6 +518,7 @@ function buildPromptBarFromData(params: { ...(entry ? { entry } : {}), percentDisplayMode: params.runtime.config.percentDisplayMode, resetTimeDecimals: params.runtime.config.resetTimeDecimals, + resetTimeSpaced: params.runtime.config.resetTimeSpaced, }; } diff --git a/src/lib/tui-sidebar-format.ts b/src/lib/tui-sidebar-format.ts index c15a3357..6e24a3d7 100644 --- a/src/lib/tui-sidebar-format.ts +++ b/src/lib/tui-sidebar-format.ts @@ -12,7 +12,14 @@ export const TUI_SIDEBAR_LAYOUT = { export function buildSidebarQuotaPanelLines(params: { data: QuotaRenderData; - config: Pick & + config: Pick< + QuotaToastConfig, + | "formatStyle" + | "percentDisplayMode" + | "percentLabelStyle" + | "resetTimeDecimals" + | "resetTimeSpaced" + > & Partial>; }): string[] { const data = sanitizeQuotaRenderData(params.data); @@ -24,8 +31,10 @@ export function buildSidebarQuotaPanelLines(params: { errors: data.errors, style: params.config.formatStyle, percentDisplayMode: params.config.percentDisplayMode, + percentLabelStyle: params.config.percentLabelStyle, accountingDetail: params.config.accountingDetail, resetTimeDecimals: params.config.resetTimeDecimals, + resetTimeSpaced: params.config.resetTimeSpaced, sessionTokens: data.sessionTokens, }); return quotaBody ? quotaBody.split("\n") : []; diff --git a/src/lib/types.ts b/src/lib/types.ts index 95aba028..2046e85d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -22,6 +22,7 @@ export type GoogleAgyAuthSourceKey = "google-agy" | "opencode-agy-auth" | "googl export type CursorQuotaPlan = "none" | "pro" | "pro-plus" | "ultra"; export type PricingSnapshotSource = "auto" | "bundled" | "runtime"; export type PercentDisplayMode = "remaining" | "used"; +export type PercentLabelStyle = "full" | "bare"; export type AccountingDetail = "summary" | "detailed"; export type SessionTokenScope = "current" | "tree"; export type OpenCodeGoWindowKey = "rolling" | "weekly" | "monthly"; @@ -119,6 +120,17 @@ export interface QuotaToastConfig { * Unset preserves the default integer-day and half-hour-step display. */ resetTimeDecimals?: number; + /** + * Join compound exact-minute reset countdown units with spaces + * ("2d 5h 14m" instead of "2d5h14m") on human-facing surfaces. + */ + resetTimeSpaced?: boolean; + /** + * Percent label wording on human-facing surfaces: "full" appends the + * direction word ("81% left" / "19% used"); "bare" shows the plain percent + * ("81%") and lets the sidebar header carry the mode indicator. + */ + percentLabelStyle?: PercentLabelStyle; minIntervalMs: number; /** Request timeout in milliseconds for remote provider API calls. */ diff --git a/src/tui.tsx b/src/tui.tsx index aa1894b0..cbdabcb0 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -377,13 +377,18 @@ function SidebarContentView(props: { const toggleIcon = () => (collapsed() ? "▶" : "▼"); const providerCount = () => panel().providerCount ?? 0; + const headerText = () => { + const base = hasDetailLines() ? `${toggleIcon()} Quota` : "Quota"; + const mode = panel().headerPercentMode; + return mode ? `${base} [${mode === "used" ? "Used" : "Remaining"}]` : base; + }; return ( - {hasDetailLines() ? `${toggleIcon()} Quota` : "Quota"} + {headerText()} 0}> ({providerCount()} providers) @@ -524,7 +529,7 @@ function buildPromptBarParts(params: { entry.resetTimeIso, isResetTimeDecimals(bar.resetTimeDecimals) ? { compactRounded: true, decimals: bar.resetTimeDecimals } - : undefined, + : { spaced: bar.resetTimeSpaced }, ) : ""; @@ -541,6 +546,7 @@ function buildPromptBarParts(params: { const percent = formatDisplayedPercentLabel( entry.percentRemaining ?? 0, bar.percentDisplayMode ?? "remaining", + "bare", ); const p = Math.max(0, Math.min(100, Math.round(entry.percentRemaining ?? 0))); const filled = Math.round((p / 100) * PROMPT_BAR_WIDTH); @@ -559,9 +565,7 @@ function buildPromptBarParts(params: { return { label: windowLabel, barText, - meta: entry.semanticSegment - ? reset - : [percent.replace(/\s+left$/u, ""), reset].filter(Boolean).join(" | "), + meta: entry.semanticSegment ? reset : [percent, reset].filter(Boolean).join(" | "), }; } diff --git a/tests/format-utils.test.ts b/tests/format-utils.test.ts new file mode 100644 index 00000000..550a432b --- /dev/null +++ b/tests/format-utils.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + DISPLAYED_PERCENT_LABEL_WIDTH, + displayedPercentLabelWidth, + formatDisplayedPercentLabel, + formatResetCountdown, +} from "../src/lib/format-utils.js"; + +describe("formatResetCountdown", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the compact compound form by default", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + expect(formatResetCountdown("2026-01-17T15:14:00.000Z")).toBe("2d5h14m"); + }); + + it("joins compound units with spaces when spaced is set", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + expect(formatResetCountdown("2026-01-17T15:14:00.000Z", { spaced: true })).toBe("2d 5h 14m"); + }); + + it("spaces hours and minutes below one day", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + expect(formatResetCountdown("2026-01-15T13:45:00.000Z", { spaced: true })).toBe("3h 45m"); + }); + + it("keeps minute-only values as a single token", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + expect(formatResetCountdown("2026-01-15T10:14:00.000Z", { spaced: true })).toBe("14m"); + }); + + it("rounds partial minutes up in spaced mode", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + expect(formatResetCountdown("2026-01-15T12:14:01.000Z", { spaced: true })).toBe("2h 15m"); + }); + + it("keeps single-unit compactRounded output unchanged when spaced is set", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + expect( + formatResetCountdown("2026-01-17T15:14:00.000Z", { + compactRounded: true, + decimals: 1, + spaced: true, + }), + ).toBe("2.2d"); + }); + + it("returns the reset marker for past timestamps in spaced mode", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + expect(formatResetCountdown("2026-01-15T09:00:00.000Z", { spaced: true })).toBe("reset"); + }); +}); + +describe("formatDisplayedPercentLabel", () => { + it("keeps the word suffix by default", () => { + expect(formatDisplayedPercentLabel(81, "remaining")).toBe("81% left"); + expect(formatDisplayedPercentLabel(81, "used")).toBe("19% used"); + }); + + it("omits the word suffix in bare mode", () => { + expect(formatDisplayedPercentLabel(81, "remaining", "bare")).toBe("81%"); + expect(formatDisplayedPercentLabel(81, "used", "bare")).toBe("19%"); + }); +}); + +describe("displayedPercentLabelWidth", () => { + it("matches the full label width by default", () => { + expect(displayedPercentLabelWidth()).toBe(DISPLAYED_PERCENT_LABEL_WIDTH); + expect(displayedPercentLabelWidth("full")).toBe(DISPLAYED_PERCENT_LABEL_WIDTH); + }); + + it("shrinks to the bare percent width in bare mode", () => { + expect(displayedPercentLabelWidth("bare")).toBe("100%".length); + }); +}); diff --git a/tests/format.test.ts b/tests/format.test.ts index 87483ca0..dbe6ef35 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -282,6 +282,110 @@ describe("formatQuotaRows", () => { expect(out).not.toContain("0.5h"); }); + it("spaces compound reset countdowns when resetTimeSpaced is set (single-window)", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + const out = formatQuotaRows({ + version: "1.0.0", + layout: { maxWidth: 50, narrowAt: 42, tinyAt: 32 }, + resetTimeSpaced: true, + entries: [ + { + name: "[Copilot] Monthly", + percentRemaining: 56, + resetTimeIso: "2026-01-17T15:14:00.000Z", + }, + ], + }); + + expect(out).toContain("2d 5h 14m"); + expect(out).not.toContain("2d5h14m"); + }); + + it("spaces compound reset countdowns when resetTimeSpaced is set (grouped)", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + const out = formatQuotaRows({ + version: "1.0.0", + style: "allWindows", + layout: { maxWidth: 50, narrowAt: 42, tinyAt: 32 }, + resetTimeSpaced: true, + entries: [ + { + name: "OpenAI 5h", + group: "OpenAI", + label: "5h:", + percentRemaining: 56, + resetTimeIso: "2026-01-15T12:14:00.000Z", + }, + ], + }); + + expect(out).toContain("2h 14m"); + expect(out).not.toContain("2h14m"); + }); + + it("omits the percent word suffix and widens the bar when percentLabelStyle is bare", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + const entry = { + name: "Copilot", + percentRemaining: 56, + resetTimeIso: "2026-01-15T12:14:00.000Z", + }; + const base = { + version: "1.0.0", + layout: { maxWidth: 50, narrowAt: 42, tinyAt: 32 }, + entries: [entry], + }; + const full = formatQuotaRows(base); + const bare = formatQuotaRows({ ...base, percentLabelStyle: "bare" as const }); + + expect(full).toContain("56% left"); + expect(bare).toContain("56%"); + expect(bare).not.toContain("56% left"); + + const barCells = (out: string) => (out.split("\n")[1]?.match(/[█░]/gu) ?? []).length; + expect(barCells(bare)).toBe(barCells(full) + " left".length); + }); + + it("omits the percent word suffix and widens the bar in grouped bare mode", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + const entry = { + name: "OpenAI 5h", + group: "OpenAI", + label: "5h:", + percentRemaining: 56, + resetTimeIso: "2026-01-15T12:14:00.000Z", + }; + const base = { + version: "1.0.0", + style: "allWindows" as const, + layout: { maxWidth: 50, narrowAt: 42, tinyAt: 32 }, + entries: [entry], + }; + const full = formatQuotaRows(base); + const bare = formatQuotaRows({ ...base, percentLabelStyle: "bare" as const }); + + expect(full).toContain("56% left"); + expect(bare).toContain("56%"); + expect(bare).not.toContain("56% left"); + + const barCells = (out: string) => + ( + out + .split("\n") + .find((line) => line.includes("█")) + ?.match(/[█░]/gu) ?? [] + ).length; + expect(barCells(bare)).toBe(barCells(full) + " left".length); + }); + it("uses minutes instead of textual zero for configured sub-hour boundaries", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); diff --git a/tests/lib.config.test.ts b/tests/lib.config.test.ts index 99e34be6..e4fd098a 100644 --- a/tests/lib.config.test.ts +++ b/tests/lib.config.test.ts @@ -882,6 +882,46 @@ describe("loadConfig", () => { } }); + it("defaults resetTimeSpaced to unset and accepts boolean overrides", async () => { + const defaults = await loadSdkConfig({}); + expect(defaults.config.resetTimeSpaced).toBeUndefined(); + + const explicit = await loadSdkConfig({ resetTimeSpaced: true }); + expect(explicit.config.resetTimeSpaced).toBe(true); + expect(explicit.meta.settingSources).toEqual({ + resetTimeSpaced: "client.config.get", + }); + + const disabled = await loadSdkConfig({ resetTimeSpaced: false }); + expect(disabled.config.resetTimeSpaced).toBe(false); + + for (const invalid of ["true", 1, null]) { + const rejected = await loadSdkConfig({ resetTimeSpaced: invalid }); + expect(rejected.config.resetTimeSpaced).toBeUndefined(); + expect(rejected.meta.settingSources).not.toHaveProperty("resetTimeSpaced"); + } + }); + + it("defaults percentLabelStyle to unset and accepts full or bare overrides", async () => { + const defaults = await loadSdkConfig({}); + expect(defaults.config.percentLabelStyle).toBeUndefined(); + + const explicit = await loadSdkConfig({ percentLabelStyle: "bare" }); + expect(explicit.config.percentLabelStyle).toBe("bare"); + expect(explicit.meta.settingSources).toEqual({ + percentLabelStyle: "client.config.get", + }); + + const full = await loadSdkConfig({ percentLabelStyle: "full" }); + expect(full.config.percentLabelStyle).toBe("full"); + + for (const invalid of ["minimal", 1, null, true]) { + const rejected = await loadSdkConfig({ percentLabelStyle: invalid }); + expect(rejected.config.percentLabelStyle).toBeUndefined(); + expect(rejected.meta.settingSources).not.toHaveProperty("percentLabelStyle"); + } + }); + it("defaults anthropicBinaryPath and trims explicit overrides", async () => { const defaults = await loadSdkConfig({}); expect(defaults.config.anthropicBinaryPath).toBe("claude"); diff --git a/tests/quota-command-format.test.ts b/tests/quota-command-format.test.ts index 60fc4e37..a28ea315 100644 --- a/tests/quota-command-format.test.ts +++ b/tests/quota-command-format.test.ts @@ -454,4 +454,30 @@ describe("formatQuotaCommand", () => { expect(metric).not.toContain("```"); expect(Array.from(metric).length).toBeLessThanOrEqual(76); }); + + it("spaces reset countdowns and omits the percent suffix when configured", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z")); + + const out = formatQuotaCommand({ + entries: [ + { + accounting: accounting("quota"), + name: "OpenAI (Pro) Weekly", + group: "OpenAI (Pro)", + label: "Weekly:", + percentRemaining: 81, + resetTimeIso: "2026-01-18T12:14:00.000Z", + }, + ], + errors: [], + percentLabelStyle: "bare", + resetTimeSpaced: true, + }); + + expect(out).toContain("reset 3d 0h 14m"); + expect(out).not.toContain("3d0h14m"); + expect(out).not.toContain("81% left"); + expect(out).toMatch(/ {2}81%(?:\s*\|)/u); + }); }); diff --git a/tests/tui-runtime.test.ts b/tests/tui-runtime.test.ts index 33354bbd..3ba604de 100644 --- a/tests/tui-runtime.test.ts +++ b/tests/tui-runtime.test.ts @@ -1657,6 +1657,60 @@ describe("tui runtime helpers", () => { }); }); + it("forwards spaced reset and bare percent label settings to session surfaces", async () => { + writeFileSync( + join(worktreeDir, "opencode.json"), + JSON.stringify({ + experimental: { + quotaToast: { + enabled: true, + percentLabelStyle: "bare", + resetTimeSpaced: true, + tuiPromptBar: { enabled: true }, + }, + }, + }), + "utf8", + ); + + const data = { + entries: [{ name: "Copilot 5h", percentRemaining: 18 }], + errors: [], + sessionTokens: undefined, + }; + collectQuotaRenderData.mockResolvedValue({ active: [], data }); + buildSidebarQuotaPanelLines.mockReturnValue(["Sidebar quota"]); + + const surfaces = await loadTuiSessionQuotaSurfaces({ + api: { + state: { + provider: [], + path: { worktree: worktreeDir, directory: nestedDir }, + session: { messages: () => [] }, + }, + client: {}, + } as any, + sessionID: "spaced-bare-session", + }); + + expect(surfaces.sidebar).toEqual({ + status: "ready", + lines: ["Sidebar quota"], + headerPercentMode: "remaining", + }); + expect(surfaces.promptBar).toMatchObject({ + status: "ready", + resetTimeSpaced: true, + }); + expect(buildSidebarQuotaPanelLines).toHaveBeenCalledWith({ + data, + config: expect.objectContaining({ + percentLabelStyle: "bare", + resetTimeSpaced: true, + }), + }); + }); + it("uses compact fallback text when session collection has no data", async () => { writeFileSync( join(worktreeDir, "opencode.json"), diff --git a/tests/tui-sidebar-format.test.ts b/tests/tui-sidebar-format.test.ts index fcc5f3f0..b51efdfc 100644 --- a/tests/tui-sidebar-format.test.ts +++ b/tests/tui-sidebar-format.test.ts @@ -631,4 +631,75 @@ describe("buildSidebarQuotaPanelLines", () => { expect(used).toEqual(remaining); expect(used.join("\n")).toContain("$2.40 / $20.00"); }); + + it("spaces compound reset countdowns when resetTimeSpaced is set", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + const data = { + entries: [ + { + name: "[Copilot] Monthly", + group: "Copilot", + label: "Monthly:", + percentRemaining: 81, + resetTimeIso: "2026-01-17T15:14:00.000Z", + }, + ], + errors: [], + sessionTokens: undefined, + }; + + for (const formatStyle of ["singleWindow", "allWindows"] as const) { + const lines = buildSidebarQuotaPanelLines({ + config: { + formatStyle, + percentDisplayMode: "remaining", + resetTimeSpaced: true, + }, + data, + }); + + expect(lines.join("\n")).toContain("2d 5h 14m"); + expect(lines.join("\n")).not.toContain("2d5h14m"); + expect(lines.every((line) => line.length <= TUI_SIDEBAR_MAX_WIDTH)).toBe(true); + } + }); + + it("omits the percent word suffix and widens the bar when percentLabelStyle is bare", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z")); + + const data = { + entries: [ + { + name: "Copilot", + percentRemaining: 81, + resetTimeIso: "2026-01-15T12:14:00.000Z", + }, + ], + errors: [], + sessionTokens: undefined, + }; + + for (const formatStyle of ["singleWindow", "allWindows"] as const) { + const full = buildSidebarQuotaPanelLines({ + config: { formatStyle, percentDisplayMode: "remaining" }, + data, + }); + const bare = buildSidebarQuotaPanelLines({ + config: { formatStyle, percentDisplayMode: "remaining", percentLabelStyle: "bare" }, + data, + }); + + expect(full.join("\n")).toContain("81% left"); + expect(bare.join("\n")).toContain("81%"); + expect(bare.join("\n")).not.toContain("81% left"); + + const barCells = (lines: string[]) => + (lines.find((line) => line.includes("░"))?.match(/[█░]/gu) ?? []).length; + expect(barCells(bare)).toBe(barCells(full) + " left".length); + expect(bare.every((line) => line.length <= TUI_SIDEBAR_MAX_WIDTH)).toBe(true); + } + }); }); diff --git a/tests/tui-smoke.test.ts b/tests/tui-smoke.test.ts index f0e4728d..0b948a6f 100644 --- a/tests/tui-smoke.test.ts +++ b/tests/tui-smoke.test.ts @@ -1160,6 +1160,60 @@ describe("tui plugin smoke", () => { ).toEqual(["[OpenCode Go]", "Five-hour window 98%", "Weekly window 53%", "Monthly window 33%"]); }); + it("renders the percent mode indicator in the sidebar header when set", async () => { + const plugin = await loadTuiModule(); + const { api, registered } = createApi(); + + loadTuiSessionQuotaSurfaces.mockResolvedValueOnce({ + sidebar: { + status: "ready", + lines: ["OpenCode Go Five-hour 98%"], + linesExpanded: ["[OpenCode Go]", "Five-hour window 98%"], + headerPercentMode: "used", + }, + compact: { status: "disabled" }, + }); + resolveTuiSurfaceRegistration.mockResolvedValueOnce({ + commandDisplay: "inline", + sidebar: { enabled: true }, + compact: { + enabled: false, + homeBottom: false, + sessionPrompt: false, + hasNativeProviderQuota: false, + suppressedByNativeProviderQuota: false, + }, + promptBar: { enabled: true }, + + announcements: { homeBottom: false }, + homeBottom: false, + }); + + await startTui(plugin, api); + + const sidebarRegistration = registered.find((registration) => registration.order === 150); + sidebarRegistration!.slots.sidebar_content({}, { session_id: "session-1" }); + await Promise.resolve(); + + const collapsed = sidebarRegistration!.slots.sidebar_content( + {}, + { session_id: "session-1" }, + ) as any; + expect(collapsed.props.children[0].props.children[0].props.children.props.children).toBe( + "▶ Quota [Used]", + ); + + collapsed.props.children[0].props.children[0].props.onMouseDown(); + + const expanded = sidebarRegistration!.slots.sidebar_content( + {}, + { session_id: "session-1" }, + ) as any; + expect(expanded.props.children[0].props.children[0].props.children.props.children).toBe( + "▼ Quota [Used]", + ); + }); + it("keeps non-expandable empty sidebar panels visible while collapsed", async () => { const plugin = await loadTuiModule(); const { api, registered } = createApi();