diff --git a/packages/core/src/commands.ts b/packages/core/src/commands.ts index b28f069..e178963 100644 --- a/packages/core/src/commands.ts +++ b/packages/core/src/commands.ts @@ -256,7 +256,11 @@ function formatSpendControlLine( const resets = spendControl.resetsAt ? ` · resets ${spendControl.resetsAt}` : '' - return `${indent}- credits: ${Math.round(spendControl.usedPercent)}% used (${Math.round(spendControl.used)} / ${Math.round(spendControl.limit)}, ${Math.round(spendControl.remaining)} remaining)${resets}` + const amount = (value: number) => Math.round(value).toLocaleString('en-US') + const unit = spendControl.unit ?? 'unit' + const plural = + spendControl.limit === 1 || unit.endsWith('s') ? unit : `${unit}s` + return `${indent}- credits: ${Math.round(spendControl.usedPercent)}% used (${amount(spendControl.used)} / ${amount(spendControl.limit)} ${plural}, ${amount(spendControl.remaining)} remaining)${resets}` } async function executeQuotaCommand( diff --git a/packages/opencode/src/tests/commands.test.ts b/packages/opencode/src/tests/commands.test.ts index 7436f71..0580ce7 100644 --- a/packages/opencode/src/tests/commands.test.ts +++ b/packages/opencode/src/tests/commands.test.ts @@ -1868,7 +1868,7 @@ describe('commands', () => { usedPercent: 20.071150665283206, remainingPercent: 79.9288493347168, resetsAt: '2026-10-01T00:00:00.000Z', - unit: 'credits', + unit: 'credit', source: 'individual_limit', reached: false, }, @@ -1896,7 +1896,7 @@ describe('commands', () => { ) expect(mainSection).toContain( - '- credits: 20% used (502 / 2500, 1998 remaining) · resets 2026-10-01T00:00:00.000Z', + '- credits: 20% used (502 / 2,500 credits, 1,998 remaining) · resets 2026-10-01T00:00:00.000Z', ) expect(fallbackSection).not.toContain('credits:') }) diff --git a/packages/opencode/src/tests/tui-quota-render.test.ts b/packages/opencode/src/tests/tui-quota-render.test.ts index bfd5cdb..fcbc023 100644 --- a/packages/opencode/src/tests/tui-quota-render.test.ts +++ b/packages/opencode/src/tests/tui-quota-render.test.ts @@ -4,6 +4,8 @@ import { buildApplyRequest, buildQuotaRowsForDisplay, buildRoutingRowsForDisplay, + computeQuotaLabelWidth, + getAccountMetadataRows, getQuotaMetadataRows, isQuotaLoaded, } from '../tui.tsx' @@ -11,6 +13,36 @@ import { describe('dynamic quota TUI rows', () => { const now = Date.UTC(2026, 6, 16, 12, 0, 0) + function projectQuotaRow(row: { + label: string + labelWidth: number + window: { usedPercent: number } + }): string { + return `${row.label.padEnd(row.labelWidth)}▓▓▓▓▓▓▓▓ ${String(Math.round(row.window.usedPercent)).padStart(3)}%` + } + + const twoWindows = { + primary: { usedPercent: 0, remainingPercent: 100, windowMinutes: 300 }, + secondary: { + usedPercent: 51, + remainingPercent: 49, + windowMinutes: 10_080, + }, + } + const withSpendControl = { + ...twoWindows, + spendControl: { + limit: 2500, + used: 501.7787666320801, + remaining: 1998.2212333679199, + usedPercent: 20.071150665283206, + remainingPercent: 79.9288493347168, + unit: 'credit', + source: 'individual_limit', + reached: false, + }, + } + test('one 7-day primary window produces one 7d row paced over seven days', () => { const rows = buildQuotaRowsForDisplay( { @@ -118,6 +150,57 @@ describe('dynamic quota TUI rows', () => { expect(rows.some((row) => row.key === 'spendControl')).toBe(false) }) + test('a sidebar with no spend control anywhere keeps the three-column label width', () => { + const fallback = { + primary: { usedPercent: 12, remainingPercent: 88, windowMinutes: 300 }, + } + const labelWidth = computeQuotaLabelWidth([twoWindows, fallback]) + + expect(labelWidth).toBe(3) + expect( + buildQuotaRowsForDisplay(twoWindows, now, false, labelWidth).map( + projectQuotaRow, + ), + ).toEqual(['5h ▓▓▓▓▓▓▓▓ 0%', '7d ▓▓▓▓▓▓▓▓ 51%']) + expect( + buildQuotaRowsForDisplay(fallback, now, false, labelWidth).map( + projectQuotaRow, + ), + ).toEqual(['5h ▓▓▓▓▓▓▓▓ 12%']) + }) + + test('one account with spend control widens every account label column', () => { + const labelWidth = computeQuotaLabelWidth([twoWindows, withSpendControl]) + + expect(labelWidth).toBe(8) + expect( + buildQuotaRowsForDisplay(twoWindows, now, false, labelWidth).map( + projectQuotaRow, + ), + ).toEqual(['5h ▓▓▓▓▓▓▓▓ 0%', '7d ▓▓▓▓▓▓▓▓ 51%']) + expect( + buildQuotaRowsForDisplay(withSpendControl, now, false, labelWidth).map( + projectQuotaRow, + ), + ).toEqual([ + '5h ▓▓▓▓▓▓▓▓ 0%', + '7d ▓▓▓▓▓▓▓▓ 51%', + 'credits ▓▓▓▓▓▓▓▓ 20%', + ]) + }) + + test('the longest label keeps a separator before its bar', () => { + const labelWidth = computeQuotaLabelWidth([withSpendControl]) + const creditsRow = buildQuotaRowsForDisplay( + withSpendControl, + now, + false, + labelWidth, + ).find((row) => row.key === 'spendControl') + + expect(creditsRow?.label.padEnd(creditsRow.labelWidth)).toBe('credits ') + }) + test('distinguishes an unloaded quota from a loaded snapshot with no windows', () => { expect(isQuotaLoaded(null)).toBe(false) expect(isQuotaLoaded({})).toBe(true) @@ -171,6 +254,41 @@ describe('dynamic quota TUI rows', () => { expect(tui.getAccountMetadataRows?.()).toEqual([]) }) + test('renders grouped credit amounts with a pluralised unit', () => { + const rows = getAccountMetadataRows(undefined, { + limit: 2500, + used: 501.7787666320801, + remaining: 1998.22123336792, + usedPercent: 20.071150665283206, + remainingPercent: 79.9288493347168, + unit: 'credit', + source: 'individual_limit', + reached: false, + }) + const rendered = JSON.stringify(rows) + + expect(rows).toContainEqual({ + label: 'credits', + value: '502 / 2,500 credits', + }) + expect(rendered).not.toContain('501.7787666320801') + expect(rendered).not.toContain('$') + }) + + test('a single-credit budget keeps the unit singular', () => { + expect( + getAccountMetadataRows(undefined, { + limit: 1, + used: 1, + remaining: 0, + usedPercent: 100, + remainingPercent: 0, + unit: 'credit', + reached: true, + }), + ).toContainEqual({ label: 'credits', value: '1 / 1 credit' }) + }) + test('modal routing apply sends sessionId on its RPC request', () => { expect(buildApplyRequest('openai-routing', 'reset', 'session-a')).toEqual({ command: 'openai-routing', diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index a9e40d8..a250e51 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -33,6 +33,7 @@ import { resolveSessionSidebarRouting, resolveSessionStickyAccount, type SidebarState, + type SpendControlReading, } from './sidebar-state.js' import { openCommandDialog } from './tui/command-dialogs.js' import { @@ -243,31 +244,63 @@ function CollapsedRow(props: { export interface QuotaDisplayRow { key: 'primary' | 'secondary' | 'spendControl' label: string + labelWidth: number window: QuotaWindow pacing: QuotaPacing | null } +const SPEND_CONTROL_LABEL = 'credits' + +// Labels a quota snapshot contributes to the sidebar, independent of pacing or +// the wall clock — the label column is sized from these alone. +function quotaRowLabels(quota: AccountQuota | null): string[] { + const labels = getPresentQuotaWindows(quota).map((row) => row.label) + if (quota?.spendControl) labels.push(SPEND_CONTROL_LABEL) + return labels +} + +// The label column is a property of the whole sidebar, not of one account's row +// set: every account's bars must start at the same column, so the width is +// derived once from every account rendered (main plus fallbacks) and threaded +// into each account's rows. The +1 keeps a separator before the longest label's +// bar; 3 is the floor the short 5h/7d labels already relied on. +export function computeQuotaLabelWidth( + quotas: ReadonlyArray, +): number { + let width = 3 + for (const quota of quotas) { + for (const label of quotaRowLabels(quota ?? null)) { + width = Math.max(width, label.length + 1) + } + } + return width +} + // Maps present quota windows onto display rows and computes pacing against each // dynamic duration or the historical 5h/7d duration for legacy snapshots. +// `labelWidth` comes from computeQuotaLabelWidth for rendered sidebars; callers +// that only inspect pacing may omit it and get this row set's own width. export function buildQuotaRowsForDisplay( quota: AccountQuota | null, now: number, pacingEnabled: boolean, + labelWidth?: number, ): QuotaDisplayRow[] { - const rows: QuotaDisplayRow[] = getPresentQuotaWindows(quota).map((row) => ({ - key: row.key, - label: row.label, - window: row.window, - pacing: - pacingEnabled && row.windowMs !== null - ? computeQuotaPacing(row.window, row.windowMs, now) - : null, - })) + const rows: Array> = + getPresentQuotaWindows(quota).map((row) => ({ + key: row.key, + label: row.label, + window: row.window, + pacing: + pacingEnabled && row.windowMs !== null + ? computeQuotaPacing(row.window, row.windowMs, now) + : null, + })) const spendControl = quota?.spendControl if (spendControl) { rows.push({ key: 'spendControl', - label: 'credits', + label: SPEND_CONTROL_LABEL, window: { usedPercent: spendControl.usedPercent, remainingPercent: spendControl.remainingPercent, @@ -276,7 +309,9 @@ export function buildQuotaRowsForDisplay( pacing: null, }) } - return rows + const width = + labelWidth ?? Math.max(3, ...rows.map((row) => row.label.length + 1)) + return rows.map((row) => ({ ...row, labelWidth: width })) } export function isQuotaLoaded(quota: AccountQuota | null): boolean { @@ -294,12 +329,39 @@ export function getQuotaMetadataRows( return rows } +function formatGroupedAmount(value: number): string { + return Math.round(value).toLocaleString('en-US') +} + +// The unit names the budget the amounts are counted in, so it agrees with the +// total rather than staying singular against a plural quantity. +function pluralizeUnit(unit: string, count: number): string { + if (count === 1) return unit + return unit.endsWith('s') ? unit : `${unit}s` +} + +export function formatSpendControlAmounts( + spendControl: SpendControlReading, +): string { + const unit = pluralizeUnit(spendControl.unit ?? 'unit', spendControl.limit) + return `${formatGroupedAmount(spendControl.used)} / ${formatGroupedAmount(spendControl.limit)} ${unit}` +} + export function getAccountMetadataRows( resetCredits: number | undefined, + spendControl?: SpendControlReading, ): Array<{ label: string; value: string }> { - return resetCredits === undefined - ? [] - : [{ label: 'resets', value: String(resetCredits) }] + const rows: Array<{ label: string; value: string }> = [] + if (spendControl) { + rows.push({ + label: 'credits', + value: formatSpendControlAmounts(spendControl), + }) + } + if (resetCredits !== undefined) { + rows.push({ label: 'resets', value: String(resetCredits) }) + } + return rows } // Quota window row: muted label left, tone-colored bar + percentage right, @@ -310,6 +372,7 @@ function QuotaRow(props: { theme: ThemeCurrent appearance: AppearancePrefs label: string + labelWidth: number window: { usedPercent: number; resetsAt?: string } | undefined pacing: QuotaPacing | null }) { @@ -329,7 +392,9 @@ function QuotaRow(props: { when={props.window} fallback={ - {props.label.padEnd(3)} + + {props.label.padEnd(props.labelWidth)} + {'\u2014'} } @@ -339,7 +404,9 @@ function QuotaRow(props: { the right edge so reset times align in their own right column. */} - {props.label.padEnd(3)} + + {props.label.padEnd(props.labelWidth)} + {(segment) => ( @@ -359,7 +426,7 @@ function QuotaRow(props: { - {' '} + {''.padEnd(props.labelWidth)} props.killed ? 'err' : props.active ? 'ok' : 'muted' const rows = () => - buildQuotaRowsForDisplay(props.quota, Date.now(), props.pacingEnabled) + buildQuotaRowsForDisplay( + props.quota, + Date.now(), + props.pacingEnabled, + props.labelWidth, + ) return ( @@ -418,6 +491,7 @@ function AccountBlock(props: { theme={props.theme} appearance={props.appearance} label={row.label} + labelWidth={row.labelWidth} window={row.window} pacing={row.pacing} /> @@ -425,7 +499,12 @@ function AccountBlock(props: { - + {(row) => ( (state().fallbacks ?? []).filter((f) => f.enabled) const activeId = () => resolveQuotaDialogActiveId(state(), props.sessionId) + const quotaLabelWidth = () => + computeQuotaLabelWidth([ + state().main?.quota ?? null, + ...enabledFallbacks().map((fb) => fb.quota), + ]) return ( @@ -483,6 +567,7 @@ function QuotaDialogContent(props: { killed={state().main?.killed ?? false} active={activeId() === 'main'} pacingEnabled={prefs().sections.pacing} + labelWidth={quotaLabelWidth()} resetCredits={state().main?.resetCredits} /> @@ -496,6 +581,7 @@ function QuotaDialogContent(props: { killed={fb.killed} active={activeId() === fb.id} pacingEnabled={prefs().sections.pacing} + labelWidth={quotaLabelWidth()} resetCredits={fb.resetCredits} marginTop={1} /> @@ -676,6 +762,11 @@ function QuotaSidebar(props: { route: sessionRouting().route, }) const activeAccount = () => resolveActiveAccount(sessionState()) + const quotaLabelWidth = () => + computeQuotaLabelWidth([ + state().main?.quota ?? null, + ...enabledFallbacks().map((fb) => fb.quota), + ]) const activeQuotaSummary = () => getCollapsedQuotaSummary(activeAccount().quota) const activePacingDeficit = () => { @@ -808,6 +899,7 @@ function QuotaSidebar(props: { killed={state().main?.killed ?? false} active={sessionRouting().activeId === 'main'} pacingEnabled={prefs().sections.pacing} + labelWidth={quotaLabelWidth()} resetCredits={state().main?.resetCredits} /> @@ -821,6 +913,7 @@ function QuotaSidebar(props: { killed={fb.killed} active={sessionRouting().activeId === fb.id} pacingEnabled={prefs().sections.pacing} + labelWidth={quotaLabelWidth()} resetCredits={fb.resetCredits} marginTop={1} />