Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

### Added (macOS)
- **The Capacity Dock and the agent-tab quota card now say whether a window will last to its reset, not just when it resets.** Each quota window gets one line under the bar: `Lasts until reset` while the linear projection lands at or under 100% at reset, `Runs out in 2d 8h` when it overflows on a window long enough for a whole-window rate to be defensible, and `Won't last until reset` when it overflows on a window of 6 hours or less, which keeps the run-out ETA suppressed there rather than crying wolf over one burst. The verdict is the same projection the Plan tab's deficit/reserve caption uses, so the two surfaces cannot disagree, and it stays silent in the first 3% of a window, on an exhausted window, with no reset time, or when the window's own label does not imply a length (a Copilot premium-requests bucket, say). The dock reserves the line's height whether or not a column has a verdict, so a projection crossing in or out of silence cannot resize the bubble under the pointer. (#1215)

## 0.9.24 - 2026-09-04

### Added
Expand Down
7 changes: 7 additions & 0 deletions docs/design/capacity-dock.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ V1 does not include:
countdown. The most constrained available window supplies the ring value;
this matches the reference's glance-first use and avoids understating a
provider whose secondary window is closer to exhaustion.
- Under that, one line says whether the window lasts: `Lasts until reset`,
`Runs out in 2d 8h`, or `Won't last until reset` on windows of 6 hours or
less, where a linear run-out ETA is not defensible. It is the same projection
the Plan tab's pace caption uses, and it is silent early in a window, on an
exhausted window, without a reset time, or when the window's label implies no
length. Its height is reserved whether or not a column has a verdict, because
the panel's frame is computed rather than fitted.
- Stale or retrying data remains visible and is labeled/dimmed. A terminal
authentication/configuration failure provides a Connect/Reconnect action in
the bubble itself. Network, rate-limit, parse, and provider outages remain
Expand Down
113 changes: 113 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/QuotaPace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,116 @@ enum QuotaPace {
)
}
}

extension QuotaPace {
/// The one-line "am I going to make it?" answer for surfaces with no room
/// for the Plan tab's deficit/reserve caption: the Capacity Dock's window
/// columns and the agent-tab hover card (#1215). Same projection as #726 —
/// `evaluate` stays the only math here — reduced to a verdict.
struct Verdict: Equatable {
let text: String
/// Drives the warning tint; the text alone already says which way it went.
let willOverflow: Bool
}

static func verdict(
usedPercent: Double,
resetsAt: Date?,
windowSeconds: Int,
now: Date = Date()
) -> Verdict? {
guard let result = evaluate(
usedPercent: usedPercent,
resetsAt: resetsAt,
windowSeconds: windowSeconds,
now: now
) else { return nil }
guard result.willOverflow else {
return Verdict(text: "Lasts until reset", willOverflow: false)
}
// An overflowing window without an ETA is one `evaluate` suppressed for
// being too short to extrapolate (<= 6h). Say it won't last rather than
// invent a minute-precision deadline off a single burst.
guard let hitsLimitAt = result.hitsLimitAt else {
return Verdict(text: "Won't last until reset", willOverflow: true)
}
let countdown = countdownLabel(seconds: hitsLimitAt.timeIntervalSince(now))
return Verdict(text: "Runs out in \(countdown)", willOverflow: true)
}

/// "2d 3h" / "4h 12m" / "8m" / "now" — the same shape as the reset
/// countdown both surfaces already print beside the bar.
static func countdownLabel(seconds: TimeInterval) -> String {
let seconds = max(0, seconds)
if seconds < 60 { return "now" }
let minutes = Int(seconds / 60)
let hours = minutes / 60
let days = hours / 24
if days > 0 { return "\(days)d \(hours % 24)h" }
if hours > 0 { return "\(hours)h \(minutes % 60)m" }
return "\(minutes)m"
}

/// Window length implied by a normalized provider label ("Weekly",
/// "5-hour", "Weekly · Opus", "Monthly usage limit"). The adapters derive
/// those labels from the exact API durations, so the round trip is lossless
/// except for calendar months, which are measured back from the reset date
/// in UTC the way the Codex spend window is (a local calendar would make
/// pace timezone-dependent). An unrecognized label returns nil, which keeps
/// the verdict silent rather than guessing a window.
static func inferredWindowSeconds(label: String, resetsAt: Date?) -> Int? {
for component in label.components(separatedBy: "·") {
if let seconds = windowSeconds(forComponent: component, resetsAt: resetsAt) {
return seconds
}
}
return nil
}

private static func windowSeconds(forComponent component: String, resetsAt: Date?) -> Int? {
let token = component.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
// Only the leading word carries the duration: the credit window's label
// is "Monthly usage limit", not "Monthly".
guard let head = token.components(separatedBy: " ").first, !head.isEmpty else { return nil }
switch head {
case "minutely": return 60
case "hourly", "hour": return 3600
case "daily": return 86_400
case "weekly": return 604_800
case "monthly": return calendarMonthSeconds(endingAt: resetsAt, months: 1)
default: break
}
// "5-hour", "3-day", "2-week", "90-min", and the spelled-out form some
// providers send ("Five-hour").
let parts = head.components(separatedBy: "-")
guard parts.count == 2, let count = wholeNumber(parts[0]), count > 0 else { return nil }
switch parts[1] {
case "min", "mins", "minute", "minutes": return count * 60
case "hour", "hours": return count * 3600
case "day", "days": return count * 86_400
case "week", "weeks": return count * 604_800
case "month", "months": return calendarMonthSeconds(endingAt: resetsAt, months: count)
default: return nil
}
}

private static let spelledNumbers = [
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
"seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12
]

private static func wholeNumber(_ text: String) -> Int? {
Int(text) ?? spelledNumbers[text]
}

/// Mirrors `CodexSubscriptionService.monthlyWindowSeconds`: the window is
/// the month preceding the reset, measured in UTC.
private static func calendarMonthSeconds(endingAt resetsAt: Date?, months: Int) -> Int? {
guard let resetsAt, months > 0 else { return nil }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
guard let start = calendar.date(byAdding: .month, value: -months, to: resetsAt) else { return nil }
let seconds = Int(resetsAt.timeIntervalSince(start))
return seconds > 0 ? seconds : nil
}
}
26 changes: 18 additions & 8 deletions mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,24 @@ extension QuotaSummary.Window {
/// Human-readable countdown like "2h 11m" or "3d 14h" or "now".
var resetsInLabel: String {
guard let resetsAt else { return "" }
let seconds = max(0, resetsAt.timeIntervalSinceNow)
if seconds < 60 { return "now" }
let minutes = Int(seconds / 60)
let hours = minutes / 60
let days = hours / 24
if days > 0 { return "\(days)d \(hours % 24)h" }
if hours > 0 { return "\(hours)h \(minutes % 60)m" }
return "\(minutes)m"
return QuotaPace.countdownLabel(seconds: resetsAt.timeIntervalSinceNow)
}

/// One-line pace verdict drawn under the bar in the Capacity Dock and the
/// agent-tab hover card (#1215). Nil means say nothing: no reset time, a
/// label with no inferable window length, too early in the window, the
/// window already exhausted, or clock skew — see `QuotaPace`.
func paceVerdict(now: Date = Date()) -> QuotaPace.Verdict? {
guard let windowSeconds = QuotaPace.inferredWindowSeconds(
label: label,
resetsAt: resetsAt
) else { return nil }
return QuotaPace.verdict(
usedPercent: percent * 100,
resetsAt: resetsAt,
windowSeconds: windowSeconds,
now: now
)
}

var percentLabel: String {
Expand Down
56 changes: 37 additions & 19 deletions mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -447,28 +447,46 @@ private struct QuotaDetailPopover: View {
private struct QuotaDetailRow: View {
let window: QuotaSummary.Window

/// The label column's width. The verdict hangs under the bar, so it is
/// indented by this plus the row's own spacing.
private static let labelWidth: CGFloat = 92
private static let rowSpacing: CGFloat = 8

var body: some View {
HStack(spacing: 8) {
Text(window.label)
.font(.system(size: 10.5))
.frame(width: 92, alignment: .leading)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule()
.fill(barColor)
.frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1))))
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: Self.rowSpacing) {
Text(window.label)
.font(.system(size: 10.5))
.frame(width: Self.labelWidth, alignment: .leading)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule()
.fill(barColor)
.frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1))))
}
}
.frame(height: 4)
Text(window.percentLabel)
.font(.codeMono(size: 10.5, weight: .medium))
.frame(width: 36, alignment: .trailing)
if !window.resetsInLabel.isEmpty {
Text(window.resetsInLabel)
.font(.codeMono(size: 10))
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .trailing)
}
}
.frame(height: 4)
Text(window.percentLabel)
.font(.codeMono(size: 10.5, weight: .medium))
.frame(width: 36, alignment: .trailing)
if !window.resetsInLabel.isEmpty {
Text(window.resetsInLabel)
.font(.codeMono(size: 10))
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .trailing)
// Will this window last to its reset (#1215)? The card's height is
// fitted, so a silent verdict collapses rather than leaving a gap.
if let verdict = window.paceVerdict() {
Text(verdict.text)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(verdict.willOverflow
? AnyShapeStyle(Color.orange)
: AnyShapeStyle(.tertiary))
.lineLimit(1)
.padding(.leading, Self.labelWidth + Self.rowSpacing)
}
}
}
Expand Down
22 changes: 20 additions & 2 deletions mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,12 @@ enum CapacityDockGlance {
/// Three stacked lines, 13 + 13 + 12, with two 3pt gaps. Taller than the
/// 17pt burned figure beside it, so it sets the row.
static let todayContentHeight: CGFloat = 44
/// 8 top + 24 percent + 2 + 13 label + 2 + 12 reset + 16 bottom.
static let windowsHeight: CGFloat = 77
/// A 9.5pt verdict's line box. Reserved on every window column whether or
/// not that column has a verdict to print, so the panel keeps one height
/// while the projection crosses in and out of silence (#1215).
static let verdictLine: CGFloat = 12
/// 8 top + 24 percent + 2 + 13 label + 2 + 12 reset + 2 + 12 verdict + 16 bottom.
static let windowsHeight: CGFloat = 91
/// 8 top + one secondary line + 16 bottom.
static let windowsEmptyHeight: CGFloat = 37
/// The staleness or reconnect line under the header. It is a section like any
Expand Down Expand Up @@ -1012,6 +1016,7 @@ struct CapacityDockDetailView: View {
alignment: HorizontalAlignment
) -> some View {
let s = model.detailScale
let verdict = window.paceVerdict()
VStack(alignment: alignment, spacing: 0) {
PercentGaugeText(
label: window.percentLabel,
Expand All @@ -1032,6 +1037,19 @@ struct CapacityDockDetailView: View {
.lineLimit(1)
.frame(height: 12 * s)
.padding(.top, 2 * s)
// Will this window last to its reset (#1215)? The panel's height is
// computed rather than fitted, so the line keeps its box even when
// there is no verdict: a column that went quiet must not resize the
// bubble under the pointer.
Text(verdict?.text ?? "")
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(verdict?.willOverflow == true
? AnyShapeStyle(Color.orange)
: AnyShapeStyle(Color.capacityDockText.opacity(0.45)))
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(height: CapacityDockGlance.verdictLine * s)
.padding(.top, 2 * s)
}
.frame(
maxWidth: .infinity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,22 +208,22 @@ struct CapacityDockGlanceTests {
+ CapacityDockGlance.todayHeight
+ CapacityDockGlance.windowsHeight
)
// 44 header + 83 sessions + 81 today + 77 windows
#expect(full == 285)
// 44 header + 83 sessions + 81 today + 91 windows
#expect(full == 299)
// The panel opens and closes on the same 16pt inset it uses sideways.
let headerParts: CGFloat = CapacityDockGlance.contentInset + 20 + 8
#expect(CapacityDockGlance.headerHeight == headerParts)
#expect(
CapacityDockGlance.windowsHeight
== CapacityDockGlance.sectionPadTop + 53 + CapacityDockGlance.contentInset
== CapacityDockGlance.sectionPadTop + 67 + CapacityDockGlance.contentInset
)
// Today is three stacked lines (13 + 3 + 13 + 3 + 12) inside its padding.
#expect(CapacityDockGlance.todayContentHeight == 44)
#expect(CapacityDockGlance.todayHeight == 81)
// Past four sessions the list scrolls, so the panel stops growing.
let capped = height(4, hasToday: true, windows: three)
#expect(height(12, hasToday: true, windows: three) == capped)
#expect(capped == 44 + CapacityDockGlance.sessionsHeight(count: 4) + 81 + 77)
#expect(capped == 44 + CapacityDockGlance.sessionsHeight(count: 4) + 81 + 91)
// Each section is independently droppable.
#expect(full - height(nil, hasToday: true, windows: three) == CapacityDockGlance.sessionsHeight(count: 1))
#expect(full - height(1, hasToday: false, windows: three) == CapacityDockGlance.todayHeight)
Expand Down
Loading
Loading