Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@

### Fixed

### Fixed
- Menu: let compact metric detail and reset rows wrap to a second line instead of truncating, so non-English locales keep the full pace and reset information (refs #2182).

## 0.48.0 — 2026-08-06

### Added
Expand Down
6 changes: 4 additions & 2 deletions Sources/CodexBar/MenuCardHeightFingerprint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ extension UsageMenuCardView.Model.Metric {
return MenuCardHeightFingerprint.join([
self.id,
self.statusText == nil ? "status=0" : "status=1",
presentation.resetText == nil ? "reset=0" : "reset=1",
presentation.metaText == nil ? "meta=0" : "meta=1",
// Reset and meta rows may wrap to a second line, so their text
// content (not just presence) can change the measured card height.
MenuCardHeightFingerprint.field("reset", presentation.resetText),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the title width in the wrapping-row fingerprint

When a stable reset label sits near the wrapping threshold, a width-changing title update—such as 9% left becoming 100% left, or a longer dynamic window title—reduces the space offered to the reset text in the same HStack and can change it from one line to two. This fingerprint still hashes only reset/meta content and intentionally ignores percentage changes, so the old one-line cached height is reused and the new second line can be clipped. Fresh evidence in this revision is that reset wrapping was introduced while the cache key still omits presentation.titleText; include that width-affecting text in the fingerprint as well.

Useful? React with 👍 / 👎.

MenuCardHeightFingerprint.field("meta", presentation.metaText),
self.detailText == nil ? "detail=0" : "detail=1",
self.pacePercent == nil ? "pace=0" : "pace=1",
self.paceOnTop ? "paceTop=1" : "paceTop=0",
Expand Down
7 changes: 5 additions & 2 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,9 @@ private struct MetricRow: View {
Text(resetText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
.lineLimit(2)
.multilineTextAlignment(.trailing)
.fixedSize(horizontal: false, vertical: true)
}
}
UsageProgressBar(
Expand All @@ -554,8 +556,9 @@ private struct MetricRow: View {
Text(metaText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
.lineLimit(2)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
Comment on lines +559 to +561

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include wrapped metric text in the height-cache fingerprint

When an already-populated metric changes from a short countdown or pace string to a longer one—for example, when a forecast is appended after refresh—these lines now change the card's height. However, Metric.heightFingerprint in MenuCardHeightFingerprint.swift records only whether reset/meta text is present, so makeMenuCardItem can reuse the old measured frame for the same scope and width; the newly added second line is then clipped, defeating this fix until another fingerprint field changes. Include the reset/meta text shape or content in the metric height fingerprint now that these fields can wrap.

Useful? React with 👍 / 👎.

}
if let detail = self.metric.detailText {
Text(detail)
Expand Down
27 changes: 24 additions & 3 deletions Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct MenuCardHeightFingerprintTests {
}

@Test
func `height fingerprint tracks optional metric rows rather than their text`() {
func `height fingerprint tracks wrapping metric text content`() {
let bare = Self.model(statusText: nil).heightFingerprint(section: "card")
let withReset = Self.model(statusText: nil, resetText: "Resets in 2h").heightFingerprint(section: "card")
let withMeta = Self.model(statusText: nil, detailLeftText: "20% in reserve")
Expand All @@ -52,8 +52,29 @@ struct MenuCardHeightFingerprintTests {

#expect(bare != withReset)
#expect(bare != withMeta)
#expect(withMeta == withChangedMeta)
#expect(withMeta == withFoldedForecast)
// Meta and reset rows can wrap to a second line, so any text change
// must invalidate the cached height instead of reusing a short frame.
#expect(withMeta != withChangedMeta)
#expect(withMeta != withFoldedForecast)
}

@Test
func `height fingerprint invalidates when wrapping text grows from short to long`() {
let shortReset = Self.model(statusText: nil, resetText: "Resets in 2h")
.heightFingerprint(section: "card")
let longReset = Self.model(
statusText: nil,
resetText: "Resets tomorrow at 6:00 AM after a very long localized description")
.heightFingerprint(section: "card")
let shortMeta = Self.model(statusText: nil, detailLeftText: "On pace")
.heightFingerprint(section: "card")
let longMeta = Self.model(
statusText: nil,
detailLeftText: "Estimated 2 session quotas left with a generously long localized pace explanation")
.heightFingerprint(section: "card")

#expect(shortReset != longReset)
#expect(shortMeta != longMeta)
}

@Test
Expand Down
22 changes: 11 additions & 11 deletions Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1942,47 +1942,47 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 635,
line: 638,
anchor: "guard self.model.provider == .doubao else { return nil }",
expectedProviderIDs: ["doubao"],
expectedReferenceCount: 1,
expectedReferenceFingerprint: ["doubao@0"],
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1062,
line: 1065,
anchor: "if provider == .kiro,",
expectedProviderIDs: ["kilo", "kiro"],
expectedReferenceCount: 2,
expectedReferenceFingerprint: ["kiro@0", "kilo@5"],
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1085,
line: 1088,
anchor: "if provider == .minimax {",
expectedProviderIDs: ["codex", "minimax"],
expectedReferenceCount: 2,
expectedReferenceFingerprint: ["minimax@0", "codex@3"],
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1120,
line: 1123,
anchor: "guard let loginMethod = snapshot?.loginMethod(for: .kilo) else {",
expectedProviderIDs: ["kilo"],
expectedReferenceCount: 1,
expectedReferenceFingerprint: ["kilo@0"],
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1204,
line: 1207,
anchor: "if input.provider == .antigravity {",
expectedProviderIDs: ["antigravity", "mistral"],
expectedReferenceCount: 2,
expectedReferenceFingerprint: ["antigravity@0", "mistral@6"],
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1224,
line: 1227,
anchor: "if input.provider == .codex, let codexProjection = input.codexProjection {",
expectedProviderIDs: ["alibaba", "alibabatokenplan", "codex", "perplexity", "sub2api"],
expectedReferenceCount: 6,
Expand All @@ -1997,31 +1997,31 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1275,
line: 1278,
anchor: "if input.provider == .kilo || input.provider == .kimi,",
expectedProviderIDs: ["kilo", "kimi"],
expectedReferenceCount: 2,
expectedReferenceFingerprint: ["kilo@0", "kimi@0"],
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1357,
line: 1360,
anchor: "var paceDetail = if input.provider == .kimi {",
expectedProviderIDs: ["kimi"],
expectedReferenceCount: 1,
expectedReferenceFingerprint: ["kimi@0"],
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1373,
line: 1376,
anchor: "if input.provider == .warp,",
expectedProviderIDs: ["chutes", "kilo", "kiro", "litellm", "sub2api", "warp"],
expectedReferenceCount: 6,
expectedReferenceFingerprint: ["warp@0", "chutes@7", "kilo@7", "litellm@7", "sub2api@16", "kiro@19"],
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1406,
line: 1409,
anchor: "if input.provider == .alibaba || input.provider == .alibabatokenplan,",
expectedProviderIDs: ["alibaba", "alibabatokenplan", "copilot", "crof", "manus", "perplexity", "zenmux"],
expectedReferenceCount: 8,
Expand All @@ -2038,7 +2038,7 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
AllowedProviderConstruct(
path: "Sources/CodexBar/MenuCardView.swift",
line: 1447,
line: 1450,
anchor: "if input.provider == .synthetic,",
expectedProviderIDs: ["synthetic"],
expectedReferenceCount: 1,
Expand Down
7 changes: 5 additions & 2 deletions Tests/CodexBarTests/UsageMenuCardLayoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ struct UsageMenuCardLayoutTests {
}

@Test
func `metric detail remains one row as pace content grows`() {
func `metric detail wraps to a second row instead of truncating as pace content grows`() {
let width: CGFloat = 296
func card(
detailRightText: String,
Expand Down Expand Up @@ -178,7 +178,10 @@ struct UsageMenuCardLayoutTests {
accessibilityLabel: "Est. 2 session quotas left · 6 windows until reset")))
.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height

#expect(abs(shortHeight - longHeight) < Self.heightTolerance)
// The meta line may wrap to two lines so tail content stays readable
// instead of truncating; the card grows by roughly one text line.
#expect(longHeight - shortHeight > Self.heightTolerance)
#expect(longHeight - shortHeight < 20)
}

private static func model(
Expand Down
Loading