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 f5e3687c..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
@@ -350,7 +370,9 @@ 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`. |
+| `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 e993622a..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.
*
@@ -142,10 +152,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;
/**
@@ -153,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;
@@ -161,7 +174,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 +184,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 +206,10 @@ export function formatResetCountdown(iso?: string, opts?: FormatResetCountdownOp
return `0.5h`;
}
- if (days > 0) return `${days}d ${hours}h`;
- 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`;
}
export const MAX_RESET_TIME_DECIMALS = 4;
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 0ef11921..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;
@@ -166,18 +178,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: "-", spaced: params.resetTimeSpaced },
+ )
: "";
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 +234,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: "-", spaced: params.resetTimeSpaced },
+ );
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 +279,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..616ecf70 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)}`;
+function formatResetsIn(iso?: string, spaced?: boolean): string {
+ if (!iso || !Number.isFinite(new Date(iso).getTime())) return "";
+ return ` | resets in ${formatResetCountdown(iso, { spaced })}`;
}
export const QUOTA_COMMAND_BAR_WIDTH = 10;
@@ -102,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}`;
@@ -128,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");
@@ -158,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}`);
@@ -168,6 +160,7 @@ function buildQuotaCommandDocument(params: {
const pctLabel = formatDisplayedPercentLabel(
interpretation.display.percentRemaining,
params.percentDisplayMode,
+ params.percentLabelStyle,
);
const displayedPercent = resolveDisplayedPercent(
interpretation.display.percentRemaining,
@@ -240,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 0c7e0260..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;
@@ -138,15 +144,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 }
+ : { spaced: params.resetTimeSpaced },
+ )
: "";
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 +187,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 +210,6 @@ export function formatQuotaRowsGrouped(params: {
1,
barWidth - separator.length - valueWidth - separator.length - timeWidth,
);
- const leftText = right ? `${label} ${right}` : label;
lines.push(
(
padRight(leftText, leftMax) +
@@ -219,6 +235,7 @@ export function formatQuotaRowsGrouped(params: {
const percentLabel = formatDisplayedPercentLabel(
interpretation.display.percentRemaining,
params.percentDisplayMode,
+ params.percentLabelStyle,
);
// Percent entries
@@ -226,17 +243,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 }
+ : { spaced: params.resetTimeSpaced },
+ )
: "";
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/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 e1f93ec6..cbdabcb0 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,
@@ -373,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)
@@ -516,10 +525,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 }
+ : { spaced: bar.resetTimeSpaced },
+ )
: "";
const hasPercent = Number.isFinite(entry.percentRemaining);
@@ -535,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);
@@ -553,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 b3daa6a6..dbe6ef35 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)", () => {
@@ -262,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"));
@@ -344,7 +468,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 +484,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/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/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..a28ea315 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", () => {
@@ -455,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 5a67ce5c..b51efdfc 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", () => {
@@ -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();