From 6d3ab9f971fc8a84286ed94e44ff69862dd739eb Mon Sep 17 00:00:00 2001 From: Marius Date: Sat, 11 Jul 2026 22:45:52 +0200 Subject: [PATCH] feat(status-bar): choose used or remaining usage (#7574) Add a persisted Appearance setting that switches provider usage labels between percent used and percent remaining while keeping meter fill consumption-based. Cover desktop and web persistence, current providers, settings search, localized copy, and regression tests. Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> --- src/main/persistence.ts | 7 ++ src/main/runtime/rpc/methods/client-ui.ts | 1 + .../settings/AppearancePane.test.tsx | 20 +++++ .../AppearanceWindowSidebarSection.tsx | 60 +++++++++++++-- .../components/settings/appearance-search.ts | 10 ++- .../appearance-usage-percentage-search.ts | 18 +++++ .../src/components/status-bar/StatusBar.tsx | 76 ++++++++++++------- .../status-bar/inline-usage-bars.test.tsx | 37 ++++++++- .../status-bar-provider-menu-focus.test.tsx | 14 +++- .../src/components/status-bar/tooltip.test.ts | 32 ++++++++ .../src/components/status-bar/tooltip.tsx | 15 ++-- .../status-bar/usage-percentage-label.test.ts | 15 ++++ .../status-bar/usage-percentage-label.ts | 19 +++++ src/renderer/src/i18n/locales/en.json | 12 ++- src/renderer/src/i18n/locales/es.json | 12 ++- src/renderer/src/i18n/locales/ja.json | 12 ++- src/renderer/src/i18n/locales/ko.json | 12 ++- src/renderer/src/i18n/locales/zh.json | 12 ++- src/renderer/src/store/slices/ui.test.ts | 26 +++++++ src/renderer/src/store/slices/ui.ts | 14 ++++ src/renderer/src/web/web-preload-api.ts | 4 + src/shared/constants.ts | 2 + src/shared/types.ts | 3 + src/shared/usage-percentage-display.test.ts | 25 ++++++ src/shared/usage-percentage-display.ts | 21 +++++ 25 files changed, 423 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-usage-percentage-search.ts create mode 100644 src/renderer/src/components/status-bar/usage-percentage-label.test.ts create mode 100644 src/renderer/src/components/status-bar/usage-percentage-label.ts create mode 100644 src/shared/usage-percentage-display.test.ts create mode 100644 src/shared/usage-percentage-display.ts diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0cac89b3f17..19cce1bbacb 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -104,6 +104,7 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -5333,6 +5334,9 @@ export class Store { this.state.ui?.workspaceBoardColumnWidth ), syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + this.state.ui?.usagePercentageDisplay + ), // Why: strict boolean coercion so a missing/legacy value reads as false // (first-run notice still fires) rather than leaking a non-bool through. trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, @@ -5412,6 +5416,9 @@ export class Store { sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true : this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay + ), markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 9b08e6decd3..271fcefad75 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -208,6 +208,7 @@ const UiUpdate = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), + usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 127df935ff7..dc5a4abd1aa 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({ settingsSearchQuery: 'automations', statusBarItems: [], toggleStatusBarItem: vi.fn(), + usagePercentageDisplay: 'used' as 'used' | 'remaining', + setUsagePercentageDisplay: vi.fn(), recordFeatureInteraction: vi.fn(), setWorktreeCardMode: vi.fn() } @@ -176,6 +178,7 @@ describe('AppearancePane', () => { vi.clearAllMocks() mocks.state.availableStatusBarToggles = [] mocks.state.settingsSearchQuery = 'automations' + mocks.state.usagePercentageDisplay = 'used' // UIZoomControl reads window.api.ui on mount; the inline-expansion pane can // render the full Interface section, so provide a minimal renderer bridge // without clobbering happy-dom's window.location. @@ -387,6 +390,23 @@ describe('AppearancePane', () => { expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull() }) + it('updates the usage percentage display from the latest status bar settings section', async () => { + mocks.state.settingsSearchQuery = 'remaining' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const remainingButton = Array.from( + container.querySelectorAll('button[role="radio"]') + ).find((button) => button.textContent === 'Remaining') + + expect(container.textContent).toContain('Usage percentages') + expect(remainingButton).toBeDefined() + + await act(async () => { + remainingButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.state.setUsagePercentageDisplay).toHaveBeenCalledWith('remaining') + }) + it('records MiniMax status bar toggles as usage tracking interactions', async () => { mocks.state.availableStatusBarToggles = [ { diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 1690832ee18..93b9f94535e 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -12,7 +12,12 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles' -import { getLayoutEntries, getSidebarEntries, getStatusBarToggles } from './appearance-search' +import { + getLayoutEntries, + getSidebarEntries, + getStatusBarToggles, + getUsagePercentageDisplayEntry +} from './appearance-search' import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting' import { getLeftSidebarAppearanceEntry, @@ -60,9 +65,12 @@ export function AppearanceWindowSidebarSection({ const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0 const statusBarItems = useAppStore((state) => state.statusBarItems) const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem) + const usagePercentageDisplay = useAppStore((state) => state.usagePercentageDisplay) + const setUsagePercentageDisplay = useAppStore((state) => state.setUsagePercentageDisplay) const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) const setWorktreeCardMode = useAppStore((state) => state.setWorktreeCardMode) const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles()) + const usagePercentageDisplayEntry = getUsagePercentageDisplayEntry() const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry() const sidebarEntries = getSidebarEntries() const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry() @@ -81,13 +89,15 @@ export function AppearanceWindowSidebarSection({ description: statusBarDescription, keywords: statusBarKeywords }) - const statusBarControlMatches = visibleStatusBarToggles.some((toggle) => - matchesSettingsSearch(searchQuery, { - title: toggle.title, - description: toggle.description, - keywords: toggle.keywords - }) - ) + const statusBarControlMatches = + matchesSettingsSearch(searchQuery, usagePercentageDisplayEntry) || + visibleStatusBarToggles.some((toggle) => + matchesSettingsSearch(searchQuery, { + title: toggle.title, + description: toggle.description, + keywords: toggle.keywords + }) + ) const sidebarAdvancedMatches = matchesSettingsSearch(searchQuery, [ workspaceCardLayoutEntry, ...sidebarEntries @@ -119,6 +129,40 @@ export function AppearanceWindowSidebarSection({ {showStatusBarControls ? (
+ + + } + /> + + {visibleStatusBarToggles.map((toggle) => { const enabled = statusBarItems.includes(toggle.id) return ( diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 7284b5db315..3ceaf412d13 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -8,8 +8,9 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages' import { getStatusBarToggles } from './appearance-status-bar-search' +import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search' -export { getStatusBarToggles } +export { getStatusBarToggles, getUsagePercentageDisplayEntry } export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { @@ -148,13 +149,14 @@ export const getTitlebarEntries = createLocalizedCatalog((): SettingsSearchEntry } ]) -export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => - getStatusBarToggles().map(({ title, description, keywords }) => ({ +export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ + getUsagePercentageDisplayEntry(), + ...getStatusBarToggles().map(({ title, description, keywords }) => ({ title, description, keywords })) -) +]) export { getLeftSidebarAppearanceEntry, getSidebarEntries } diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts new file mode 100644 index 00000000000..9375c7c9fd1 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts @@ -0,0 +1,18 @@ +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export const getUsagePercentageDisplayEntry = createLocalizedCatalog(() => ({ + title: translate( + 'auto.components.settings.appearance.search.usagePercentageDisplayTitle', + 'Usage percentages' + ), + description: translate( + 'auto.components.settings.appearance.search.usagePercentageDisplayDescription', + 'Choose whether provider limits show the percentage used or remaining.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword('auto.components.settings.appearance.search.896eb53fd4', 'status bar') + ] +})) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index aa826573e73..281c968fa2f 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -79,6 +79,11 @@ import { selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client' import { translate } from '@/i18n/i18n' +import { + normalizeUsagePercentageDisplay, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' +import { formatUsagePercentageLabel } from './usage-percentage-label' type StatusBarProps = { floatingTerminalOpen: boolean @@ -963,9 +968,11 @@ export function InlineUsageBars({ limits: ProviderRateLimits isFetching: boolean }): React.JSX.Element { - // Why: show % used (consumption), not remaining — matches harness meters (#7551). - // Keep the "used" word in compact labels so bare "32% 5h" is not read as remaining. - const usedSuffix = translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used') + const display = normalizeUsagePercentageDisplay( + useAppStore((state) => state.usagePercentageDisplay) + ) + // Why: the preference changes copy, while bar fill stays consumption-based + // so empty/green and full/red keep the meter semantics introduced in #8167. const usageWindows = [ limits.session ? { @@ -1006,8 +1013,7 @@ export function InlineUsageBars({ />
- {window.used} - {usedSuffix} {window.label} + {formatUsagePercentageLabel(window.used, display)} {window.label} ))} @@ -1080,16 +1086,21 @@ function InlineUsageSkeleton(): React.JSX.Element { } // --------------------------------------------------------------------------- -// Window label (shows percent used / consumption) +// Window label // --------------------------------------------------------------------------- -function WindowLabel({ w, label }: { w: RateLimitWindow; label: string }): React.JSX.Element { - const used = clampUsedPercent(w.usedPercent) - // Why: "32% 5h" is ambiguous after the remaining→used flip; keep "used" explicit. +function WindowLabel({ + w, + label, + display +}: { + w: RateLimitWindow + label: string + display: UsagePercentageDisplay +}): React.JSX.Element { return ( - {used} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} {label} + {formatUsagePercentageLabel(w.usedPercent, display)} {label} ) } @@ -1104,10 +1115,12 @@ const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) function ProviderSegment({ p, - compact + compact, + display }: { p: ProviderRateLimits | null compact: boolean + display: UsagePercentageDisplay }): React.JSX.Element { const provider = p?.provider ?? 'claude' const statusLabel = p ? getProviderUsageStatusLabel(p) : '' @@ -1160,20 +1173,20 @@ function ProviderSegment({ return ( - {visibleBuckets.map((bucket, i) => { - const used = clampUsedPercent(bucket.usedPercent) - return ( - - {i > 0 && ·} - - {bucket.name} {used} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} - - - ) - })} + {visibleBuckets.map((bucket, i) => ( + + {i > 0 && ·} + + {bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)} + + + ))} {visibleBuckets.length === 0 && p.session && ( - + )} {isStale && } @@ -1211,7 +1224,7 @@ function ProviderSegment({ {visibleWindows.map((window, index) => ( {index > 0 && ·} - + ))} {isStale && } @@ -1745,6 +1758,9 @@ export function ProviderDetailsMenu({ children?: React.ReactNode }): React.JSX.Element { const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const usagePercentageDisplay = normalizeUsagePercentageDisplay( + useAppStore((s) => s.usagePercentageDisplay) + ) const skipCloseAutoFocusRef = useRef(false) const handleOpenChange = (nextOpen: boolean): void => { @@ -1787,7 +1803,7 @@ export function ProviderDetailsMenu({ ) : ( - + )} @@ -1812,7 +1828,11 @@ export function ProviderDetailsMenu({ {topContent}
{/* Why: provider-specific action sections may render richer reset-credit UI. */} - +
{children ? ( <> diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx index 018d260768f..404caa344dc 100644 --- a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -1,16 +1,31 @@ import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' vi.mock('@/i18n/i18n', () => ({ i18n: { language: 'en' }, - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) +const mocks = vi.hoisted(() => ({ + usagePercentageDisplay: 'used' as 'used' | 'remaining' +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) => + selector({ usagePercentageDisplay: mocks.usagePercentageDisplay }) +})) + function claudeLimits(): ProviderRateLimits { return { provider: 'claude', @@ -39,6 +54,10 @@ function claudeLimits(): ProviderRateLimits { } describe('InlineUsageBars', () => { + beforeEach(() => { + mocks.usagePercentageDisplay = 'used' + }) + it('renders Claude Fable usage in inactive account preview rows', async () => { const { InlineUsageBars } = await import('./StatusBar') @@ -51,4 +70,18 @@ describe('InlineUsageBars', () => { expect(markup).toContain('16% used wk') expect(markup).toContain('42% used Fable') }) + + it('shows remaining copy without reversing consumption meter fill', async () => { + mocks.usagePercentageDisplay = 'remaining' + const { InlineUsageBars } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('68% left 5h') + expect(markup).toContain('84% left wk') + expect(markup).toContain('58% left Fable') + expect(markup).toContain('width:32%') + }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx index 25dfa651bc5..a72761322a5 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx +++ b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' const recordFeatureInteractionMock = vi.fn() +const usagePercentageDisplayMock = 'used' vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() @@ -17,8 +18,15 @@ vi.mock('react', async () => { vi.mock('../../store', () => ({ useAppStore: ( - selector: (state: { recordFeatureInteraction: typeof recordFeatureInteractionMock }) => unknown - ) => selector({ recordFeatureInteraction: recordFeatureInteractionMock }) + selector: (state: { + recordFeatureInteraction: typeof recordFeatureInteractionMock + usagePercentageDisplay: 'used' + }) => unknown + ) => + selector({ + recordFeatureInteraction: recordFeatureInteractionMock, + usagePercentageDisplay: usagePercentageDisplayMock + }) })) vi.mock('./tooltip', () => ({ @@ -108,8 +116,10 @@ describe('ProviderDetailsMenu focus handoff', () => { const element = await renderProviderDetailsMenu() const dropdown = findChildByType(element, 'DropdownMenu') const content = findChildByType(element, 'DropdownMenuContent') + const providerPanel = findChildByType(element, 'ProviderPanel') expect(dropdown.props.modal).toBe(false) + expect(providerPanel.props.usagePercentageDisplay).toBe('used') const preventDefault = vi.fn() ;(content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({ diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 8f214ecc7fa..c553f68ddb1 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -45,6 +45,17 @@ function provider(overrides: Partial = {}): ProviderRateLimi } } +const PROVIDER_IDS: ProviderRateLimits['provider'][] = [ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode-go', + 'kimi', + 'minimax', + 'grok' +] + afterEach(() => { vi.useRealTimers() }) @@ -473,6 +484,27 @@ describe('ProviderPanel reset rendering', () => { expect(markup).toContain('width:100%') expect(markup).not.toContain('140%') }) + + it.each(PROVIDER_IDS)( + 'applies remaining copy to %s while retaining consumption bar direction', + (providerId) => { + const p = provider({ + provider: providerId, + status: 'ok', + session: { + usedPercent: 25, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p, usagePercentageDisplay: 'remaining' })) + + expect(markup).toContain('75% left') + expect(markup).toContain('width:25%') + } + ) }) describe('clampUsedPercent', () => { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 47ba6414267..7890529bde2 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -7,6 +7,8 @@ import { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { formatUsagePercentageLabel } from './usage-percentage-label' export { getProviderDisplayName, @@ -213,12 +215,14 @@ export function ProviderPanel({ p, inverted = false, className, - showResetCredits = true + showResetCredits = true, + usagePercentageDisplay = 'used' }: { p: ProviderRateLimits | null inverted?: boolean className?: string showResetCredits?: boolean + usagePercentageDisplay?: UsagePercentageDisplay }): React.JSX.Element { const textClass = inverted ? 'text-background' : 'text-foreground' const mutedClass = inverted ? 'text-background/60' : 'text-muted-foreground' @@ -288,8 +292,8 @@ export function ProviderPanel({ if (!w) { return null } - // Why: show % used (consumption), not remaining — matches Claude/Codex - // harness meters and avoids the "full green bar = exhausted" misread (#7551). + // Why: preference changes the copy only; consumption-based bar direction + // preserves the empty/green to full/red meter convention from #8167. const usedPct = clampUsedPercent(w.usedPercent) const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null @@ -303,10 +307,7 @@ export function ProviderPanel({ />
- - {usedPct} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} - + {formatUsagePercentageLabel(usedPct, usagePercentageDisplay)} {resetLabel && {resetLabel}}
diff --git a/src/renderer/src/components/status-bar/usage-percentage-label.test.ts b/src/renderer/src/components/status-bar/usage-percentage-label.test.ts new file mode 100644 index 00000000000..e994bf6b034 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-percentage-label.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +import { formatUsagePercentageLabel } from './usage-percentage-label' + +describe('formatUsagePercentageLabel', () => { + it('formats used and remaining percentages without changing source semantics', () => { + expect(formatUsagePercentageLabel(8, 'used')).toBe('8% used') + expect(formatUsagePercentageLabel(8, 'remaining')).toBe('92% left') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-percentage-label.ts b/src/renderer/src/components/status-bar/usage-percentage-label.ts new file mode 100644 index 00000000000..395c6831bf4 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-percentage-label.ts @@ -0,0 +1,19 @@ +import { translate } from '@/i18n/i18n' +import { + getDisplayedUsagePercentage, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' + +export function formatUsagePercentageLabel( + usedPercent: number, + display: UsagePercentageDisplay +): string { + const percentage = getDisplayedUsagePercentage(usedPercent, display) + return display === 'used' + ? translate('auto.components.status.bar.usagePercentageLabel.used', '{{value0}}% used', { + value0: String(percentage) + }) + : translate('auto.components.status.bar.usagePercentageLabel.remaining', '{{value0}}% left', { + value0: String(percentage) + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d9e2d1e3bb8..c9c058e3276 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH Host" + }, + "usagePercentageLabel": { + "used": "{{value0}}% used", + "remaining": "{{value0}}% left" } } }, @@ -7238,7 +7242,9 @@ "f8e2a1c4b6": "Grok Usage", "e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "Usage percentages", + "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "Weekly credits", "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", "c6d1a8f4e2": "Resets {{when}}" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "Used", + "usagePercentageDisplayRemaining": "Remaining" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e30c7b66f9f..c84e4f354d9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "Host SSH" + }, + "usagePercentageLabel": { + "used": "{{value0}}% usado", + "remaining": "{{value0}}% restante" } } }, @@ -7201,7 +7205,9 @@ "f8e2a1c4b6": "Uso de Grok", "e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "Porcentajes de uso", + "usagePercentageDisplayDescription": "Elige si los límites del proveedor muestran el porcentaje usado o restante." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "Créditos semanales", "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", "c6d1a8f4e2": "Se restablece {{when}}" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "Usado", + "usagePercentageDisplayRemaining": "Restante" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1651fbb09d2..6d481080c55 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH ホスト" + }, + "usagePercentageLabel": { + "used": "{{value0}}% 使用済み", + "remaining": "残り {{value0}}%" } } }, @@ -7223,7 +7227,9 @@ "f8e2a1c4b6": "Grok の使用状況", "e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "使用率", + "usagePercentageDisplayDescription": "プロバイダーの制限を使用済みまたは残りの割合で表示するかを選択します。" } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "週次クレジット", "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", "c6d1a8f4e2": "{{when}} にリセット" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "使用済み", + "usagePercentageDisplayRemaining": "残り" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f5af8bbdb59..8e5fc8a4c5d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH 호스트" + }, + "usagePercentageLabel": { + "used": "{{value0}}% 사용", + "remaining": "{{value0}}% 남음" } } }, @@ -7186,7 +7190,9 @@ "f8e2a1c4b6": "Grok 사용량", "e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "사용량 백분율", + "usagePercentageDisplayDescription": "공급자 한도를 사용한 비율 또는 남은 비율로 표시할지 선택합니다." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "주간 크레딧", "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", "c6d1a8f4e2": "{{when}}에 재설정" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "사용", + "usagePercentageDisplayRemaining": "남음" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c4eb2fdbe8f..d4e2f36f918 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH 主机" + }, + "usagePercentageLabel": { + "used": "已用 {{value0}}%", + "remaining": "剩余 {{value0}}%" } } }, @@ -7186,7 +7190,9 @@ "f8e2a1c4b6": "Grok 使用量", "e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "用量百分比", + "usagePercentageDisplayDescription": "选择以已用或剩余百分比显示提供商限额。" } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "每周额度", "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "已用", + "usagePercentageDisplayRemaining": "剩余" } }, "right": { diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 0f88ef4ca52..9e2e82e0cb4 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1260,6 +1260,32 @@ describe('createUISlice hydratePersistedUI', () => { expect(setUI).not.toHaveBeenCalled() }) + it('persists and hydrates the usage percentage display preference', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setUsagePercentageDisplay('used') + + expect(store.getState().usagePercentageDisplay).toBe('used') + expect(setUI).toHaveBeenCalledWith({ usagePercentageDisplay: 'used' }) + + store.getState().hydratePersistedUI(makePersistedUI({ usagePercentageDisplay: 'remaining' })) + expect(store.getState().usagePercentageDisplay).toBe('remaining') + }) + + it('defaults invalid usage percentage display values to used', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + usagePercentageDisplay: 'left' as PersistedUIState['usagePercentageDisplay'] + }) + ) + + expect(store.getState().usagePercentageDisplay).toBe('used') + }) + it('clamps persisted workspace board column width', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 205d31878cc..a0f6aa0db73 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -29,6 +29,11 @@ import type { WorkspaceHostScope, VisibleWorkspaceHostIds } from '../../../../shared/types' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { + DEFAULT_USAGE_PERCENTAGE_DISPLAY, + normalizeUsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { TaskSourceContext } from '../../../../shared/task-source-context' @@ -869,6 +874,8 @@ export type UISlice = { toggleStatusBarItem: (item: StatusBarItem) => void statusBarVisible: boolean setStatusBarVisible: (v: boolean) => void + usagePercentageDisplay: UsagePercentageDisplay + setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void workspacePortScan: { key: string; result: WorkspacePortScanResult } | null workspacePortScansByKey: Record workspacePortScanRefreshing: boolean @@ -2112,6 +2119,12 @@ export const createUISlice: StateCreator = (set, get) window.api.ui.set({ statusBarVisible: v }).catch(console.error) set({ statusBarVisible: v }) }, + usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, + setUsagePercentageDisplay: (display) => { + const normalized = normalizeUsagePercentageDisplay(display) + window.api.ui.set({ usagePercentageDisplay: normalized }).catch(console.error) + set({ usagePercentageDisplay: normalized }) + }, workspacePortScan: null, workspacePortScansByKey: {}, workspacePortScanRefreshing: false, @@ -2400,6 +2413,7 @@ export const createUISlice: StateCreator = (set, get) syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, statusBarItems: statusBarItemsWithGrok, statusBarVisible: ui.statusBarVisible ?? true, + usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay), // Why: absent → true so existing users see the pet the first time // they enable the experimental flag. Only an explicit Hide pet // dismissal persists a `false` value. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index cd77c51acef..4e4b5502051 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -71,6 +71,7 @@ import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' import { normalizeUiLanguage } from '../../../shared/ui-language' +import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' import { @@ -3251,6 +3252,9 @@ function mergeWebUIState( safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, agentActivityDisplayMode: normalizeAgentActivityDisplayMode( safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode + ), + usagePercentageDisplay: normalizeUsagePercentageDisplay( + safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay ) } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f0b6c9dc04e..1a8a3f6ffe6 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -31,6 +31,7 @@ import { import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' +import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -495,6 +496,7 @@ export function getDefaultUIState(): PersistedUIState { _workspaceStatusesDefaultVisualsMigrated: true, statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], statusBarVisible: true, + usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, dismissedUpdateVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, diff --git a/src/shared/types.ts b/src/shared/types.ts index 24308e9327f..fedd1623e46 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,7 @@ import type { GlobalWindowsRuntimeDefault, LocalWindowsRuntimePreference } from './project-execution-runtime' +import type { UsagePercentageDisplay } from './usage-percentage-display' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -3311,6 +3312,8 @@ export type PersistedUIState = { _grokStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean + /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ + usagePercentageDisplay?: UsagePercentageDisplay dismissedUpdateVersion: string | null lastUpdateCheckAt: number | null pendingUpdateNudgeId?: string | null diff --git a/src/shared/usage-percentage-display.test.ts b/src/shared/usage-percentage-display.test.ts new file mode 100644 index 00000000000..feae8c4371d --- /dev/null +++ b/src/shared/usage-percentage-display.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + getDisplayedUsagePercentage, + normalizeUsagePercentageDisplay +} from './usage-percentage-display' + +describe('usage percentage display', () => { + it('defaults unknown persisted values to the current used-capacity behavior', () => { + expect(normalizeUsagePercentageDisplay(undefined)).toBe('used') + expect(normalizeUsagePercentageDisplay('left')).toBe('used') + }) + + it('shows either the provider value or its complement', () => { + expect(getDisplayedUsagePercentage(6, 'used')).toBe(6) + expect(getDisplayedUsagePercentage(6, 'remaining')).toBe(94) + }) + + it('rounds and bounds percentages for display', () => { + expect(getDisplayedUsagePercentage(20.5, 'used')).toBe(21) + expect(getDisplayedUsagePercentage(20.5, 'remaining')).toBe(80) + expect(getDisplayedUsagePercentage(120, 'remaining')).toBe(0) + expect(getDisplayedUsagePercentage(-20, 'used')).toBe(0) + expect(getDisplayedUsagePercentage(Number.NaN, 'remaining')).toBe(0) + }) +}) diff --git a/src/shared/usage-percentage-display.ts b/src/shared/usage-percentage-display.ts new file mode 100644 index 00000000000..ad2603df820 --- /dev/null +++ b/src/shared/usage-percentage-display.ts @@ -0,0 +1,21 @@ +export type UsagePercentageDisplay = 'used' | 'remaining' + +// Why: missing settings preserve the consumption-meter behavior introduced in #8167. +export const DEFAULT_USAGE_PERCENTAGE_DISPLAY: UsagePercentageDisplay = 'used' + +export function normalizeUsagePercentageDisplay(value: unknown): UsagePercentageDisplay { + return value === 'used' || value === 'remaining' ? value : DEFAULT_USAGE_PERCENTAGE_DISPLAY +} + +export function getDisplayedUsagePercentage( + usedPercent: number, + display: UsagePercentageDisplay +): number { + if (!Number.isFinite(usedPercent)) { + // Why: invalid provider data must not be presented as 100% remaining capacity. + return 0 + } + const boundedUsedPercent = Math.min(100, Math.max(0, usedPercent)) + const percentage = display === 'used' ? boundedUsedPercent : 100 - boundedUsedPercent + return Math.round(percentage) +}