From d339ef33dba29892655235dad08a70b957392a16 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:54:35 +0200 Subject: [PATCH 1/4] fix(tui): align quota rows by label width --- .../src/tests/tui-quota-render.test.ts | 55 +++++++++++++++++++ packages/opencode/src/tui.tsx | 35 +++++++----- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/tests/tui-quota-render.test.ts b/packages/opencode/src/tests/tui-quota-render.test.ts index bfd5cdb..0b189ce 100644 --- a/packages/opencode/src/tests/tui-quota-render.test.ts +++ b/packages/opencode/src/tests/tui-quota-render.test.ts @@ -11,6 +11,14 @@ 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 ?? 3)}▓▓▓▓▓▓▓▓ ${String(Math.round(row.window.usedPercent)).padStart(3)}%` + } + test('one 7-day primary window produces one 7d row paced over seven days', () => { const rows = buildQuotaRowsForDisplay( { @@ -118,6 +126,53 @@ describe('dynamic quota TUI rows', () => { expect(rows.some((row) => row.key === 'spendControl')).toBe(false) }) + test('aligns quota bars to the longest displayed label without widening two-window rows', () => { + const withCredits = buildQuotaRowsForDisplay( + { + primary: { usedPercent: 0, remainingPercent: 100, windowMinutes: 300 }, + secondary: { + usedPercent: 51, + remainingPercent: 49, + windowMinutes: 10_080, + }, + spendControl: { + limit: 2500, + used: 501.7787666320801, + remaining: 1998.2212333679199, + usedPercent: 20.071150665283206, + remainingPercent: 79.9288493347168, + unit: 'credit', + source: 'individual_limit', + reached: false, + }, + }, + now, + false, + ) + const withoutCredits = buildQuotaRowsForDisplay( + { + primary: { usedPercent: 0, remainingPercent: 100, windowMinutes: 300 }, + secondary: { + usedPercent: 51, + remainingPercent: 49, + windowMinutes: 10_080, + }, + }, + now, + false, + ) + + expect(withCredits.map(projectQuotaRow)).toEqual([ + '5h ▓▓▓▓▓▓▓▓ 0%', + '7d ▓▓▓▓▓▓▓▓ 51%', + 'credits▓▓▓▓▓▓▓▓ 20%', + ]) + expect(withoutCredits.map(projectQuotaRow)).toEqual([ + '5h ▓▓▓▓▓▓▓▓ 0%', + '7d ▓▓▓▓▓▓▓▓ 51%', + ]) + }) + test('distinguishes an unloaded quota from a loaded snapshot with no windows', () => { expect(isQuotaLoaded(null)).toBe(false) expect(isQuotaLoaded({})).toBe(true) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index a9e40d8..1cf6eed 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -243,6 +243,7 @@ function CollapsedRow(props: { export interface QuotaDisplayRow { key: 'primary' | 'secondary' | 'spendControl' label: string + labelWidth: number window: QuotaWindow pacing: QuotaPacing | null } @@ -254,15 +255,16 @@ export function buildQuotaRowsForDisplay( now: number, pacingEnabled: boolean, ): 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({ @@ -276,7 +278,8 @@ export function buildQuotaRowsForDisplay( pacing: null, }) } - return rows + const labelWidth = Math.max(3, ...rows.map((row) => row.label.length)) + return rows.map((row) => ({ ...row, labelWidth })) } export function isQuotaLoaded(quota: AccountQuota | null): boolean { @@ -310,6 +313,7 @@ function QuotaRow(props: { theme: ThemeCurrent appearance: AppearancePrefs label: string + labelWidth: number window: { usedPercent: number; resetsAt?: string } | undefined pacing: QuotaPacing | null }) { @@ -329,7 +333,9 @@ function QuotaRow(props: { when={props.window} fallback={ - {props.label.padEnd(3)} + + {props.label.padEnd(props.labelWidth)} + {'\u2014'} } @@ -339,7 +345,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 +367,7 @@ function QuotaRow(props: { - {' '} + {''.padEnd(props.labelWidth)} From fed3198fedec8badc1b30477413a0426c7afa4ec Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:58:12 +0200 Subject: [PATCH 2/4] fix(quota): show credit budget amounts --- packages/core/src/commands.ts | 4 ++- packages/opencode/src/tests/commands.test.ts | 4 +-- .../src/tests/tui-quota-render.test.ts | 22 ++++++++++++ packages/opencode/src/tui.tsx | 34 ++++++++++++++++--- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/core/src/commands.ts b/packages/core/src/commands.ts index b28f069..5706e44 100644 --- a/packages/core/src/commands.ts +++ b/packages/core/src/commands.ts @@ -256,7 +256,9 @@ 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 ?? 'units' + return `${indent}- credits: ${Math.round(spendControl.usedPercent)}% used (${amount(spendControl.used)} / ${amount(spendControl.limit)} ${unit}, ${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..9fcda75 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 credit, 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 0b189ce..4f64784 100644 --- a/packages/opencode/src/tests/tui-quota-render.test.ts +++ b/packages/opencode/src/tests/tui-quota-render.test.ts @@ -4,6 +4,7 @@ import { buildApplyRequest, buildQuotaRowsForDisplay, buildRoutingRowsForDisplay, + getAccountMetadataRows, getQuotaMetadataRows, isQuotaLoaded, } from '../tui.tsx' @@ -226,6 +227,27 @@ describe('dynamic quota TUI rows', () => { expect(tui.getAccountMetadataRows?.()).toEqual([]) }) + test('renders rounded credit-budget amounts with the reported 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.5k credit', + }) + expect(rendered).not.toContain('501.7787666320801') + expect(rendered).not.toContain('$') + }) + 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 1cf6eed..bada45e 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 { @@ -297,12 +298,32 @@ export function getQuotaMetadataRows( return rows } +function formatCompactSpendAmount(value: number): string { + if (value < 1000) return String(Math.round(value)) + return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k` +} + +export function formatSpendControlAmounts( + spendControl: SpendControlReading, +): string { + return `${formatCompactSpendAmount(spendControl.used)} / ${formatCompactSpendAmount(spendControl.limit)} ${spendControl.unit ?? 'units'}` +} + 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, @@ -434,7 +455,12 @@ function AccountBlock(props: { - + {(row) => ( Date: Fri, 18 Sep 2026 23:08:29 +0200 Subject: [PATCH 3/4] fix(tui): size the quota label column across the sidebar --- .../src/tests/tui-quota-render.test.ts | 115 +++++++++++------- packages/opencode/src/tui.tsx | 59 ++++++++- 2 files changed, 126 insertions(+), 48 deletions(-) diff --git a/packages/opencode/src/tests/tui-quota-render.test.ts b/packages/opencode/src/tests/tui-quota-render.test.ts index 4f64784..8cf9c45 100644 --- a/packages/opencode/src/tests/tui-quota-render.test.ts +++ b/packages/opencode/src/tests/tui-quota-render.test.ts @@ -4,6 +4,7 @@ import { buildApplyRequest, buildQuotaRowsForDisplay, buildRoutingRowsForDisplay, + computeQuotaLabelWidth, getAccountMetadataRows, getQuotaMetadataRows, isQuotaLoaded, @@ -14,10 +15,32 @@ describe('dynamic quota TUI rows', () => { function projectQuotaRow(row: { label: string - labelWidth?: number + labelWidth: number window: { usedPercent: number } }): string { - return `${row.label.padEnd(row.labelWidth ?? 3)}▓▓▓▓▓▓▓▓ ${String(Math.round(row.window.usedPercent)).padStart(3)}%` + 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', () => { @@ -127,51 +150,55 @@ describe('dynamic quota TUI rows', () => { expect(rows.some((row) => row.key === 'spendControl')).toBe(false) }) - test('aligns quota bars to the longest displayed label without widening two-window rows', () => { - const withCredits = buildQuotaRowsForDisplay( - { - primary: { usedPercent: 0, remainingPercent: 100, windowMinutes: 300 }, - secondary: { - usedPercent: 51, - remainingPercent: 49, - windowMinutes: 10_080, - }, - spendControl: { - limit: 2500, - used: 501.7787666320801, - remaining: 1998.2212333679199, - usedPercent: 20.071150665283206, - remainingPercent: 79.9288493347168, - unit: 'credit', - source: 'individual_limit', - reached: false, - }, - }, - now, - false, - ) - const withoutCredits = buildQuotaRowsForDisplay( - { - primary: { usedPercent: 0, remainingPercent: 100, windowMinutes: 300 }, - secondary: { - usedPercent: 51, - remainingPercent: 49, - windowMinutes: 10_080, - }, - }, + 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(withCredits.map(projectQuotaRow)).toEqual([ - '5h ▓▓▓▓▓▓▓▓ 0%', - '7d ▓▓▓▓▓▓▓▓ 51%', - 'credits▓▓▓▓▓▓▓▓ 20%', - ]) - expect(withoutCredits.map(projectQuotaRow)).toEqual([ - '5h ▓▓▓▓▓▓▓▓ 0%', - '7d ▓▓▓▓▓▓▓▓ 51%', - ]) + expect(creditsRow?.label.padEnd(creditsRow.labelWidth)).toBe('credits ') }) test('distinguishes an unloaded quota from a loaded snapshot with no windows', () => { diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index bada45e..2f81d28 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -249,12 +249,42 @@ export interface QuotaDisplayRow { 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: Array> = getPresentQuotaWindows(quota).map((row) => ({ @@ -270,7 +300,7 @@ export function buildQuotaRowsForDisplay( if (spendControl) { rows.push({ key: 'spendControl', - label: 'credits', + label: SPEND_CONTROL_LABEL, window: { usedPercent: spendControl.usedPercent, remainingPercent: spendControl.remainingPercent, @@ -279,8 +309,9 @@ export function buildQuotaRowsForDisplay( pacing: null, }) } - const labelWidth = Math.max(3, ...rows.map((row) => row.label.length)) - return rows.map((row) => ({ ...row, labelWidth })) + 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 { @@ -414,6 +445,7 @@ function AccountBlock(props: { killed: boolean active: boolean pacingEnabled: boolean + labelWidth: number resetCredits?: number marginTop?: number }) { @@ -422,7 +454,12 @@ function AccountBlock(props: { const statusTone = (): Tone => 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 ( @@ -502,6 +539,11 @@ function QuotaDialogContent(props: { const enabledFallbacks = () => (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 ( @@ -518,6 +560,7 @@ function QuotaDialogContent(props: { killed={state().main?.killed ?? false} active={activeId() === 'main'} pacingEnabled={prefs().sections.pacing} + labelWidth={quotaLabelWidth()} resetCredits={state().main?.resetCredits} /> @@ -531,6 +574,7 @@ function QuotaDialogContent(props: { killed={fb.killed} active={activeId() === fb.id} pacingEnabled={prefs().sections.pacing} + labelWidth={quotaLabelWidth()} resetCredits={fb.resetCredits} marginTop={1} /> @@ -711,6 +755,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 = () => { @@ -843,6 +892,7 @@ function QuotaSidebar(props: { killed={state().main?.killed ?? false} active={sessionRouting().activeId === 'main'} pacingEnabled={prefs().sections.pacing} + labelWidth={quotaLabelWidth()} resetCredits={state().main?.resetCredits} /> @@ -856,6 +906,7 @@ function QuotaSidebar(props: { killed={fb.killed} active={sessionRouting().activeId === fb.id} pacingEnabled={prefs().sections.pacing} + labelWidth={quotaLabelWidth()} resetCredits={fb.resetCredits} marginTop={1} /> From ecc673b9a5ce7e5687947964580bf324e0756f66 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:09:38 +0200 Subject: [PATCH 4/4] fix(quota): render grouped credit amounts with a plural unit --- packages/core/src/commands.ts | 6 ++++-- packages/opencode/src/tests/commands.test.ts | 2 +- .../src/tests/tui-quota-render.test.ts | 18 ++++++++++++++++-- packages/opencode/src/tui.tsx | 15 +++++++++++---- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/core/src/commands.ts b/packages/core/src/commands.ts index 5706e44..e178963 100644 --- a/packages/core/src/commands.ts +++ b/packages/core/src/commands.ts @@ -257,8 +257,10 @@ function formatSpendControlLine( ? ` · resets ${spendControl.resetsAt}` : '' const amount = (value: number) => Math.round(value).toLocaleString('en-US') - const unit = spendControl.unit ?? 'units' - return `${indent}- credits: ${Math.round(spendControl.usedPercent)}% used (${amount(spendControl.used)} / ${amount(spendControl.limit)} ${unit}, ${amount(spendControl.remaining)} remaining)${resets}` + 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 9fcda75..0580ce7 100644 --- a/packages/opencode/src/tests/commands.test.ts +++ b/packages/opencode/src/tests/commands.test.ts @@ -1896,7 +1896,7 @@ describe('commands', () => { ) expect(mainSection).toContain( - '- credits: 20% used (502 / 2,500 credit, 1,998 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 8cf9c45..fcbc023 100644 --- a/packages/opencode/src/tests/tui-quota-render.test.ts +++ b/packages/opencode/src/tests/tui-quota-render.test.ts @@ -254,7 +254,7 @@ describe('dynamic quota TUI rows', () => { expect(tui.getAccountMetadataRows?.()).toEqual([]) }) - test('renders rounded credit-budget amounts with the reported unit', () => { + test('renders grouped credit amounts with a pluralised unit', () => { const rows = getAccountMetadataRows(undefined, { limit: 2500, used: 501.7787666320801, @@ -269,12 +269,26 @@ describe('dynamic quota TUI rows', () => { expect(rows).toContainEqual({ label: 'credits', - value: '502 / 2.5k credit', + 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 2f81d28..a250e51 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -329,15 +329,22 @@ export function getQuotaMetadataRows( return rows } -function formatCompactSpendAmount(value: number): string { - if (value < 1000) return String(Math.round(value)) - return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k` +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 { - return `${formatCompactSpendAmount(spendControl.used)} / ${formatCompactSpendAmount(spendControl.limit)} ${spendControl.unit ?? 'units'}` + const unit = pluralizeUnit(spendControl.unit ?? 'unit', spendControl.limit) + return `${formatGroupedAmount(spendControl.used)} / ${formatGroupedAmount(spendControl.limit)} ${unit}` } export function getAccountMetadataRows(