Skip to content

Show the provider cards in the menu, with a Detail switch per card - #346

Closed
ozgurasan-agent wants to merge 8 commits into
vinzdg:mainfrom
ozgurasan-agent:codex/menu-usage-cards
Closed

ozgurasan-agent wants to merge 8 commits into
vinzdg:mainfrom
ozgurasan-agent:codex/menu-usage-cards

Conversation

@ozgurasan-agent

@ozgurasan-agent ozgurasan-agent commented Sep 23, 2026 •

Copy link
Copy Markdown

Summary

The menu bar's menu becomes the detailed view. It opens straight onto one card per provider, each with its own Detail switch. Refresh all now refreshes those cards without closing the menu, and a single Claude account is named just "Claude" instead of "Claude Gmail".

Stacked on #313, which is still open. This branch carries #313's commit (0b2a6de), so until #313 merges, review only the commits after it: a92ad31 (the cards), e5e42ca (the switch fix), f33d60e (the Claude name), ca6d274 (Refresh all) and the two merges of main.

Changes

Provider cards in the menu (a92ad31, e5e42ca)

  • One card per provider. The card is the same ProviderLimitsContent the notch's tooltip uses, drawn on a plain rounded surface (MenuUsageCard).
  • Detail switch per card. Off shows the limits only. On shows everything the notch's tooltip carries (sessions, reset credits, Codex usage, DeepSeek detail), and the card grows downward. The choice is saved per provider and survives a relaunch.
  • Reset times. A reset also shows when it happens, e.g. Resets in 4h 55m (13:30) or (Thu 01:30). A card shows the time only when every row on it has room, so no card mixes rows with and without it.
  • Layout fixes found on screen:
    • Usage lines of different sizes. When the card was given less height than it needed, SwiftUI shrank the only text allowed to shrink: the usage line's minimumScaleFactor. The card now always lays out at its full height.
    • The switch moved when flipped. The card was centred in the item's whole-point rounding, which differs open vs. closed. It is now pinned to the top of the item.
    • The switch sat lower under a tier line. It now sits on the title's line, so a tier such as Codex plus no longer pushes it down.
    • The switch slid into place when Detail was turned on. For one layout pass an open card sat in a closed card's frame and was centred there, and .animation(_:value:) animated the track's position. The root frame now has minimums too, and the animation is scoped with animation(_:body:) to the accent's opacity and the knob's offset.

Refresh all keeps the menu open (ca6d274)

  • AppKit closes a menu on mouse-up and only then sends the action, and nothing public keeps a plain item's menu open. So Refresh all is now a row with a view of its own (MenuCommandRowView), as the cards' Detail switch is, and it takes the click itself.
  • While the pass runs, the row reads "Refreshing…" in a disabled row's ink and ignores clicks. Each card redraws as its reading lands, and the row returns to "Refresh all" when the pass ends.
  • UsageStore.refreshNow() now returns the pass it starts, or the one already running. It is @discardableResult, so its other callers are unchanged. The wait ends with the menu, so a pass stuck behind an unanswered keychain prompt cannot leave later menus saying "Refreshing…".
  • A row with a view gets nothing drawn for it, so the row draws what AppKit would, measured off AppKit's own rows in the same menu at 2x on macOS 27:
    • the title at 30pt, and the ⌘R column;
    • labelColor for the title and tertiaryLabelColor for the shortcut;
    • the highlight: the system's .selection material with a 5pt inset and 7pt corners. It matched AppKit's own highlight to the pixel (colour, box and corner curve) and follows the accent colour and appearance. The menu delegate's willHighlight tells the row when it is highlighted.
  • Keyboard. AppKit hands Return to a highlighted row with a view instead of acting on it, and only to a view that takes first responder. The row takes first responder only while highlighted, so it does not grab the keyboard as the menu opens, and it answers Return the way it answers a click (menu stays open). ⌘R still matches the item's key equivalent. Escape, if it reaches the row, cancels tracking.
  • "Refreshing…" is translated for all 12 locales.

A lone Claude account is "Claude" (f33d60e)

  • With one Claude Code login, the card read "Claude Gmail Usage". The label comes from the domain of the signed-in address, and it exists to tell two accounts apart.
  • ClaudeProfile.displayNames(for:) now names a lone profile plain "Claude". Two or more profiles keep their labels, and the full address where two labels collide, exactly as before. The name is set in one place, so the menu, the notch, Settings and notifications all change together.

Merged with main (568dbc8)

main had moved on by 36 commits, through 1.18.0. Two files conflicted:

  • TooltipCard.swift. main added a continuous colour ramp for the limit and money bars (barColor), and renamed the reset-credit section to UsageResetCreditsSection. This branch had moved the tooltip's rows into ProviderDetailContent, which the menu's cards share, and measures bars against the surface's own column (providerDetailWidth). Both are kept: main's barColor with this branch's track width, and the reset credits drawn from ProviderDetailContent with main's availableResetCredits(at:). An opened menu card now shows reset credits just as the notch does.
  • Localizable.xcstrings. The resolution is main's file byte-for-byte, plus this branch's four keys. Those keys now also carry Turkish and Indonesian, the two locales main added. The only key both sides touched is the old reset-time explanation, which this branch had replaced; nothing reads it any more, so it stays retired.

Also in this branch, and separable

StatusItemSummary.swift and its test carry a change that is not part of the menu cards: the menu bar readout no longer reserves a fixed width for its figures, so the item follows the width of what it currently shows. testTheItemKeepsOneWidthAsTheFiguresMove is replaced by testTheItemFollowsTheCurrentFigureWidths. It is independent of everything above, and I'm happy to lift it into its own PR.

Test Plan

  • Run test suite (make test-ci): 1965 tests, 0 failures, 5 skipped (the opt-in live checks), on the merged branch. make test gives the same result.
  • Tested on macOS (version: 27.0)
  • UI / Notch interactions verified

New tests:

  • testALoneAccountIsJustClaude
  • testRefreshAllRunsWithTheMenuStillOpenAndSaysSoUntilItIsDone
  • testANextMenuDoesNotInheritAPassThatNeverEnded
  • testASecondCallerIsHandedThePassAlreadyRunning
  • testReturnOnTheHighlightedRowRefreshesAndIsPassedOnOtherwise
  • testEscapeOnTheRowStillClosesTheMenu
  • testCommandRStillRefreshes
  • From the card work: testTheSwitchDoesNotMoveWhenItIsFlipped and testAnOpenedCardInTheClosedFrameKeepsItsHeaderInPlace. The second fails against the old root frame (22,361 differing pixels).

Checked on screen, in Light mode:

  • Cards appear straight away. The Claude card is titled "Claude Usage", and Settings lists the account as "Claude".
  • Clicking Refresh all keeps the menu open, shows "Refreshing…", and returns to "Refresh all" when the pass ends, with the cards updated in place.
  • The hover highlight follows the pointer onto and off the row from above and below.
  • Return on the highlighted row refreshes and keeps the menu open. Return on Settings… still opens Settings, and the arrow keys still move through the menu.
  • The row's text, shortcut and highlight were compared with AppKit's own rows in the same menu by pixel measurement.
  • The merged build was re-checked on screen: the cards, the hover highlight, and a click on Refresh all.
  • From the card work: the switch's track stays in the same box across 130 frames captured while it was flipped, and the usage lines are all the same height.

Not checked on screen:

  • Escape and ⌘R. The automation's synthetic keys never reached the open menu, not even on AppKit's own rows. Both are covered by the unit tests above; worth one manual try.
  • Dark mode for the new row. The system was in Light mode. Its colours and material are system-provided, and the cards were checked in dark offscreen.
  • After a click, AppKit briefly drops the row's highlight; it comes back as soon as the pointer moves.

Screenshots / Screen Recordings

The menu was captured on screen while testing. Screenshots can't be attached from the command line; I can add them here if that helps review.

Özgür Asan added 2 commits September 22, 2026 14:14
The menu bar's menu now opens straight onto a card per provider: the same
ProviderLimitsContent the notch's tooltip starts from, on a plain rounded
surface. Each card has a Detail switch in its header; closed it is the
limits, open it is everything the notch's tooltip carries, and the card
grows downward to hold it. The choice is per provider and survives a
relaunch. Resets also say when they land, "(13:30)" or "(Thu 01:30)", on
any card whose every row has room for it.

The card always lays out at the height its content asks for and is pinned
to the top of its menu item. Offered less, SwiftUI squeezed the one thing
that gives — a usage line's minimumScaleFactor — so one "% Used · % left"
came out smaller than the one under it. Centred in an item whose frame is
its fitting height rounded up, the header moved by a different fraction
open than closed, taking the switch with it. The switch sits on the title's
line, so a tier named under the title no longer pushes it down.
The only conflict was the string catalog, where both sides had appended
keys: upstream added 36, this branch added three and retired one — the
"Time remaining" explanation, which now describes the reset time the
row carries beside the countdown. No key was touched by both sides, so
the resolution is upstream's file with this branch's own four changes
applied to it and every upstream entry left byte-for-byte as it was.
@vinzdg

vinzdg commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Thanks @ozgurasan-agent. I want to be fair here: this is not #301 coming back, and I checked the specific things I closed that one over. ResetCopy.swift:110-116 correctly uses setLocalizedDateFormatFromTemplate("j:mm")/"E j:mm" — no h, no fixed "EEE HH:mm", and format: is still threaded. The global Reset time picker stays in Settings. There's no ImageRenderer in Sources/. The catalog is +195/−65 in place with no key reorder and the one specifier key matching across all ten locales. You took the feedback seriously and it shows.

But it carries #301's actual cost in a new shape, and there's a second thing I can't take.

Blocker — SwiftUI layout per provider, every second, with the menu closed. builtMenu is set in rebuild (StatusItemController.swift:347) and never cleared — menuDidClose only invalidates the timer (:320-323). Meanwhile snapshots.didSet calls redrawOpenCards unconditionally (:51-58), which per card does hosting.rootView = … plus fittingSize (:520-538, MenuUsageCard.swift:224-228) — a forced layout. A local runtime republishes every second (:88-89). So after the menu is opened once, it's N main-thread layout passes per second for the rest of the session. One line fixes it: nil builtMenu in menuDidClose.

Blocker — it deletes the fixed-width menu bar item. StatusItemSummary.swift drops percentRoom/countdownRoom, and StatusItemSummaryTests.swift:432 renames testTheItemKeepsOneWidthAsTheFiguresMove to …FollowsTheCurrentFigureWidths and inverts it to assert the width now varies, dropping the XCTAssertLessThan(width(0.72, nil), 150, …) bound.

That's the same change I closed #314 for two days ago, and the reason stands: an item that resizes as its numbers change pushes every item to its left along with it, so 1h 00m → 59m reflows the whole menu bar about once a minute. Nothing in "show provider cards" requires it — please drop that part and keep the reservation.

Two more:

  • The Detail switch looks dead on first open. setSwitchFrame scans builtMenu.items by representedObject (:507-511), but the preference fires inside MenuUsageCardItem.make (MenuUsageCard.swift:238) before representedObject is assigned (:437-444) — so interactiveRect stays .zero and mouseUp rejects every click (:277-281). Worth testing by hand on a fresh launch.
  • The 30s menu clock writes model?.now (:334-337), which republishes to every notch while a menu is open. The timer itself is correctly gated and invalidated.

Nit: MenuUsageCardTests.swift:179-197 sets watchLimit: 0.3 then asserts menuModel.watchLimit == 0.3 — it can't fail. The rest of the new tests are genuine render assertions.

Fix the two blockers and I'll take it; the feature itself is good and the card rendering is nicely done. Note main has moved a lot today — #336 added a weekly-headline preference and #343 renamed the second ring — so you'll want a rebase.

Özgür Asan added 5 commits September 24, 2026 08:08
On screen, turning Detail on slid the switch's track up into place from
below while the word beside it stood still. The offscreen checks could
not see it, because it happens between two layouts.

The item is handed the new card before it is re-measured, so for one
pass an open card is laid out in a closed card's frame. With only a
maximum on the root frame, a card taller than its frame kept its own
height and was centred on it, putting the header half the growth too
high. The track carried `.animation(_:value:)`, which animates its
position as well as its colour and knob, so it glided back from there.

The root frame now has minimums too, so it is always the item's size and
a card that does not fit yet hangs off the bottom until the item grows.
The switch's animation is scoped with `animation(_:body:)` to the
accent's opacity and the knob's offset, so nothing about the track's
geometry is ever animated.

testAnOpenedCardInTheClosedFrameKeepsItsHeaderInPlace draws the header
in that in-between state; against the old frame it differs from the
settled header in 22,361 pixels.
With one Claude Code login the menu card read "Claude Gmail Usage": the
account label, taken from the signed-in address's domain, was added to
every Claude ring whether or not there was another ring to tell it from.
The label exists to tell two accounts apart; with only one it names a
product that does not exist.

displayNames(for:) is the one caller that sees every profile, so it now
names a lone profile plain "Claude". Two or more profiles keep their
labels, and the whole address where two labels collide, exactly as
before. The name reaches the menu, the notch, Settings and notifications
from there, so all of them change together.
Clicking Refresh all took the menu away, and with it the cards the
refresh was for. AppKit ends a menu's tracking on mouse-up and only then
sends the action, and nothing public keeps a plain item's menu open.

The row is now a view of its own, as the cards' Detail switch is, so it
takes the click and the menu stays up. While the pass runs the row reads
"Refreshing…" in a disabled row's ink and ignores clicks; each card
redraws as its reading lands. refreshNow() hands back the pass, or the
one already running, so the row knows when it is over. The wait ends
with the menu, so a pass stuck behind an unanswered keychain prompt
cannot leave every later menu saying "Refreshing…".

A row with a view gets nothing drawn for it, so MenuCommandRowView puts
it back, measured off AppKit's own rows in the same menu at 2x on macOS
27: the title at 30pt, the shortcut's column, labelColor and
tertiaryLabelColor ink, and the highlight. The highlight is the
system's .selection material with a 5pt inset and 7pt corners, which
matches AppKit's own to the pixel and follows the accent colour and the
appearance. The menu's delegate tells the row when it is highlighted,
because AppKit tracks that for a row with a view but draws nothing.

AppKit hands Return to a highlighted row with a view instead of acting
on it, and only to a view that takes first responder. The row takes it
only while highlighted — taking it always made the row grab the
keyboard as the menu opened and draw itself highlighted — and answers
Return the way it answers a click. ⌘R still matches the item's key
equivalent, and Escape, should it reach the row, cancels tracking.

"Refreshing…" is translated for every locale Refresh all is.
Two files conflicted.

TooltipCard.swift: main ramps the limit and money bars' colour across
the whole range, and renamed the Codex reset-credit section to
UsageResetCreditsSection, now drawn for Claude too. This branch had
moved the tooltip's rows into ProviderDetailContent, which the menu's
cards share, and measures a bar against the surface's own column
(providerDetailWidth). Both are kept: main's barColor with the branch's
track width, and the reset credits drawn from ProviderDetailContent with
main's availableResetCredits(at:), so an opened menu card shows them as
the notch does.

The string catalog: main made 889 changes, among them two new locales,
Turkish and Indonesian; this branch added four keys and retired one. No
key was changed by both sides except the retired one, "Time until usage
resets, such as 3 Days 3h or 3h 20m.", which main gave Turkish and
Indonesian; nothing reads it any more, so it stays retired. The
resolution is main's file byte-for-byte with the branch's four keys
added, each now carrying Turkish and Indonesian as well.
refreshNow() now hands back the pass it starts, and the wake and language
observers call it as the single expression of MainActor.assumeIsolated.
That closure's result type is generic: newer compilers settle it to
Void and drop the pass, but the Xcode 26.6 toolchain CI builds with
infers it from both sides and fails to type-check —

    conflicting arguments to generic parameter 'T'
    ('Void' vs. 'Task<Void, Never>?')

— so neither the unit tests nor the unsigned bundle built. The
observers have no use for the pass, so they now discard it outright.
@ozgurasan-agent

Copy link
Copy Markdown
Author

Superseded by #358, which carries this same feature with every review finding from this thread addressed (menu layout on close, menu-bar width consistency, switch frame reporting) plus the refreshNow() compilation fix that was failing CI here. Closing in favour of that one — please continue the review there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants