diff --git a/app/renderer/lib/format.ts b/app/renderer/lib/format.ts index 597f7094..322fe9bb 100644 --- a/app/renderer/lib/format.ts +++ b/app/renderer/lib/format.ts @@ -1,4 +1,5 @@ import { localeTag, t } from '../i18n' +import type { MenubarPayload } from './types' export type ActiveCurrency = { code: string; symbol: string; rate: number } @@ -24,6 +25,16 @@ export function isEstimatedCost(cost: number, estimatedCost: number | undefined) return estimated > 0 && estimated >= cost * 0.01 && /[1-9]/.test(formatUsd(cost)) } +/** The estimated share of a payload's total: the provider split carries it on + * current CLIs; the model split is the fallback for a CLI that predates that. */ +export function estimatedPortion(current: MenubarPayload['current']): number { + const providers = current.providerDetails?.filter(row => !row.excludedFromTotal) + if (providers?.some(row => row.estimatedCostUSD !== undefined)) { + return providers.reduce((sum, row) => sum + (row.estimatedCostUSD ?? 0), 0) + } + return (current.topModels ?? []).reduce((sum, row) => sum + (row.estimatedCostUSD ?? 0), 0) +} + /** Raw-USD input formatted against an explicit payload currency. This keeps a * persisted exact snapshot correct on its very first paint, before App's * global active-currency effect has had a chance to run. */ diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index 1fbeeb41..8ec61af8 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -322,6 +322,23 @@ describe('Overview', () => { expect(ticks.at(-1)).toHaveTextContent(now.toLocaleString('en-US', { month: 'short', day: 'numeric' })) }) + it.each([ + ['marks', 12.4, '~$312.40'], + ['does not mark', 2, '$312.40'], + ])('%s the hero total by the shared 1%% estimated rule', async (_label, estimatedCostUSD, shown) => { + const payload = makePayload(new Date()) + payload.current.providerDetails = [ + { id: 'claude', label: 'Claude', cost: 300 - estimatedCostUSD }, + { id: 'cursor', label: 'Cursor', cost: 12.4 + estimatedCostUSD, estimatedCostUSD }, + { id: 'gateway', label: 'Gateway', cost: 50, estimatedCostUSD: 50, excludedFromTotal: true }, + ] + getOverview.mockResolvedValue(payload) + + const { container } = render() + + await waitFor(() => expect(container.querySelector('.ov-hero-num')).toHaveTextContent(new RegExp(`^${shown.replace(/[$.]/g, '\\$&')}$`))) + }) + it('keeps weekday labels, and month labels on one row at least three columns apart', async () => { const now = new Date() getOverview.mockResolvedValue(makePayload(now)) diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 6917eb37..2a937443 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -11,7 +11,7 @@ import { StaleBanner } from '../components/StaleBanner' import { DUR, motionEnabled, useBarGrowIn } from '../lib/motion' import { useOptimizeSnapshot } from '../hooks/useOptimizeSnapshot' import { type Polled, usePolled } from '../hooks/usePolled' -import { asOfLabel, formatCompact, formatCount, formatUsd, formatUsdWithCurrency, isEstimatedCost } from '../lib/format' +import { asOfLabel, estimatedPortion, formatCompact, formatCount, formatUsd, formatUsdWithCurrency, isEstimatedCost } from '../lib/format' import { Usd, sumTokens, tokensOf, useUsdPop } from '../components/Usd' import { codeburn } from '../lib/ipc' import { @@ -598,8 +598,9 @@ function streakDays(daily: DailyHistoryEntry[], now: Date): number { * changes (a user action), but never on the 30s poll: a value that arrives * under the same `animateKey` snaps in place instead of re-animating. */ -function CountUp({ value, tokens, animateKey, animate = true }: { value: number; tokens?: ReturnType; animateKey: string; animate?: boolean }) { - const pop = useUsdPop(tokens) +function CountUp({ value, tokens, animateKey, animate = true, estimated = false }: { value: number; tokens?: ReturnType; animateKey: string; animate?: boolean; estimated?: boolean }) { + const pop = useUsdPop(tokens, false, estimated) + const mark = estimated ? '~' : '' const ref = pop.ref const keyRef = useRef(null) @@ -609,7 +610,7 @@ function CountUp({ value, tokens, animateKey, animate = true }: { value: number; const keyChanged = keyRef.current !== animateKey keyRef.current = animateKey if (!animate || !keyChanged || !motionEnabled()) { - element.textContent = formatUsd(value) + element.textContent = mark + formatUsd(value) return } const counter = { n: 0 } @@ -617,14 +618,14 @@ function CountUp({ value, tokens, animateKey, animate = true }: { value: number; n: value, duration: 0.7, ease: 'power2.out', - onUpdate: () => { element.textContent = formatUsd(counter.n) }, + onUpdate: () => { element.textContent = mark + formatUsd(counter.n) }, }) return () => { tween.kill() } - }, [value, animateKey, animate]) + }, [value, animateKey, animate, mark]) return ( <> -
{formatUsd(value)}
+
{mark}{formatUsd(value)}
{pop.pop} ) @@ -1098,6 +1099,10 @@ export function OverviewContent({ const useGeneration = headline != null && genModels != null const heroCost = combined ? combined.combined.cost : useGeneration ? headline.cost : data.current.cost const heroCalls = combined ? combined.combined.calls : useGeneration ? headline.calls : data.current.calls + // The combined aggregate carries no estimated split, so it is never marked. + const heroEstimated = !combined && isEstimatedCost(heroCost, useGeneration + ? genModels.reduce((sum, model) => sum + (model.estimatedCostUSD ?? 0), 0) + : estimatedPortion(data.current)) const heroSessions = combined ? combined.combined.sessions : data.current.sessions const heroSessionLabel = combined ? formatCombinedSessionCount() @@ -1200,7 +1205,7 @@ export function OverviewContent({ {/* A returning launch already showed a truthful persisted headline. Replaying the live hero from $0 on handoff makes that exact value appear to collapse and recover; snap to the revalidated total. */} - +
{formatCount(heroCalls, 'call')} · {heroSessionLabel}
{(provider === 'cursor' || provider === 'cursor-agent') && } {combined diff --git a/app/renderer/vscode/summary.ts b/app/renderer/vscode/summary.ts index 93cd5290..b088f920 100644 --- a/app/renderer/vscode/summary.ts +++ b/app/renderer/vscode/summary.ts @@ -1,5 +1,5 @@ import { t } from '../i18n' -import { formatCount, formatResetTime, formatUsd, isEstimatedCost, setActiveCurrency, type ActiveCurrency } from '../lib/format' +import { estimatedPortion, formatCount, formatResetTime, formatUsd, isEstimatedCost, setActiveCurrency, type ActiveCurrency } from '../lib/format' import { PROVIDER_NAMES } from '../lib/providers' import type { MenubarPayload, ProviderName, QuotaProvider } from '../lib/types' @@ -24,16 +24,6 @@ export type Summary = { const USD: ActiveCurrency = { code: 'USD', symbol: '$', rate: 1 } -/** The estimated share of a payload's total: the provider split carries it on - * current CLIs; the model split is the fallback for a CLI that predates that. */ -function estimatedPortion(current: MenubarPayload['current']): number { - const providers = current.providerDetails?.filter(row => !row.excludedFromTotal) - if (providers?.some(row => row.estimatedCostUSD !== undefined)) { - return providers.reduce((sum, row) => sum + (row.estimatedCostUSD ?? 0), 0) - } - return (current.topModels ?? []).reduce((sum, row) => sum + (row.estimatedCostUSD ?? 0), 0) -} - /** The #1657 rule, as every other surface applies it. Needs the payload's currency active. */ export function figureOf(payload: MenubarPayload): Figure { return { cost: payload.current.cost, estimated: isEstimatedCost(payload.current.cost, estimatedPortion(payload.current)) } diff --git a/gnome/indicator.js b/gnome/indicator.js index d11c9b90..bad6f151 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -99,6 +99,13 @@ function formatCost(value, currency, rate = 1, exact = false) { return `${symbol}${parts.join('.')}`; } +// Same rule as the CLI (src/format.ts isEstimatedCost): `~` once the estimated +// portion is at least 1% of the figure, unless `shown` reads as zero. +function isEstimatedCost(cost, estimatedCostUSD, shown) { + const estimated = Number(estimatedCostUSD) || 0; + return estimated > 0 && estimated >= cost * 0.01 && /[1-9]/.test(shown); +} + function formatTokensCompact(n) { const v = Number(n) || 0; if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`; @@ -795,7 +802,10 @@ class CodeBurnIndicator extends PanelMenu.Button { this._panelLabel.set_text(this._fmt(cost)); this._heroLabel.set_text(combined ? `Combined · ${current.label || ''}` : (current.label || '')); - this._heroAmount.set_text(this._fmt(cost)); + // Combined totals carry no estimated split, so only a local figure is marked. + const estimated = !combined && current === payload?.current + && isEstimatedCost(cost, (current.providerDetails ?? []).reduce((sum, d) => sum + numberValue(d?.estimatedCostUSD), 0), this._fmt(cost)); + this._heroAmount.set_text(`${estimated ? '~' : ''}${this._fmt(cost)}`); const calls = Number(combined?.calls ?? current.calls ?? 0); const sessions = Number(combined?.sessions ?? current.sessions ?? 0); diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index 6109ba1a..80ffb5ad 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -104,7 +104,11 @@ struct HeroSection: View { if total >= 1_000 { return String(format: "%.0fK tok", total / 1_000) } return String(format: "%.0f tok", total) } - return totals.cost.asCurrency() + let shown = totals.cost.asCurrency() + // The combined aggregate carries no estimated split, so it is never marked. + guard combinedUsage == nil else { return shown } + let estimated = store.payload.current.providerDetails.reduce(0.0) { $0 + ($1.estimatedCostUSD ?? 0) } + return (isEstimatedCost(totals.cost, estimated, shown: shown) ? "~" : "") + shown } private var combinedUsage: CombinedUsage? { diff --git a/windows/src/App.tsx b/windows/src/App.tsx index b8185569..84e1e85b 100644 --- a/windows/src/App.tsx +++ b/windows/src/App.tsx @@ -4,7 +4,7 @@ import { listen } from '@tauri-apps/api/event' import type { MenubarPayload } from './lib/payload' import type { CurrencyState } from './lib/currency' -import { USD, formatCurrency, formatTokens, trayBadgeText } from './lib/currency' +import { USD, formatCurrency, formatTokens, isEstimatedCost, trayBadgeText } from './lib/currency' import { PayloadCache, sameSelection, selectionKey, type Selection } from './lib/cache' import { relativePast } from './lib/dates' import { applyTheme, readSetting, writeSetting } from './lib/settings' @@ -423,9 +423,11 @@ export function App() { const isTokenMetric = settings.metric === 'tokens' || settings.metric === 'totalTokens' // The 16 px badge bitmap has room for about four glyphs, so the mac's up/down token pair // cannot fit; both token metrics show the total there and the hero carries the split. + const trayEstimated = trayCombined === null && trayCost !== null + && isEstimatedCost(trayCost, (trayCurrent?.providerDetails ?? []).reduce((sum, d) => sum + (d.estimatedCostUSD ?? 0), 0), formatCurrency(trayCost, currency)) const trayFigure = isTokenMetric ? (trayTokens === null ? null : `${formatTokens(trayTokens)} tok`) - : (trayCost === null ? null : formatCurrency(trayCost, currency)) + : (trayCost === null ? null : `${trayEstimated ? '~' : ''}${formatCurrency(trayCost, currency)}`) // Combined scope with a paired device that did not report this cycle: the figure is short // of what these machines actually spent, and the mac says so beside its title with a dimmed // "reachable/total". A 16 px bitmap has no room for that, so the badge is drawn dimmed and diff --git a/windows/src/components/HeroSection.tsx b/windows/src/components/HeroSection.tsx index 4baacc8b..ee08194e 100644 --- a/windows/src/components/HeroSection.tsx +++ b/windows/src/components/HeroSection.tsx @@ -1,6 +1,6 @@ import type { CombinedUsage, CursorSyncStatus, MenubarPayload } from '../lib/payload' import type { CurrencyState } from '../lib/currency' -import { formatCurrency, formatTokens } from '../lib/currency' +import { formatCurrency, formatTokens, isEstimatedCost } from '../lib/currency' import { prettyDate, relativePast, todayKey } from '../lib/dates' import { SectionCaption } from './CollapsibleSection' import { ArrowDownRight, ArrowUpRight, LeafIcon, MonitorIcon, WarningIcon } from './Icons' @@ -50,9 +50,12 @@ export function HeroSection({ payload, currency, periodLabel, isToday, dailyBudg // Both token metrics put the total in the headline, as the mac's heroText does; only the // Tokens metric replaces calls and sessions with the up and down split. const isTokenMetric = metric === 'tokens' || metric === 'totalTokens' + const shownCost = formatCurrency(cost, currency) + // The combined aggregate carries no estimated split, so it is never marked. + const estimated = !totals && isEstimatedCost(cost, (payload?.current.providerDetails ?? []).reduce((sum, d) => sum + (d.estimatedCostUSD ?? 0), 0), shownCost) const headline = isTokenMetric ? `${formatTokens(inputTokens + outputTokens)} tok` - : formatCurrency(cost, currency) + : `${estimated ? '~' : ''}${shownCost}` const label = payload?.current.label || periodLabel const caption = combined ? `Combined · ${label}` : isToday ? `Today · ${todayLabel}` : label