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>
This commit is contained in:
Marius
2026-07-11 13:45:52 -07:00
committed by GitHub
co-authored by gatsby74
parent 19102355d7
commit 6d3ab9f971
25 changed files with 423 additions and 56 deletions
+7
View File
@@ -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
),
@@ -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(),
@@ -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<HTMLButtonElement>('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 = [
{
@@ -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({
<SettingsRow label={statusBarTitle} description={statusBarDescription} control={null} />
{showStatusBarControls ? (
<div className="ml-4 divide-y divide-border/40 border-t border-border/40">
<SearchableSetting
title={usagePercentageDisplayEntry.title}
description={usagePercentageDisplayEntry.description}
keywords={usagePercentageDisplayEntry.keywords}
>
<SettingsRow
label={usagePercentageDisplayEntry.title}
description={usagePercentageDisplayEntry.description}
control={
<SettingsSegmentedControl
ariaLabel={usagePercentageDisplayEntry.title}
value={usagePercentageDisplay}
onChange={setUsagePercentageDisplay}
options={[
{
value: 'used',
label: translate(
'auto.components.settings.AppearanceWindowSidebarSection.usagePercentageDisplayUsed',
'Used'
)
},
{
value: 'remaining',
label: translate(
'auto.components.settings.AppearanceWindowSidebarSection.usagePercentageDisplayRemaining',
'Remaining'
)
}
]}
/>
}
/>
</SearchableSetting>
{visibleStatusBarToggles.map((toggle) => {
const enabled = statusBarItems.includes(toggle.id)
return (
@@ -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 }
@@ -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')
]
}))
@@ -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({
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{window.used}
{usedSuffix} {window.label}
{formatUsagePercentageLabel(window.used, display)} {window.label}
</span>
</div>
))}
@@ -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 (
<span className="tabular-nums">
{used}
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} {label}
{formatUsagePercentageLabel(w.usedPercent, display)} {label}
</span>
)
}
@@ -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 (
<span className="inline-flex items-center gap-1.5">
<ProviderIcon provider={provider} />
{visibleBuckets.map((bucket, i) => {
const used = clampUsedPercent(bucket.usedPercent)
return (
<React.Fragment key={bucket.name}>
{i > 0 && <span className="text-muted-foreground">·</span>}
<span className="tabular-nums">
{bucket.name} {used}
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')}
</span>
</React.Fragment>
)
})}
{visibleBuckets.map((bucket, i) => (
<React.Fragment key={bucket.name}>
{i > 0 && <span className="text-muted-foreground">·</span>}
<span className="tabular-nums">
{bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)}
</span>
</React.Fragment>
))}
{visibleBuckets.length === 0 && p.session && (
<WindowLabel w={p.session} label={formatWindowLabel(p.session.windowMinutes)} />
<WindowLabel
w={p.session}
label={formatWindowLabel(p.session.windowMinutes)}
display={display}
/>
)}
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
</span>
@@ -1211,7 +1224,7 @@ function ProviderSegment({
{visibleWindows.map((window, index) => (
<React.Fragment key={window.key}>
{index > 0 && <span className="text-muted-foreground">·</span>}
<WindowLabel w={window.window} label={window.label} />
<WindowLabel w={window.window} label={window.label} display={display} />
</React.Fragment>
))}
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
@@ -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({
</span>
</span>
) : (
<ProviderSegment p={provider} compact={compact} />
<ProviderSegment p={provider} compact={compact} display={usagePercentageDisplay} />
)}
</button>
</DropdownMenuTrigger>
@@ -1812,7 +1828,11 @@ export function ProviderDetailsMenu({
{topContent}
<div className="p-2">
{/* Why: provider-specific action sections may render richer reset-credit UI. */}
<ProviderPanel p={provider} showResetCredits={!hidePanelResetCredits} />
<ProviderPanel
p={provider}
showResetCredits={!hidePanelResetCredits}
usagePercentageDisplay={usagePercentageDisplay}
/>
</div>
{children ? (
<>
@@ -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<string, string>) => {
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(
<InlineUsageBars limits={claudeLimits()} isFetching={false} />
)
expect(markup).toContain('68% left 5h')
expect(markup).toContain('84% left wk')
expect(markup).toContain('58% left Fable')
expect(markup).toContain('width:32%')
})
})
@@ -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<typeof import('react')>('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)({
@@ -45,6 +45,17 @@ function provider(overrides: Partial<ProviderRateLimits> = {}): 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', () => {
@@ -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({
/>
</div>
<div className={`flex justify-between ${mutedClass}`}>
<span>
{usedPct}
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')}
</span>
<span>{formatUsagePercentageLabel(usedPct, usagePercentageDisplay)}</span>
{resetLabel && <span>{resetLabel}</span>}
</div>
</div>
@@ -0,0 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
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')
})
})
@@ -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)
})
}
+11 -1
View File
@@ -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": {
+11 -1
View File
@@ -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": {
+11 -1
View File
@@ -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": {
+11 -1
View File
@@ -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": {
+11 -1
View File
@@ -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": {
+26
View File
@@ -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()
+14
View File
@@ -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<string, WorkspacePortScanResult>
workspacePortScanRefreshing: boolean
@@ -2112,6 +2119,12 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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.
+4
View File
@@ -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
)
}
}
+2
View File
@@ -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: {},
+3
View File
@@ -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
@@ -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)
})
})
+21
View File
@@ -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)
}