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"); + }); });