Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 23 additions & 1 deletion docs/readme/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"` |
Expand Down Expand Up @@ -274,6 +276,24 @@ Leave it unset to preserve the default display exactly.

</details>

<details>
<summary><strong>Space out reset countdowns and simplify percent labels</strong></summary>

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.

</details>

<details>
<summary><strong>Change maintainer notices</strong></summary>

Expand Down Expand Up @@ -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`. |
Expand Down
2 changes: 2 additions & 0 deletions src/lib/cli-show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
34 changes: 34 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
CursorQuotaPlan,
GoogleModelId,
PercentDisplayMode,
PercentLabelStyle,
PricingSnapshotSource,
QuotaResetWindow,
QuotaToastConfig,
Expand All @@ -47,6 +48,8 @@ export const QUOTA_TOAST_SETTING_SOURCE_KEYS = [
"percentDisplayMode",
"accountingDetail",
"resetTimeDecimals",
"resetTimeSpaced",
"percentLabelStyle",
"minIntervalMs",
"requestTimeoutMs",
"debug",
Expand Down Expand Up @@ -159,6 +162,8 @@ type ValidatedQuotaToastPatch = {
percentDisplayMode?: PercentDisplayMode;
accountingDetail?: QuotaToastConfig["accountingDetail"];
resetTimeDecimals?: number;
resetTimeSpaced?: boolean;
percentLabelStyle?: PercentLabelStyle;
minIntervalMs?: number;
requestTimeoutMs?: number;
debug?: boolean;
Expand Down Expand Up @@ -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";
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 25 additions & 9 deletions src/lib/format-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -142,17 +152,20 @@ 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;
/**
* When set with compactRounded, render the largest active unit with this
* 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;
Expand All @@ -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 {
Expand All @@ -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;
Expand All @@ -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;
Expand Down
79 changes: 59 additions & 20 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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,
});
}
Expand All @@ -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,
),
);

Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
Loading