diff --git a/README.md b/README.md
index 98d9b6f8..09e1f7c5 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 or spacing, bare 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 2bff205b..8d5222c5 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"` |
+| Show percentages without `left` or `used` | `percentLabelStyle: "bare"` |
+| Add spaces between reset countdown units | `resetTimeSpaced: true` |
| Show supplementary accounting facts | `accountingDetail: "detailed"` |
| Show slash results with messages | `tuiCommandDisplay: "inline"` |
| Show slash results in a TUI popup | `tuiCommandDisplay: "dialog"` |
@@ -48,6 +50,8 @@ The installer chooses `allWindows` by default. If the setting is absent, the bui
// Show every quota reset period as percentage remaining.
"formatStyle": "allWindows",
"percentDisplayMode": "remaining",
+ "percentLabelStyle": "bare",
+ "resetTimeSpaced": true,
"accountingDetail": "summary",
// Keep TUI slash-command results with normal messages.
@@ -274,6 +278,26 @@ Leave it unset to use the default exact-to-minute display.
+
+Space reset units and shorten percent labels
+
+Set `resetTimeSpaced` to `true` to add spaces between exact compound countdown units. For example, `2d5h14m` becomes `2d 5h 14m`, and `3h45m` becomes `3h 45m`. Minute-only values such as `14m`, expired values shown as `reset`, and partial-minute rounding stay unchanged. This setting applies to `/quota` in Web, Desktop, and the TUI, popup toasts, terminal `show`, the expanded and collapsed Sidebar, Compact status, and the prompt bar.
+
+`resetTimeDecimals` keeps its existing largest-unit decimal format and takes precedence over spacing on the displays where decimal countdowns apply.
+
+Set `percentLabelStyle` to `"bare"` to show `81%` instead of `81% left`, or `19%` instead of `19% used`. Full reports identify the mode as `Quota [Remaining]` or `Quota [Used]`. The Sidebar uses the same heading, keeps its collapse icon, and gives the freed columns to its bars. Compact status and prompt percentages remain bare.
+
+```jsonc
+{
+ "resetTimeSpaced": true,
+ "percentLabelStyle": "bare",
+}
+```
+
+Both settings are optional. Leave them unset to keep the existing compact countdowns and full percent labels.
+
+
+
Change maintainer notices
@@ -349,8 +373,10 @@ Existing `experimental.quotaToast` settings remain supported. Quota settings do
| `requestTimeoutMs` | `5000` | Remote provider request timeout in milliseconds. |
| `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. |
+| `percentLabelStyle` | unset | Set to `bare` to remove `left` or `used` from full-report percentage labels. Full reports and the Sidebar name the direction in a `Quota [Remaining]` or `Quota [Used]` heading. `full` is also accepted. Unset keeps full labels. |
| `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 a largest-unit reset countdown override in popup toasts, the Sidebar panel, terminal `show`, and the prompt bar. Accepts integers `0`–`4`; when unset, the default shows exact remaining days, hours, and minutes as `DdHhMm`. |
+| `resetTimeSpaced` | unset | Set to `true` to space exact compound countdowns such as `2d 5h 14m` on `/quota`, popup toasts, terminal `show`, the Sidebar, Compact status, and the prompt bar. `resetTimeDecimals` keeps its legacy decimal format where it applies. Unset or `false` keeps compact spelling. |
| `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..607e0dfd 100644
--- a/src/lib/cli-show.ts
+++ b/src/lib/cli-show.ts
@@ -3,6 +3,7 @@ import { hasAnthropicCredentialsConfigured } from "./anthropic.js";
import { findGitWorktreeRoot, getEffectiveConfigRoot } from "./config-file-utils.js";
import { sanitizeQuotaRenderData } from "./display-sanitize.js";
import { formatQuotaRows } from "./format.js";
+import { formatQuotaModeHeading } from "./format-utils.js";
import { DEFAULT_KIMI_AUTH_CACHE_MAX_AGE_MS, resolveKimiAuthCached } from "./kimi-auth.js";
import {
loadConfiguredOpenCodeConfig,
@@ -359,8 +360,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()) {
@@ -368,7 +371,12 @@ export async function runCliShowCommand(options: RunCliShowCommandOptions = {}):
return 1;
}
- writeLine(stdout, output);
+ writeLine(
+ stdout,
+ config.percentLabelStyle === "bare"
+ ? `${formatQuotaModeHeading(config.percentDisplayMode)}\n\n${output}`
+ : output,
+ );
return data.entries.length > 0 ? 0 : 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diff --git a/src/lib/config.ts b/src/lib/config.ts
index ed5cd3af..eda04a10 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -23,6 +23,7 @@ import type {
CursorQuotaPlan,
GoogleModelId,
PercentDisplayMode,
+ PercentLabelStyle,
PricingSnapshotSource,
QuotaResetWindow,
QuotaToastConfig,
@@ -45,8 +46,10 @@ export const QUOTA_TOAST_SETTING_SOURCE_KEYS = [
"tuiCommandDisplay",
"formatStyle",
"percentDisplayMode",
+ "percentLabelStyle",
"accountingDetail",
"resetTimeDecimals",
+ "resetTimeSpaced",
"minIntervalMs",
"requestTimeoutMs",
"debug",
@@ -157,8 +160,10 @@ type ValidatedQuotaToastPatch = {
tuiCommandDisplay?: TuiCommandDisplay;
formatStyle?: QuotaToastConfig["formatStyle"];
percentDisplayMode?: PercentDisplayMode;
+ percentLabelStyle?: PercentLabelStyle;
accountingDetail?: QuotaToastConfig["accountingDetail"];
resetTimeDecimals?: number;
+ resetTimeSpaced?: boolean;
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";
}
@@ -677,6 +686,13 @@ function extractValidatedQuotaToastPatch(
patch.percentDisplayMode = quotaToastConfig.percentDisplayMode;
}
+ if (
+ hasOwnKey(quotaToastConfig, "percentLabelStyle") &&
+ isValidPercentLabelStyle(quotaToastConfig.percentLabelStyle)
+ ) {
+ patch.percentLabelStyle = quotaToastConfig.percentLabelStyle;
+ }
+
if (hasOwnKey(quotaToastConfig, "accountingDetail")) {
if (isValidAccountingDetail(quotaToastConfig.accountingDetail)) {
patch.accountingDetail = quotaToastConfig.accountingDetail;
@@ -692,6 +708,13 @@ function extractValidatedQuotaToastPatch(
patch.resetTimeDecimals = quotaToastConfig.resetTimeDecimals;
}
+ if (
+ hasOwnKey(quotaToastConfig, "resetTimeSpaced") &&
+ typeof quotaToastConfig.resetTimeSpaced === "boolean"
+ ) {
+ patch.resetTimeSpaced = quotaToastConfig.resetTimeSpaced;
+ }
+
if (
hasOwnKey(quotaToastConfig, "minIntervalMs") &&
isPositiveNumber(quotaToastConfig.minIntervalMs)
@@ -947,6 +970,11 @@ function applyValidatedQuotaToastPatch(
applySettingSource(settingSources, "percentDisplayMode", sourcePath);
}
+ if (hasOwnKey(patch, "percentLabelStyle")) {
+ config.percentLabelStyle = patch.percentLabelStyle;
+ applySettingSource(settingSources, "percentLabelStyle", sourcePath);
+ }
+
if (hasOwnKey(patch, "accountingDetail")) {
config.accountingDetail = patch.accountingDetail!;
applySettingSource(settingSources, "accountingDetail", sourcePath);
@@ -957,6 +985,11 @@ function applyValidatedQuotaToastPatch(
applySettingSource(settingSources, "resetTimeDecimals", sourcePath);
}
+ if (hasOwnKey(patch, "resetTimeSpaced")) {
+ config.resetTimeSpaced = patch.resetTimeSpaced;
+ applySettingSource(settingSources, "resetTimeSpaced", 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 b5e1a225..9c18eccc 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",
+ style: PercentLabelStyle = "full",
): string {
const displayedPercent = resolveDisplayedPercent(percentRemaining, mode);
- return `${displayedPercent}% ${mode === "used" ? "used" : "left"}`;
+ const percent = `${displayedPercent}%`;
+ return style === "bare" ? percent : `${percent} ${mode === "used" ? "used" : "left"}`;
}
export const DISPLAYED_PERCENT_LABEL_WIDTH = "100% used".length;
+export function displayedPercentLabelWidth(style: PercentLabelStyle = "full"): number {
+ return style === "bare" ? "100%".length : DISPLAYED_PERCENT_LABEL_WIDTH;
+}
+
+export function formatQuotaModeHeading(mode: PercentDisplayMode = "remaining"): string {
+ return `Quota [${mode === "used" ? "Used" : "Remaining"}]`;
+}
+
/**
* Format a token count with K/M suffix for compactness.
*
@@ -151,6 +161,8 @@ export interface FormatResetCountdownOptions {
* many decimal places.
*/
decimals?: number;
+ /** Join exact compound countdown units with spaces. */
+ spaced?: boolean;
}
const MS_PER_DAY = 86_400_000;
@@ -191,8 +203,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 separator = opts?.spaced ? " " : "";
+ if (days > 0) return [`${days}d`, `${hours}h`, `${minutes}m`].join(separator);
+ if (hours > 0) return [`${hours}h`, `${minutes}m`].join(separator);
return `${minutes}m`;
}
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 9b098dd1..cfa72fb8 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 },
)
: "";
@@ -239,7 +251,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 8d7bfa8b..29f54da5 100644
--- a/src/lib/quota-command-format.ts
+++ b/src/lib/quota-command-format.ts
@@ -14,6 +14,7 @@ import {
bar,
formatDisplayedPercentLabel,
formatLocalCallTimestamp,
+ formatQuotaModeHeading,
formatResetCountdown,
formatTokenCount,
padLeft,
@@ -31,9 +32,9 @@ import {
import { SESSION_TOKEN_SECTION_HEADING } from "./session-tokens-format.js";
import type { QuotaToastConfig } from "./types.js";
-function formatCommandReset(iso?: string): string {
+function formatCommandReset(iso?: string, spaced?: boolean): string {
if (!iso || !Number.isFinite(new Date(iso).getTime())) return "";
- const countdown = formatResetCountdown(iso);
+ const countdown = formatResetCountdown(iso, { spaced });
return countdown === "reset" ? countdown : `reset ${countdown}`;
}
@@ -89,9 +90,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,
+ resetTimeSpaced?: boolean,
+): string {
const right = entry.right?.trim();
- const reset = formatCommandReset(entry.resetTimeIso);
+ const reset = formatCommandReset(entry.resetTimeIso, resetTimeSpaced);
if (right && reset) return ` | ${padRight(right, rightWidth)} | ${reset}`;
if (right) return ` | ${right}`;
if (reset) return ` | ${reset}`;
@@ -115,7 +120,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");
@@ -145,7 +152,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}`);
@@ -155,6 +162,7 @@ function buildQuotaCommandDocument(params: {
const pctLabel = formatDisplayedPercentLabel(
interpretation.display.percentRemaining,
params.percentDisplayMode,
+ params.percentLabelStyle,
);
const displayedPercent = resolveDisplayedPercent(
interpretation.display.percentRemaining,
@@ -212,7 +220,13 @@ function buildQuotaCommandDocument(params: {
blocks: [
{
kind: "lines",
- lines: [`Quota (/quota) ${formatLocalCallTimestamp(params.generatedAtMs)}`],
+ lines: [
+ `${
+ params.percentLabelStyle === "bare"
+ ? formatQuotaModeHeading(params.percentDisplayMode)
+ : "Quota"
+ } (/quota) ${formatLocalCallTimestamp(params.generatedAtMs)}`,
+ ],
},
],
},
@@ -227,7 +241,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..bcb91636 100644
--- a/src/lib/quota-toast-runtime.ts
+++ b/src/lib/quota-toast-runtime.ts
@@ -10,6 +10,7 @@ import type { RuntimeContextRootHints } from "./config-file-utils.js";
import { isCursorModelId, isCursorProviderId } from "./cursor-pricing.js";
import { sanitizeDisplayText } from "./display-sanitize.js";
import { formatQuotaRows } from "./format.js";
+import { formatQuotaModeHeading } from "./format-utils.js";
import {
BUNDLED_MAINTAINER_ANNOUNCEMENTS,
formatMaintainerAnnouncementHomeCountLine,
@@ -249,7 +250,9 @@ export function createQuotaToastRuntime(
config.onlyCurrentModel && params.sessionID ? (params.sessionMeta?.providerID ?? "") : "";
const renderIdentity = JSON.stringify({
accountingDetail: config.accountingDetail,
+ percentLabelStyle: config.percentLabelStyle,
resetTimeDecimals: config.resetTimeDecimals,
+ resetTimeSpaced: config.resetTimeSpaced,
sessionTokenScope: config.sessionTokenScope,
opencodeGoWindows: config.opencodeGoWindows,
opencodeMonthlyLimit: config.opencodeMonthlyLimit,
@@ -625,6 +628,9 @@ export function createQuotaToastRuntime(
try {
await dependencies.showToast({
+ ...(runtimeConfig.percentLabelStyle === "bare"
+ ? { title: formatQuotaModeHeading(runtimeConfig.percentDisplayMode) }
+ : {}),
message: sanitizeDisplayText(message),
variant: "info",
duration: runtimeConfig.toastDurationMs,
@@ -917,8 +923,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-compact-format.ts b/src/lib/tui-compact-format.ts
index a392d1b6..f20cdc29 100644
--- a/src/lib/tui-compact-format.ts
+++ b/src/lib/tui-compact-format.ts
@@ -92,10 +92,11 @@ function getWindowLabel(entry: QuotaToastEntry): { text: string; isWindow: boole
function formatCompactValueEntrySegment(
entry: Extract,
+ resetTimeSpaced?: boolean,
): string | null {
const name = getProviderName(entry);
const value = compactText(entry.value);
- const reset = formatResetCountdown(entry.resetTimeIso);
+ const reset = formatResetCountdown(entry.resetTimeIso, { spaced: resetTimeSpaced });
const segment = [name, value, reset].filter(Boolean).join(" - ");
return segment || null;
}
@@ -135,6 +136,7 @@ function buildSemanticCandidate(
entry: QuotaToastEntry,
percentDisplayMode: QuotaToastConfig["percentDisplayMode"],
accountingDetail: QuotaToastConfig["accountingDetail"],
+ resetTimeSpaced?: boolean,
): CompactCandidate | null {
if (!entry.semantic) return null;
const shouldRequestBasis =
@@ -159,7 +161,9 @@ function buildSemanticCandidate(
const label = compactText(interpretation.label);
const prefix = compactText([provider, label].filter(Boolean).join(": "));
const displayValue = compactText(
- [value, formatResetCountdown(entry.resetTimeIso)].filter(Boolean).join(" "),
+ [value, formatResetCountdown(entry.resetTimeIso, { spaced: resetTimeSpaced })]
+ .filter(Boolean)
+ .join(" "),
);
const segment = compactText([prefix, displayValue].filter(Boolean).join(" "));
if (!segment) return null;
@@ -183,6 +187,7 @@ function formatCompactEntryCandidates(params: {
entries: QuotaRenderData["entries"];
percentDisplayMode: QuotaToastConfig["percentDisplayMode"];
accountingDetail: QuotaToastConfig["accountingDetail"];
+ resetTimeSpaced?: boolean;
}): CompactCandidate[] {
const semantic: CompactCandidate[] = [];
const groups = new Map();
@@ -194,13 +199,14 @@ function formatCompactEntryCandidates(params: {
entry,
params.percentDisplayMode,
params.accountingDetail,
+ params.resetTimeSpaced,
);
if (candidate) semantic.push(candidate);
continue;
}
if (isValueEntry(entry)) {
- const segment = formatCompactValueEntrySegment(entry);
+ const segment = formatCompactValueEntrySegment(entry, params.resetTimeSpaced);
if (segment) pendingLegacy.push({ kind: "value", segment });
continue;
}
@@ -210,7 +216,7 @@ function formatCompactEntryCandidates(params: {
const value = compactText(
[
formatCompactPercentLabel(entry.percentRemaining, params.percentDisplayMode),
- formatResetCountdown(entry.resetTimeIso),
+ formatResetCountdown(entry.resetTimeIso, { spaced: params.resetTimeSpaced }),
]
.filter(Boolean)
.join(" "),
@@ -358,6 +364,7 @@ export function buildCompactQuotaStatusLine(params: {
data: QuotaRenderData;
percentDisplayMode?: QuotaToastConfig["percentDisplayMode"];
accountingDetail?: QuotaToastConfig["accountingDetail"];
+ resetTimeSpaced?: boolean;
maxWidth: number;
}): string {
const maxWidth = normalizeMaxWidth(params.maxWidth);
@@ -370,6 +377,7 @@ export function buildCompactQuotaStatusLine(params: {
entries: data.entries,
percentDisplayMode,
accountingDetail,
+ resetTimeSpaced: params.resetTimeSpaced,
});
const sessionTokensSegment = formatCompactSessionTokensSegment(data);
if (sessionTokensSegment) {
diff --git a/src/lib/tui-panel-state.ts b/src/lib/tui-panel-state.ts
index 0e5ee1c6..51c1e937 100644
--- a/src/lib/tui-panel-state.ts
+++ b/src/lib/tui-panel-state.ts
@@ -12,6 +12,7 @@ export type SidebarPanelState = {
lines: string[];
linesExpanded?: string[];
providerCount?: number;
+ headerPercentMode?: PercentDisplayMode;
};
export type CompactStatusState =
@@ -40,6 +41,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..b1c1f892 100644
--- a/src/lib/tui-runtime.ts
+++ b/src/lib/tui-runtime.ts
@@ -314,6 +314,9 @@ function buildCompactStatusFromData(params: {
data,
percentDisplayMode: params.runtime.config.percentDisplayMode,
accountingDetail: params.runtime.config.accountingDetail,
+ ...(params.runtime.config.resetTimeSpaced !== undefined
+ ? { resetTimeSpaced: params.runtime.config.resetTimeSpaced }
+ : {}),
maxWidth: params.maxWidth ?? params.runtime.config.tuiCompactStatus.maxWidth,
})
: "";
@@ -379,6 +382,9 @@ function buildSidebarPanelFromData(params: {
data: primaryData,
percentDisplayMode: params.runtime.config.percentDisplayMode,
accountingDetail: params.runtime.config.accountingDetail,
+ ...(params.runtime.config.resetTimeSpaced !== undefined
+ ? { resetTimeSpaced: params.runtime.config.resetTimeSpaced }
+ : {}),
maxWidth: TUI_SIDEBAR_MAX_WIDTH,
}),
].filter((line): line is string => Boolean(line))
@@ -406,6 +412,9 @@ function buildSidebarPanelFromData(params: {
lines,
...(providerCount > 0 ? { providerCount } : {}),
...(linesExpanded ? { linesExpanded } : {}),
+ ...(params.runtime.config.percentLabelStyle === "bare"
+ ? { headerPercentMode: params.runtime.config.percentDisplayMode }
+ : {}),
};
}
@@ -516,6 +525,9 @@ function buildPromptBarFromData(params: {
...(entry ? { entry } : {}),
percentDisplayMode: params.runtime.config.percentDisplayMode,
resetTimeDecimals: params.runtime.config.resetTimeDecimals,
+ ...(params.runtime.config.resetTimeSpaced !== undefined
+ ? { resetTimeSpaced: params.runtime.config.resetTimeSpaced }
+ : {}),
};
}
diff --git a/src/lib/tui-sidebar-format.ts b/src/lib/tui-sidebar-format.ts
index c15a3357..e287b7e2 100644
--- a/src/lib/tui-sidebar-format.ts
+++ b/src/lib/tui-sidebar-format.ts
@@ -13,7 +13,7 @@ export const TUI_SIDEBAR_LAYOUT = {
export function buildSidebarQuotaPanelLines(params: {
data: QuotaRenderData;
config: Pick &
- Partial>;
+ Partial>;
}): string[] {
const data = sanitizeQuotaRenderData(params.data);
@@ -24,8 +24,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 6f01be1c..71b3b12b 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";
@@ -112,6 +113,8 @@ export interface QuotaToastConfig {
formatStyle: QuotaFormatStyle;
/** Shared percent meaning for popup toasts and the TUI sidebar. */
percentDisplayMode: PercentDisplayMode;
+ /** Optional suffix style for percentage labels. Unset preserves full labels. */
+ percentLabelStyle?: PercentLabelStyle;
/** Whether human surfaces include supplementary semantic accounting rows. */
accountingDetail: AccountingDetail;
/**
@@ -119,6 +122,8 @@ export interface QuotaToastConfig {
* Unset uses the default exact-to-minute DdHhMm display.
*/
resetTimeDecimals?: number;
+ /** Whether exact multi-unit reset countdowns include spaces between units. */
+ resetTimeSpaced?: boolean;
minIntervalMs: number;
/** Request timeout in milliseconds for remote provider API calls. */
diff --git a/src/tui.tsx b/src/tui.tsx
index aa1894b0..c1c12c88 100644
--- a/src/tui.tsx
+++ b/src/tui.tsx
@@ -10,8 +10,10 @@ import type { JSX } from "@opentui/solid";
import { createEffect, createSignal, onCleanup, Show } from "solid-js";
import {
formatDisplayedPercentLabel,
+ formatQuotaModeHeading,
formatResetCountdown,
isResetTimeDecimals,
+ resolveDisplayedPercent,
} from "./lib/format-utils.js";
import {
buildQuotaDialogCommandOutput,
@@ -377,13 +379,19 @@ function SidebarContentView(props: {
const toggleIcon = () => (collapsed() ? "▶" : "▼");
const providerCount = () => panel().providerCount ?? 0;
+ const headerText = () => {
+ const heading = panel().headerPercentMode
+ ? formatQuotaModeHeading(panel().headerPercentMode)
+ : "Quota";
+ return hasDetailLines() ? `${toggleIcon()} ${heading}` : heading;
+ };
return (
- {hasDetailLines() ? `${toggleIcon()} Quota` : "Quota"}
+ {headerText()}
0}>
({providerCount()} providers)
@@ -524,7 +532,7 @@ function buildPromptBarParts(params: {
entry.resetTimeIso,
isResetTimeDecimals(bar.resetTimeDecimals)
? { compactRounded: true, decimals: bar.resetTimeDecimals }
- : undefined,
+ : { spaced: bar.resetTimeSpaced },
)
: "";
@@ -541,8 +549,12 @@ function buildPromptBarParts(params: {
const percent = formatDisplayedPercentLabel(
entry.percentRemaining ?? 0,
bar.percentDisplayMode ?? "remaining",
+ "bare",
+ );
+ const p = Math.min(
+ 100,
+ resolveDisplayedPercent(entry.percentRemaining ?? 0, bar.percentDisplayMode ?? "remaining"),
);
- const p = Math.max(0, Math.min(100, Math.round(entry.percentRemaining ?? 0)));
const filled = Math.round((p / 100) * PROMPT_BAR_WIDTH);
const empty = PROMPT_BAR_WIDTH - filled;
let barText = "█".repeat(filled) + "░".repeat(empty);
@@ -559,9 +571,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
index 3fd502e0..cc708e35 100644
--- a/tests/format-utils.test.ts
+++ b/tests/format-utils.test.ts
@@ -1,6 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { formatResetCountdown } from "../src/lib/format-utils.js";
+import {
+ DISPLAYED_PERCENT_LABEL_WIDTH,
+ displayedPercentLabelWidth,
+ formatDisplayedPercentLabel,
+ formatQuotaModeHeading,
+ formatResetCountdown,
+} from "../src/lib/format-utils.js";
describe("formatResetCountdown", () => {
afterEach(() => {
@@ -17,6 +23,58 @@ describe("formatResetCountdown", () => {
vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
expect(formatResetCountdown("2026-01-15T10:00:00.000Z")).toBe("reset");
- expect(formatResetCountdown("2026-01-15T09:59:59.999Z")).toBe("reset");
+ expect(formatResetCountdown("2026-01-15T09:59:59.999Z", { spaced: true })).toBe("reset");
+ });
+
+ it("keeps exact countdowns compact by default and spaces compound units on request", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+
+ const multiDay = "2026-01-17T15:14:00.000Z";
+ const sameDay = "2026-01-15T13:45:00.000Z";
+ expect(formatResetCountdown(multiDay)).toBe("2d5h14m");
+ expect(formatResetCountdown(multiDay, { spaced: true })).toBe("2d 5h 14m");
+ expect(formatResetCountdown(sameDay, { spaced: true })).toBe("3h 45m");
+ });
+
+ it("leaves minute-only and partial-minute behavior unchanged in spaced mode", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+
+ expect(formatResetCountdown("2026-01-15T10:14:00.000Z", { spaced: true })).toBe("14m");
+ expect(formatResetCountdown("2026-01-15T12:14:01.000Z", { spaced: true })).toBe("2h 15m");
+ });
+
+ it("keeps resetTimeDecimals output unchanged when spacing is enabled", () => {
+ 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");
+ });
+});
+
+describe("percent labels", () => {
+ it("keeps full labels by default and supports bare remaining and used labels", () => {
+ expect(formatDisplayedPercentLabel(81, "remaining")).toBe("81% left");
+ expect(formatDisplayedPercentLabel(81, "used")).toBe("19% used");
+ expect(formatDisplayedPercentLabel(81, "remaining", "bare")).toBe("81%");
+ expect(formatDisplayedPercentLabel(81, "used", "bare")).toBe("19%");
+ });
+
+ it("reserves only the bare percentage width when requested", () => {
+ expect(displayedPercentLabelWidth()).toBe(DISPLAYED_PERCENT_LABEL_WIDTH);
+ expect(displayedPercentLabelWidth("full")).toBe(DISPLAYED_PERCENT_LABEL_WIDTH);
+ expect(displayedPercentLabelWidth("bare")).toBe("100%".length);
+ });
+
+ it("names the report-level percentage mode", () => {
+ expect(formatQuotaModeHeading("remaining")).toBe("Quota [Remaining]");
+ expect(formatQuotaModeHeading("used")).toBe("Quota [Used]");
});
});
diff --git a/tests/format.test.ts b/tests/format.test.ts
index 2b969216..d6746ce5 100644
--- a/tests/format.test.ts
+++ b/tests/format.test.ts
@@ -1072,4 +1072,84 @@ describe("formatQuotaRows", () => {
expect(used).not.toContain("% left");
expect(used).not.toContain("% used");
});
+
+ it("preserves default formatting when the new options are explicit but disabled", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+ const params = {
+ version: "1.0.0",
+ layout: { maxWidth: 36, narrowAt: 36, tinyAt: 20 },
+ entries: [
+ {
+ name: "[OpenAI Account With A Long Label] Weekly",
+ percentRemaining: 56,
+ resetTimeIso: "2026-01-17T15:14:00.000Z",
+ },
+ ],
+ };
+
+ expect(formatQuotaRows({ ...params, percentLabelStyle: "full", resetTimeSpaced: false })).toBe(
+ formatQuotaRows(params),
+ );
+ });
+
+ it("uses freed percent columns for a wider bar in a 36-column long-label layout", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+ const params = {
+ version: "1.0.0",
+ layout: { maxWidth: 36, narrowAt: 36, tinyAt: 20 },
+ entries: [
+ {
+ name: "[OpenAI Account With A Long Label] Weekly",
+ percentRemaining: 56,
+ resetTimeIso: "2026-01-17T15:14:00.000Z",
+ },
+ ],
+ };
+ const full = formatQuotaRows(params);
+ const bare = formatQuotaRows({
+ ...params,
+ percentLabelStyle: "bare",
+ resetTimeSpaced: true,
+ });
+ const barCells = (output: string) =>
+ output
+ .split("\n")
+ .find((line) => line.includes("█"))
+ ?.match(/[█░]/gu)?.length ?? 0;
+
+ expect(bare).toContain("56%");
+ expect(bare).not.toContain("56% left");
+ expect(bare).toContain("2d 5h 14m");
+ expect(barCells(bare)).toBe(barCells(full) + " left".length);
+ expect(bare.split("\n").every((line) => line.length <= 36)).toBe(true);
+ });
+
+ it("combines bare used labels and spaced reset times in grouped output", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+ const output = formatQuotaRows({
+ version: "1.0.0",
+ style: "allWindows",
+ layout: { maxWidth: 36, narrowAt: 36, tinyAt: 20 },
+ percentDisplayMode: "used",
+ percentLabelStyle: "bare",
+ resetTimeSpaced: true,
+ entries: [
+ {
+ name: "OpenAI 5h",
+ group: "OpenAI Account With A Long Label",
+ label: "5h:",
+ percentRemaining: 81,
+ resetTimeIso: "2026-01-15T13:45:00.000Z",
+ },
+ ],
+ });
+
+ expect(output).toContain("19%");
+ expect(output).not.toContain("19% used");
+ expect(output).toContain("3h 45m");
+ expect(output.split("\n").every((line) => line.length <= 36)).toBe(true);
+ });
});
diff --git a/tests/lib.cli-show.test.ts b/tests/lib.cli-show.test.ts
index 4559cd3c..3a039ada 100644
--- a/tests/lib.cli-show.test.ts
+++ b/tests/lib.cli-show.test.ts
@@ -94,6 +94,7 @@ describe("runCliShowCommand", () => {
});
afterEach(() => {
+ vi.useRealTimers();
if (savedConfigDir !== undefined) process.env.OPENCODE_CONFIG_DIR = savedConfigDir;
else delete process.env.OPENCODE_CONFIG_DIR;
mockProviders.length = 0;
@@ -159,6 +160,59 @@ describe("runCliShowCommand", () => {
expect(provider.fetch).toHaveBeenCalledOnce();
});
+ it("adds a Quota mode heading for bare CLI labels and spaces reset units", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+ const provider = {
+ id: "synthetic",
+ cachePolicy: { kind: "account-neutral" as const },
+ isAvailable: vi.fn().mockResolvedValue(true),
+ fetch: vi.fn().mockResolvedValue({
+ attempted: true,
+ entries: [
+ {
+ accounting: TEST_ACCOUNTING,
+ name: "Synthetic Weekly",
+ percentRemaining: 81,
+ resetTimeIso: "2026-01-17T15:14:00.000Z",
+ },
+ ],
+ errors: [],
+ }),
+ };
+ mockProviders.push(provider);
+ writeFileSync(
+ join(workspaceDir, "opencode.json"),
+ JSON.stringify({
+ experimental: {
+ quotaToast: {
+ enabledProviders: ["synthetic"],
+ percentDisplayMode: "used",
+ percentLabelStyle: "bare",
+ resetTimeSpaced: true,
+ },
+ },
+ }),
+ "utf8",
+ );
+
+ const stdout = createCaptureStream();
+ const stderr = createCaptureStream();
+ const code = await runCliShowCommand({
+ argv: [],
+ cwd: workspaceDir,
+ stdout: stdout.stream as any,
+ stderr: stderr.stream as any,
+ });
+
+ expect(code).toBe(0);
+ expect(stdout.output.startsWith("Quota [Used]\n\n")).toBe(true);
+ expect(stdout.output).toContain("19%");
+ expect(stdout.output).not.toContain("19% used");
+ expect(stdout.output).toContain("2d 5h 14m");
+ expect(stderr.output).toBe("");
+ });
+
it("renders two Antigravity account labels in human-readable CLI output", async () => {
const provider = {
id: "google-antigravity",
diff --git a/tests/lib.config.test.ts b/tests/lib.config.test.ts
index 99e34be6..32ce8721 100644
--- a/tests/lib.config.test.ts
+++ b/tests/lib.config.test.ts
@@ -860,6 +860,25 @@ describe("loadConfig", () => {
expect(invalid.config.percentDisplayMode).toBe("remaining");
});
+ it("defaults percentLabelStyle to unset and accepts full or bare overrides", async () => {
+ const defaults = await loadSdkConfig({});
+ expect(defaults.config.percentLabelStyle).toBeUndefined();
+
+ for (const percentLabelStyle of ["full", "bare"] as const) {
+ const configured = await loadSdkConfig({ percentLabelStyle });
+ expect(configured.config.percentLabelStyle).toBe(percentLabelStyle);
+ expect(configured.meta.settingSources).toEqual({
+ percentLabelStyle: "client.config.get",
+ });
+ }
+
+ 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 resetTimeDecimals to unset and accepts integer 0..4 overrides", async () => {
const defaults = await loadSdkConfig({});
expect(defaults.config.resetTimeDecimals).toBeUndefined();
@@ -882,6 +901,39 @@ describe("loadConfig", () => {
}
});
+ it("defaults resetTimeSpaced to unset and accepts boolean overrides", async () => {
+ const defaults = await loadSdkConfig({});
+ expect(defaults.config.resetTimeSpaced).toBeUndefined();
+
+ for (const resetTimeSpaced of [true, false]) {
+ const configured = await loadSdkConfig({ resetTimeSpaced });
+ expect(configured.config.resetTimeSpaced).toBe(resetTimeSpaced);
+ expect(configured.meta.settingSources).toEqual({
+ resetTimeSpaced: "client.config.get",
+ });
+ }
+
+ 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("loads both display options together without changing reset decimals", async () => {
+ const configured = await loadSdkConfig({
+ percentLabelStyle: "bare",
+ resetTimeDecimals: 2,
+ resetTimeSpaced: true,
+ });
+
+ expect(configured.config).toMatchObject({
+ percentLabelStyle: "bare",
+ resetTimeDecimals: 2,
+ resetTimeSpaced: true,
+ });
+ });
+
it("defaults anthropicBinaryPath and trims explicit overrides", async () => {
const defaults = await loadSdkConfig({});
expect(defaults.config.anthropicBinaryPath).toBe("claude");
diff --git a/tests/lib.quota-toast-runtime.test.ts b/tests/lib.quota-toast-runtime.test.ts
index ea3db08b..e185e74c 100644
--- a/tests/lib.quota-toast-runtime.test.ts
+++ b/tests/lib.quota-toast-runtime.test.ts
@@ -1,7 +1,7 @@
import { rm } from "fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { DEFAULT_CONFIG } from "../src/lib/types.js";
+import type { DEFAULT_CONFIG } from "../src/lib/types.js";
import {
createAlibabaAuthModuleMock,
createPluginTestClient as createClient,
@@ -841,6 +841,65 @@ describe("quota toast runtime state machine", () => {
expect(getToastMessage(clientB)).not.toContain("First config");
});
+ it("separates rendered-message cache entries for each display option", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+ const provider = {
+ id: "openai",
+ isAvailable: vi.fn().mockResolvedValue(true),
+ fetch: vi.fn().mockResolvedValue({
+ attempted: true,
+ entries: [
+ {
+ accounting: TEST_ACCOUNTING,
+ name: "OpenAI Weekly",
+ percentRemaining: 81,
+ resetTimeIso: "2026-01-17T15:14:00.000Z",
+ },
+ ],
+ errors: [],
+ }),
+ };
+ mocks.getProviders.mockReturnValue([provider]);
+
+ mocks.loadConfig.mockResolvedValueOnce(makeToastConfig());
+ const defaultClient = createClient();
+ const { runtime: defaultRuntime } = await createRuntime(defaultClient);
+ await defaultRuntime.handleTrigger({
+ sessionID: "session-display-cache",
+ trigger: "session.idle",
+ });
+ expect(getToastMessage(defaultClient)).toContain("81% left");
+ expect(getToastMessage(defaultClient)).toContain("2d5h14m");
+
+ mocks.loadConfig.mockResolvedValueOnce(makeToastConfig({ resetTimeSpaced: true }));
+ const spacedClient = createClient();
+ const { runtime: spacedRuntime } = await createRuntime(spacedClient);
+ await spacedRuntime.handleTrigger({
+ sessionID: "session-display-cache",
+ trigger: "session.idle",
+ });
+ expect(getToastMessage(spacedClient)).toContain("81% left");
+ expect(getToastMessage(spacedClient)).toContain("2d 5h 14m");
+
+ mocks.loadConfig.mockResolvedValueOnce(
+ makeToastConfig({ percentDisplayMode: "used", percentLabelStyle: "bare" }),
+ );
+ const bareClient = createClient();
+ const { runtime: bareRuntime } = await createRuntime(bareClient);
+ await bareRuntime.handleTrigger({
+ sessionID: "session-display-cache",
+ trigger: "session.idle",
+ });
+ expect(getToastMessage(bareClient)).toContain("19%");
+ expect(getToastMessage(bareClient)).not.toContain("19% used");
+ expect(bareClient.tui.showToast).toHaveBeenCalledWith({
+ body: expect.objectContaining({ title: "Quota [Used]" }),
+ });
+
+ expect(provider.fetch).toHaveBeenCalledTimes(3);
+ });
+
it("emits reset text only from a fresh collection", async () => {
mocks.loadConfig.mockResolvedValueOnce(
makeToastConfig({ resetNotifications: { enabled: true, windows: ["weekly"] } }),
diff --git a/tests/plugin.quota-command.test.ts b/tests/plugin.quota-command.test.ts
index e3e0e3b0..446d85c3 100644
--- a/tests/plugin.quota-command.test.ts
+++ b/tests/plugin.quota-command.test.ts
@@ -416,6 +416,57 @@ describe("/quota command behavior", () => {
expect(injected).not.toContain("81% left");
});
+ it("applies bare percent labels and spaced resets to /quota output", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+ try {
+ mocks.loadConfig.mockResolvedValueOnce({
+ ...DEFAULT_CONFIG,
+ enabled: true,
+ enabledProviders: ["openai"],
+ showOnQuestion: false,
+ showSessionTokens: false,
+ percentDisplayMode: "used",
+ percentLabelStyle: "bare",
+ resetTimeSpaced: true,
+ minIntervalMs: 60_000,
+ });
+
+ const provider = {
+ id: "openai",
+ isAvailable: vi.fn().mockResolvedValue(true),
+ fetch: vi.fn().mockResolvedValue({
+ attempted: true,
+ entries: [
+ {
+ accounting: TEST_ACCOUNTING,
+ name: "OpenAI Pro",
+ percentRemaining: 81,
+ resetTimeIso: "2026-01-17T15:14:00.000Z",
+ },
+ ],
+ errors: [],
+ }),
+ };
+ mocks.getProviders.mockReturnValue([provider]);
+
+ const { QuotaToastPlugin } = await import("../src/plugin.js");
+ const client = createClient();
+ await QuotaToastPlugin({ client } as any);
+
+ const injected = await buildDialogOutput({
+ client,
+ sessionID: "session-quota-display-options",
+ });
+ expect(injected).toContain("Quota [Used] (/quota)");
+ expect(injected).toContain("19%");
+ expect(injected).not.toContain("19% used");
+ expect(injected).toContain("2d 5h 14m");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("rewrites default_agent only when one zero-width-normalized key matches", async () => {
const { QuotaToastPlugin } = await import("../src/plugin.js");
const hooks = await QuotaToastPlugin({ client: createClient() } as any);
diff --git a/tests/quota-command-format.test.ts b/tests/quota-command-format.test.ts
index f59913b8..ed54f8c2 100644
--- a/tests/quota-command-format.test.ts
+++ b/tests/quota-command-format.test.ts
@@ -487,4 +487,32 @@ describe("formatQuotaCommand", () => {
expect(metric).not.toContain("```");
expect(Array.from(metric).length).toBeLessThanOrEqual(76);
});
+
+ it("uses a report heading to disambiguate bare used labels and spaces reset units", () => {
+ 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-18T17:14:00.000Z",
+ },
+ ],
+ errors: [],
+ percentDisplayMode: "used",
+ percentLabelStyle: "bare",
+ resetTimeSpaced: true,
+ generatedAtMs: Date.now(),
+ });
+
+ expect(out.split("\n")[0]).toMatch(/^Quota \[Used\] \(\/quota\) /u);
+ expect(out).toContain("19%");
+ expect(out).not.toContain("19% used");
+ expect(out).toContain("reset 3d 5h 14m");
+ });
});
diff --git a/tests/tui-compact-format.test.ts b/tests/tui-compact-format.test.ts
index 18418ec6..83a4065b 100644
--- a/tests/tui-compact-format.test.ts
+++ b/tests/tui-compact-format.test.ts
@@ -28,6 +28,29 @@ describe("buildCompactQuotaStatusLine", () => {
expect(line).toBe("OpenAI Weekly 50% 2d5h14m");
});
+ it("spaces exact compound resets while keeping compact percentages bare", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+
+ const line = buildCompactQuotaStatusLine({
+ percentDisplayMode: "used",
+ resetTimeSpaced: true,
+ maxWidth: 96,
+ data: {
+ entries: [
+ {
+ name: "OpenAI Weekly",
+ percentRemaining: 81,
+ resetTimeIso: "2026-01-17T15:14:00.000Z",
+ },
+ ],
+ errors: [],
+ },
+ });
+
+ expect(line).toBe("OpenAI Weekly 19% 2d 5h 14m");
+ });
+
it("renders expired provider resets once", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
diff --git a/tests/tui-runtime.test.ts b/tests/tui-runtime.test.ts
index 33354bbd..2a58d9b2 100644
--- a/tests/tui-runtime.test.ts
+++ b/tests/tui-runtime.test.ts
@@ -1657,6 +1657,81 @@ describe("tui runtime helpers", () => {
});
});
+ it("forwards spaced resets and bare labels to TUI quota displays", async () => {
+ writeFileSync(
+ join(worktreeDir, "opencode.json"),
+ JSON.stringify({
+ experimental: {
+ quotaToast: {
+ enabled: true,
+ percentDisplayMode: "used",
+ percentLabelStyle: "bare",
+ resetTimeSpaced: true,
+ tuiCompactStatus: {
+ enabled: true,
+ sessionPrompt: true,
+ maxWidth: 42,
+ },
+ tuiPromptBar: { enabled: true },
+ },
+ },
+ }),
+ "utf8",
+ );
+
+ const data = {
+ entries: [
+ {
+ name: "Copilot 5h",
+ percentRemaining: 18,
+ resetTimeIso: "2026-01-15T13:45:00.000Z",
+ },
+ ],
+ errors: [],
+ sessionTokens: undefined,
+ };
+ collectQuotaRenderData.mockResolvedValue({ active: [], data });
+ buildSidebarQuotaPanelLines.mockReturnValue(["Sidebar quota"]);
+ buildCompactQuotaStatusLine.mockReturnValue("Compact 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: "used",
+ });
+ expect(surfaces.promptBar).toMatchObject({
+ status: "ready",
+ percentDisplayMode: "used",
+ resetTimeSpaced: true,
+ });
+ expect(buildSidebarQuotaPanelLines).toHaveBeenCalledWith({
+ data,
+ config: expect.objectContaining({
+ percentLabelStyle: "bare",
+ resetTimeSpaced: true,
+ }),
+ });
+ expect(buildCompactQuotaStatusLine).toHaveBeenCalledWith({
+ data,
+ percentDisplayMode: "used",
+ accountingDetail: "summary",
+ resetTimeSpaced: true,
+ maxWidth: 42,
+ });
+ });
+
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..72198800 100644
--- a/tests/tui-sidebar-format.test.ts
+++ b/tests/tui-sidebar-format.test.ts
@@ -631,4 +631,46 @@ describe("buildSidebarQuotaPanelLines", () => {
expect(used).toEqual(remaining);
expect(used.join("\n")).toContain("$2.40 / $20.00");
});
+
+ it("applies spaced resets and bare labels within the 36-column sidebar", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+ const data = {
+ entries: [
+ {
+ name: "OpenAI Account With A Long Label Weekly",
+ group: "OpenAI Account With A Long Label",
+ label: "Weekly:",
+ percentRemaining: 81,
+ resetTimeIso: "2026-01-17T15:14:00.000Z",
+ },
+ ],
+ errors: [],
+ sessionTokens: undefined,
+ };
+
+ for (const formatStyle of ["singleWindow", "allWindows"] as const) {
+ const full = buildSidebarQuotaPanelLines({
+ data,
+ config: { formatStyle, percentDisplayMode: "remaining" },
+ });
+ const bare = buildSidebarQuotaPanelLines({
+ data,
+ config: {
+ formatStyle,
+ percentDisplayMode: "remaining",
+ percentLabelStyle: "bare",
+ resetTimeSpaced: true,
+ },
+ });
+ const barCells = (lines: string[]) =>
+ lines.find((line) => line.includes("█"))?.match(/[█░]/gu)?.length ?? 0;
+
+ expect(bare.join("\n")).toContain("81%");
+ expect(bare.join("\n")).not.toContain("81% left");
+ expect(bare.join("\n")).toContain("2d 5h 14m");
+ 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 8fff7d9e..22c00d52 100644
--- a/tests/tui-smoke.test.ts
+++ b/tests/tui-smoke.test.ts
@@ -1161,6 +1161,50 @@ describe("tui plugin smoke", () => {
).toEqual(["[OpenCode Go]", "Five-hour window 98%", "Weekly window 53%", "Monthly window 33%"]);
});
+ it("keeps sidebar collapse icons while naming the bare percent mode", async () => {
+ const plugin = await loadTuiModule();
+ const { api, registered } = createApi();
+
+ loadTuiSessionQuotaSurfaces.mockResolvedValueOnce({
+ sidebar: {
+ status: "ready",
+ lines: ["OpenCode Go 98%"],
+ linesExpanded: ["[OpenCode Go]", "Five-hour 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 registration = registered.find((item) => item.order === 150)!;
+ registration.slots.sidebar_content({}, { session_id: "session-mode" });
+ await Promise.resolve();
+
+ const collapsed = registration.slots.sidebar_content({}, { session_id: "session-mode" }) as any;
+ const header = collapsed.props.children[0].props.children[0];
+ expect(header.props.children.props.children).toBe("▶ Quota [Used]");
+
+ header.props.onMouseDown();
+ const expanded = registration.slots.sidebar_content({}, { session_id: "session-mode" }) 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();
@@ -1832,4 +1876,52 @@ describe("tui plugin smoke", () => {
expect(hint.props.children[2].props.children).toBe("50% | 2d5h14m");
});
+
+ it("keeps the prompt percentage bare while spacing reset units", async () => {
+ vi.setSystemTime(new Date("2026-01-15T10:00:00.000Z"));
+ const plugin = await loadTuiModule();
+ const { api, registered } = createApi();
+
+ 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,
+ });
+ loadTuiSessionQuotaSurfaces.mockResolvedValueOnce({
+ sidebar: { status: "disabled", lines: [] },
+ compact: { status: "disabled" },
+ promptBar: {
+ status: "ready",
+ entry: {
+ name: "OpenAI Weekly",
+ percentRemaining: 81,
+ resetTimeIso: "2026-01-17T15:14:00.000Z",
+ },
+ percentDisplayMode: "used",
+ resetTimeSpaced: true,
+ },
+ });
+
+ await startTui(plugin, api);
+ const registration = registered.find((item) => item.order === 90)!;
+ registration.slots.session_prompt({}, { session_id: "session-spaced-reset" });
+ await flushPromises();
+ const rendered = registration.slots.session_prompt(
+ {},
+ { session_id: "session-spaced-reset" },
+ ) as any;
+ const hint = rendered.props.children[1];
+
+ expect(hint.props.children[1].props.children).toBe(`██${"░".repeat(10)}`);
+ expect(hint.props.children[2].props.children).toBe("19% | 2d 5h 14m");
+ });
});