Redesign menu bar icon and provider icons, harden 429 handling#1
Redesign menu bar icon and provider icons, harden 429 handling#1willwashburn wants to merge 11 commits into
Conversation
Remove the Bun/React terminal dashboard and replace it with a native SwiftUI menu bar app (MenuBarExtra + Swift Charts) that shows Claude and Codex usage limits as burndown charts, matching the requested design. - Reads existing CLI credentials (Claude Code keychain item, ~/.codex/auth.json) - Fetches the same Anthropic OAuth usage and Codex usage endpoints - Renders ideal-vs-actual burndown per window with over/under pace badges - Persists usage samples to draw the real usage curve over time - Auto-refreshes every 60s; provider picker for Codex/Claude - build.sh assembles a menu-bar-only (LSUIElement) .app bundle Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cA8oiGrNxkFE4JbALbPgw
Restore the proven monitor/1.0.0 User-Agent used by the prior working implementation instead of a new value, in case the endpoint validates it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cA8oiGrNxkFE4JbALbPgw
The Anthropic usage endpoint returns 6-digit fractional seconds with an explicit offset (e.g. 2026-06-17T05:19:59.253508+00:00), which ISO8601DateFormatter rejects. Fall back to stripping fractional seconds so reset times parse and the burndown charts render instead of silently degrading to a plain bar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cA8oiGrNxkFE4JbALbPgw
- Replace the Claude/Codex dropdown with a segmented control of brand icons from lobe-icons (bundled SVGs, tinted as template images). - Redesign burndown charts as cards: gradient gap fill, ringed current marker, "resets in" readout, and a header summary (% left + pace badge) so numbers no longer overlap the plot. - Capitalize the plan label and present it as a pill. - build.sh now bundles SwiftPM resource bundles into the .app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cA8oiGrNxkFE4JbALbPgw
Menu bar: - Replace the usage gauge with a flame that grows with the busiest window's usage and fills + turns red when that window is burning off its target pace (over the ideal burndown). Rendered via ImageRenderer to a non-template NSImage so the color survives in the menu bar. Popover: - Use the official lobe-icons artwork: Claude renders its own coral mark, the provider formerly shown as the Codex blob now shows the OpenAI mark in white. Icons render full-color (self-colored as-is, monochrome marks tinted) instead of being grayed when unselected. - Remove the refresh and quit buttons from the header. Rate limiting: - Treat HTTP 429 from the usage endpoints as a transient .rateLimited state: keep showing the last good reading with a quiet notice and back off exponentially instead of wiping the panel with a hard error. Also ignore node_modules/ and .npm-cache/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request replaces the previous Node.js/Bun-based terminal dashboard with a native macOS menu bar application written in Swift and SwiftUI, displaying Claude and Codex usage limits as burndown charts. The code review identifies several critical and medium-severity issues in the new Swift implementation, including a potential runtime crash when modifying a dictionary during iteration, synchronous disk I/O blocking the main thread, a lack of protection against concurrent refreshes, rendering issues from using dynamic offsets as SwiftUI identifiers, thread-safety concerns in the icon cache, and a potential premature exit in the build script.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private func pruneStaleWindows(reference: Date) { | ||
| for k in cache.keys { | ||
| let parts = k.split(separator: "|") | ||
| guard parts.count == 3, let ts = Double(parts[2]), ts != 0 else { continue } | ||
| let resetDate = Date(timeIntervalSince1970: ts) | ||
| if resetDate < reference.addingTimeInterval(-3600) { | ||
| cache.removeValue(forKey: k) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Modifying the cache dictionary while iterating over cache.keys will cause a runtime crash in Swift. You should collect the keys to remove first, and then perform the removal outside of the iteration.
| private func pruneStaleWindows(reference: Date) { | |
| for k in cache.keys { | |
| let parts = k.split(separator: "|") | |
| guard parts.count == 3, let ts = Double(parts[2]), ts != 0 else { continue } | |
| let resetDate = Date(timeIntervalSince1970: ts) | |
| if resetDate < reference.addingTimeInterval(-3600) { | |
| cache.removeValue(forKey: k) | |
| } | |
| } | |
| } | |
| private func pruneStaleWindows(reference: Date) { | |
| let keysToRemove = cache.keys.filter { k in | |
| let parts = k.split(separator: "|") | |
| guard parts.count == 3, let ts = Double(parts[2]), ts != 0 else { return false } | |
| let resetDate = Date(timeIntervalSince1970: ts) | |
| return resetDate < reference.addingTimeInterval(-3600) | |
| } | |
| for k in keysToRemove { | |
| cache.removeValue(forKey: k) | |
| } | |
| } |
| private func persist() { | ||
| guard let data = try? JSONEncoder().encode(cache) else { return } | ||
| try? data.write(to: fileURL) | ||
| } |
There was a problem hiding this comment.
Performing synchronous disk I/O (data.write(to:)) inside queue.sync blocks the calling thread. Since this is called from the @MainActor UsageViewModel on the main thread, it can cause UI stutters or hangs. Consider performing the write operation asynchronously on a background queue.
private func persist() {
guard let data = try? JSONEncoder().encode(cache) else { return }
DispatchQueue.global(qos: .background).async {
try? data.write(to: self.fileURL)
}
}| func refresh(force: Bool = false) async { | ||
| guard let provider = providers[selectedProvider] else { return } | ||
| if !force, let until = backoffUntil, Date() < until { return } | ||
| isLoading = true |
There was a problem hiding this comment.
There is no guard to prevent concurrent refreshes if isLoading is already true. This can lead to multiple overlapping network requests and duplicate history records if the user triggers a manual refresh or switches providers quickly. Adding a check for isLoading prevents this.
| func refresh(force: Bool = false) async { | |
| guard let provider = providers[selectedProvider] else { return } | |
| if !force, let until = backoffUntil, Date() < until { return } | |
| isLoading = true | |
| func refresh(force: Bool = false) async { | |
| guard !isLoading else { return } | |
| guard let provider = providers[selectedProvider] else { return } | |
| if !force, let until = backoffUntil, Date() < until { return } | |
| isLoading = true |
| ForEach(Array(viewModel.charts.enumerated()), id: \.offset) { _, item in | ||
| if let data = item.data { | ||
| BurndownChartView(title: item.metric.name, data: data) | ||
| } else { | ||
| SimpleUsageRow(metric: item.metric) | ||
| } | ||
| } |
There was a problem hiding this comment.
Using \.offset as the identifier in ForEach is an anti-pattern when the list can change dynamically (such as when switching providers). It can cause SwiftUI to reuse views incorrectly, leading to rendering glitches or animation bugs. Use the stable metric.id instead.
| ForEach(Array(viewModel.charts.enumerated()), id: \.offset) { _, item in | |
| if let data = item.data { | |
| BurndownChartView(title: item.metric.name, data: data) | |
| } else { | |
| SimpleUsageRow(metric: item.metric) | |
| } | |
| } | |
| ForEach(viewModel.charts, id: \.metric.id) { item in | |
| if let data = item.data { | |
| BurndownChartView(title: item.metric.name, data: data) | |
| } else { | |
| SimpleUsageRow(metric: item.metric) | |
| } | |
| } |
| /// Caches `NSImage`s loaded from the bundled SVG resources. A `nil` tint renders | ||
| /// the SVG's own colors; a non-nil tint recolors the opaque pixels to it while | ||
| /// preserving the glyph's transparency (for monochrome marks). | ||
| private final class BrandIconCache { |
There was a problem hiding this comment.
BrandIconCache is a shared singleton accessed from the UI thread but is not thread-safe since its cache dictionary is mutated without synchronization. Marking it as @MainActor ensures safe access from the main thread.
| private final class BrandIconCache { | |
| @MainActor | |
| private final class BrandIconCache { |
| for bundle in "$BIN_PATH"/*.bundle; do | ||
| [ -e "$bundle" ] && cp -R "$bundle" "$APP_DIR/Contents/Resources/" | ||
| done |
There was a problem hiding this comment.
Under set -e, if no .bundle files exist, the glob *.bundle evaluates literally, and [ -e "$bundle" ] returns false (exit code 1). This can cause the script to exit prematurely depending on the bash environment. Using a standard if statement is safer.
| for bundle in "$BIN_PATH"/*.bundle; do | |
| [ -e "$bundle" ] && cp -R "$bundle" "$APP_DIR/Contents/Resources/" | |
| done | |
| for bundle in "$BIN_PATH"/*.bundle; do | |
| if [ -e "$bundle" ]; then | |
| cp -R "$bundle" "$APP_DIR/Contents/Resources/" | |
| fi | |
| done |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 120cbd84ce
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| <key>LSUIElement</key> | ||
| <true/> |
There was a problem hiding this comment.
With LSUIElement enabled, macOS hides the app from the Dock and normal app menu, and the new MenuBarExtra content does not provide any Quit/NSApp.terminate action. Anyone who launches the installed background app therefore has no UI path to stop it other than killing it from Activity Monitor or the terminal, so keep a quit item somewhere in the menu bar UI before shipping this mode.
Useful? React with 👍 / 👎.
Use a warm orange→red ramp (red when off target) instead of the green/yellow/orange scale — a green flame reads as contradictory. Also remove the "%" text from the menu bar so only the flame shows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe repository is rewritten from a Node.js/TypeScript terminal CLI (Bun/React/Ink) into a native macOS SwiftUI menu bar app. All TypeScript source, npm tooling, and CI publishing workflows are deleted. A new Swift package introduces credential loading, async provider fetching, persistent usage history, burndown chart data computation, and a full SwiftUI view hierarchy with a colored flame menu bar icon. New release automation handles macOS code signing, notarization, and DMG packaging. ChangesSwift macOS App (AgentLimit)
Sequence DiagramsequenceDiagram
actor User
participant MenuBarLabel
participant UsageViewModel
participant ClaudeProvider
participant CodexProvider
participant UsageHistoryStore
participant BurndownBuilder
participant ContentView
User->>MenuBarLabel: clicks menu bar icon
MenuBarLabel->>UsageViewModel: observes headlineUsage / headlineOffTarget
UsageViewModel->>ClaudeProvider: fetch()
ClaudeProvider-->>UsageViewModel: ProviderStatus + [UsageMetric]
UsageViewModel->>CodexProvider: fetch()
CodexProvider-->>UsageViewModel: ProviderStatus + [UsageMetric]
loop per metric
UsageViewModel->>UsageHistoryStore: record(provider, metric)
UsageHistoryStore-->>UsageViewModel: [UsageSample]
UsageViewModel->>BurndownBuilder: build(metric, samples, now)
BurndownBuilder-->>UsageViewModel: BurndownData?
end
UsageViewModel-->>MenuBarLabel: updated headlineUsage, headlineOffTarget
UsageViewModel-->>ContentView: status, charts, lastUpdated, notice
ContentView-->>User: renders popover with burndown charts
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 OSV Scanner (2.3.8)Error: ENOENT: no such file or directory, scandir '/inmem/1299/nsjail-214e79db-af9d-4e66-ac07-0dd24f0812d2/merged/.git/hooks' Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (1)
README.md (1)
41-41: ⚡ Quick winAdd language specifiers to fenced code blocks.
Markdown linting (MD040) requires language specifiers on all fenced code blocks. Lines 41, 48, 54, and 60 are missing them.
🔧 Proposed fixes
-```bash +```bashApply
```bashto lines 41, 48, and 54.For line 60 (the directory tree), use:
-``` +```text Package.swift Swift package manifestAlso applies to: 48-48, 54-54, 60-60
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@README.md` at line 41, The fenced code blocks in the README.md file are missing language specifiers, which violates markdown linting rules (MD040). Add bash as the language specifier to the opening backticks on lines 41, 48, and 54 (change ``` to ```bash). For the directory tree section on line 60, add text as the language specifier instead (change ``` to ```text).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@README.md`:
- Line 9: The README.md contains inaccurate claims that contradict the PR's
actual implementation. Remove or rephrase line 9 which claims the app "shows
your highest current usage as a percentage" since the PR objective states the
percentage text label is removed—update this to reflect that usage information
is conveyed only through the flame icon. Similarly, remove or rephrase line 20
which claims users can "refresh manually any time" since the PR removes the
refresh and quit buttons from the header and no refresh button exists in
ContentView.swift. Keep line 14's claim about the blue line color as it
accurately reflects BurndownChartView's lineColor property.
In `@Sources/AgentLimit/Burndown.swift`:
- Around line 60-63: The current code calculates remaining values by subtracting
percentage from 100, but does not clamp the input percentage values to the [0,
100] range first, which means negative percentages could produce remaining
values greater than 100. Fix this by clamping the percentage values to [0, 100]
before computing the remaining value in the expressions at lines 60 and 62 that
involve sample.percentage and metric.percentage respectively, as well as the
similar calculations at lines 80-81. Use min(max()) functions to clamp the
percentage value first, then subtract from 100 to get the remaining value.
In `@Sources/AgentLimit/ContentView.swift`:
- Around line 155-156: The metric percentage clamping in the ProgressView
initialization only clamps the upper bound with min(metric.percentage, 100),
allowing negative percentages to produce inconsistent results between the
progress bar and the label. Update line 155 to clamp both the lower and upper
bounds by wrapping the min function with max to ensure the value stays within
the 0...100 range, or use Swift's built-in clamped(to:) method on
metric.percentage to clamp it to a valid range before passing it to ProgressView
and the Text label on line 156.
- Around line 80-82: The `.rateLimited` case in ContentView.swift is only
displaying a noticeView banner and discarding the previously retained usage data
instead of showing them together. Modify the `.rateLimited` case handler to
display both the rate-limited notice banner and the retained charts/rows
content, similar to how other states handle combined notification and data
display. This ensures users can still view their usage information while being
informed about the rate-limited state.
In `@Sources/AgentLimit/Credentials.swift`:
- Around line 39-56: The keychainPassword(service:) function calls
process.waitUntilExit() without a timeout, causing credential refresh to block
indefinitely if the /usr/bin/security command hangs. Replace the unbounded
process.waitUntilExit() call with a timeout-aware mechanism using
DispatchSemaphore or a polling loop that monitors process termination with a
configurable timeout duration (e.g., a few seconds). If the timeout is exceeded
before the process terminates, terminate the process and return nil to allow the
application to continue rather than hanging indefinitely.
In `@Sources/AgentLimit/Providers.swift`:
- Around line 41-65: The code currently returns a healthy ProviderStatus even
when no metrics are successfully extracted, which silently masks schema changes
or API failures. After collecting metrics through the three window() function
calls (five_hour, seven_day, and seven_day_opus), add a check to ensure the
metrics array is not empty before returning the ProviderStatus. If metrics is
empty after all window() attempts, return a failure status instead of proceeding
with the successful response. Apply this same pattern to both provider status
parsing blocks mentioned in the comment (the claude provider section containing
the window() function and the second section referenced at lines 103-148).
In `@Sources/AgentLimit/UsageHistory.swift`:
- Around line 34-37: Metrics without a resetsAt value should not have their
samples persisted because they cannot be pruned and will accumulate
indefinitely. Add a guard condition at the beginning of all methods that persist
or save samples (including the key method and the methods referenced in lines
42-57 and 61-70) to check if the metric has a resetsAt value, and return early
or skip persistence if resetsAt is nil. This prevents generating keys with
"none" values that bypass timestamp-based pruning logic.
- Around line 73-75: The persist() method currently performs a non-atomic write
to fileURL that can result in a corrupted or truncated file if the app is
interrupted. Replace the current write(to:) call with write(to:options:) and
pass the .atomic option to ensure the file is written atomically. This
guarantees that either the entire file is written successfully or no changes are
applied, preventing data corruption from interrupted writes.
In `@Sources/AgentLimit/UsageViewModel.swift`:
- Around line 17-41: The timer property created in the start() method is never
invalidated, which can cause duplicate network activity if the UsageViewModel is
recreated. Add a deinit method to the UsageViewModel class that invalidates the
timer by calling invalidate() on it and setting it to nil. Also check the
location at line 125 mentioned in the comment to apply the same cleanup logic
there as well.
- Around line 57-63: The refresh() method has a race condition where slower
refresh calls can overwrite newer results in status, charts, and lastUpdated
properties. Add a tracking mechanism such as a timestamp or request identifier
captured before calling await provider.fetch() for each provider, then compare
this value before updating the state after the fetch completes to ensure only
the most recent refresh result is applied. This applies both to the main refresh
logic starting at Line 57 and the additional instances at Lines 98-101 that
update the same state properties.
---
Nitpick comments:
In `@README.md`:
- Line 41: The fenced code blocks in the README.md file are missing language
specifiers, which violates markdown linting rules (MD040). Add bash as the
language specifier to the opening backticks on lines 41, 48, and 54 (change ```
to ```bash). For the directory tree section on line 60, add text as the language
specifier instead (change ``` to ```text).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: bd78205f-505f-4449-91e7-6a25cb3ebcf0
⛔ Files ignored due to path filters (4)
Sources/AgentLimit/Resources/claude.svgis excluded by!**/*.svgSources/AgentLimit/Resources/openai.svgis excluded by!**/*.svgbun.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (38)
.github/workflows/publish.yml.gitignoreAGENTS.mdApp/Info.plistCHANGELOG.mdLICENSEPackage.swiftREADME.mdSources/AgentLimit/AgentLimitApp.swiftSources/AgentLimit/BrandIcon.swiftSources/AgentLimit/Burndown.swiftSources/AgentLimit/BurndownChartView.swiftSources/AgentLimit/ContentView.swiftSources/AgentLimit/Credentials.swiftSources/AgentLimit/Models.swiftSources/AgentLimit/Providers.swiftSources/AgentLimit/UsageHistory.swiftSources/AgentLimit/UsageViewModel.swiftbin/cli.jsbin/cli.tsxbuild.shpackage.jsonsrc/App.tsxsrc/components/Dashboard.tsxsrc/components/Footer.tsxsrc/components/Header.tsxsrc/components/ProgressBar.tsxsrc/components/ProviderCard.tsxsrc/components/index.tssrc/index.tsxsrc/providers/claude.tssrc/providers/codex.tssrc/providers/index.tssrc/providers/types.tssrc/utils/colors.tssrc/utils/keychain.tssrc/utils/time.tstsconfig.json
💤 Files with no reviewable changes (22)
- AGENTS.md
- src/components/Footer.tsx
- tsconfig.json
- src/providers/types.ts
- src/utils/time.ts
- src/providers/codex.ts
- CHANGELOG.md
- src/utils/colors.ts
- package.json
- src/utils/keychain.ts
- src/components/ProviderCard.tsx
- bin/cli.js
- src/components/Dashboard.tsx
- src/components/ProgressBar.tsx
- src/index.tsx
- .github/workflows/publish.yml
- src/providers/claude.ts
- src/components/Header.tsx
- src/components/index.ts
- bin/cli.tsx
- src/App.tsx
- src/providers/index.ts
| series.append((sample.date, max(0, 100 - sample.percentage))) | ||
| } | ||
| series.append((now, max(0, 100 - metric.percentage))) | ||
| series.sort { $0.date < $1.date } |
There was a problem hiding this comment.
Clamp percentage to [0, 100] before converting to remaining.
At Line 60/62/80, negative upstream percentages would produce remaining > 100, which can skew chart shading and pace comparisons. Clamp input percentage first, then compute remaining.
Suggested patch
+ func clampedPercentage(_ value: Double) -> Double {
+ min(100, max(0, value))
+ }
+
var series: [(date: Date, remaining: Double)] = [(windowStart, 100)]
for sample in samples where sample.date > windowStart && sample.date < now {
- series.append((sample.date, max(0, 100 - sample.percentage)))
+ series.append((sample.date, 100 - clampedPercentage(sample.percentage)))
}
- series.append((now, max(0, 100 - metric.percentage)))
+ series.append((now, 100 - clampedPercentage(metric.percentage)))
@@
- actualRemaining: max(0, 100 - metric.percentage),
+ actualRemaining: 100 - clampedPercentage(metric.percentage),Also applies to: 80-81
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/Burndown.swift` around lines 60 - 63, The current code
calculates remaining values by subtracting percentage from 100, but does not
clamp the input percentage values to the [0, 100] range first, which means
negative percentages could produce remaining values greater than 100. Fix this
by clamping the percentage values to [0, 100] before computing the remaining
value in the expressions at lines 60 and 62 that involve sample.percentage and
metric.percentage respectively, as well as the similar calculations at lines
80-81. Use min(max()) functions to clamp the percentage value first, then
subtract from 100 to get the remaining value.
| case .rateLimited: | ||
| noticeView(status.message ?? "Rate-limited. Retrying shortly.") | ||
| default: |
There was a problem hiding this comment.
Rate-limited state currently hides retained usage data
Line 80 routes .rateLimited to only noticeView, which drops the previously retained charts/rows instead of showing them with a banner as described in the PR objective.
Suggested fix
case .rateLimited:
- noticeView(status.message ?? "Rate-limited. Retrying shortly.")
+ VStack(alignment: .leading, spacing: 12) {
+ noticeView(status.message ?? "Rate-limited. Retrying shortly.")
+ if status.metrics.isEmpty {
+ if viewModel.charts.isEmpty {
+ messageView("No previous usage snapshot available yet.")
+ } else {
+ chartsView(plan: status.plan)
+ }
+ } else {
+ chartsView(plan: status.plan)
+ }
+ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/ContentView.swift` around lines 80 - 82, The
`.rateLimited` case in ContentView.swift is only displaying a noticeView banner
and discarding the previously retained usage data instead of showing them
together. Modify the `.rateLimited` case handler to display both the
rate-limited notice banner and the retained charts/rows content, similar to how
other states handle combined notification and data display. This ensures users
can still view their usage information while being informed about the
rate-limited state.
| ProgressView(value: min(metric.percentage, 100), total: 100) | ||
| Text("\(Int(metric.percentage.rounded()))% used") |
There was a problem hiding this comment.
Clamp progress input to a valid 0...100 range
Line 155 clamps only the upper bound. If a provider returns a negative percentage, the bar and label can diverge.
Suggested fix
- ProgressView(value: min(metric.percentage, 100), total: 100)
+ ProgressView(value: max(0, min(metric.percentage, 100)), total: 100)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/ContentView.swift` around lines 155 - 156, The metric
percentage clamping in the ProgressView initialization only clamps the upper
bound with min(metric.percentage, 100), allowing negative percentages to produce
inconsistent results between the progress bar and the label. Update line 155 to
clamp both the lower and upper bounds by wrapping the min function with max to
ensure the value stays within the 0...100 range, or use Swift's built-in
clamped(to:) method on metric.percentage to clamp it to a valid range before
passing it to ProgressView and the Text label on line 156.
| private static func keychainPassword(service: String) -> String? { | ||
| let process = Process() | ||
| process.executableURL = URL(fileURLWithPath: "/usr/bin/security") | ||
| process.arguments = ["find-generic-password", "-s", service, "-w"] | ||
|
|
||
| let outPipe = Pipe() | ||
| process.standardOutput = outPipe | ||
| process.standardError = Pipe() | ||
|
|
||
| do { | ||
| try process.run() | ||
| } catch { | ||
| return nil | ||
| } | ||
|
|
||
| let data = outPipe.fileHandleForReading.readDataToEndOfFile() | ||
| process.waitUntilExit() | ||
| guard process.terminationStatus == 0 else { return nil } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify subprocess calls in credential loading are currently unbounded.
rg -n -C3 'Process\(|waitUntilExit|terminationHandler|timeout|find-generic-password' Sources/AgentLimit/Credentials.swiftRepository: AgentWorkforce/limit
Length of output: 826
Add a timeout for the keychain subprocess.
The subprocess wait at line 55 is unbounded; if /usr/bin/security hangs, credential refresh will block indefinitely.
Suggested fix
private static func keychainPassword(service: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
process.arguments = ["find-generic-password", "-s", service, "-w"]
let outPipe = Pipe()
process.standardOutput = outPipe
process.standardError = Pipe()
+ let done = DispatchGroup()
+ done.enter()
+ process.terminationHandler = { _ in done.leave() }
do {
try process.run()
} catch {
return nil
}
- let data = outPipe.fileHandleForReading.readDataToEndOfFile()
- process.waitUntilExit()
+ if done.wait(timeout: .now() + .seconds(5)) == .timedOut {
+ process.terminate()
+ return nil
+ }
+ let data = outPipe.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0 else { return nil }
let value = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return (value?.isEmpty == false) ? value : nil
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private static func keychainPassword(service: String) -> String? { | |
| let process = Process() | |
| process.executableURL = URL(fileURLWithPath: "/usr/bin/security") | |
| process.arguments = ["find-generic-password", "-s", service, "-w"] | |
| let outPipe = Pipe() | |
| process.standardOutput = outPipe | |
| process.standardError = Pipe() | |
| do { | |
| try process.run() | |
| } catch { | |
| return nil | |
| } | |
| let data = outPipe.fileHandleForReading.readDataToEndOfFile() | |
| process.waitUntilExit() | |
| guard process.terminationStatus == 0 else { return nil } | |
| private static func keychainPassword(service: String) -> String? { | |
| let process = Process() | |
| process.executableURL = URL(fileURLWithPath: "/usr/bin/security") | |
| process.arguments = ["find-generic-password", "-s", service, "-w"] | |
| let outPipe = Pipe() | |
| process.standardOutput = outPipe | |
| process.standardError = Pipe() | |
| let done = DispatchGroup() | |
| done.enter() | |
| process.terminationHandler = { _ in done.leave() } | |
| do { | |
| try process.run() | |
| } catch { | |
| return nil | |
| } | |
| if done.wait(timeout: .now() + .seconds(5)) == .timedOut { | |
| process.terminate() | |
| return nil | |
| } | |
| let data = outPipe.fileHandleForReading.readDataToEndOfFile() | |
| guard process.terminationStatus == 0 else { return nil } | |
| let value = String(data: data, encoding: .utf8)? | |
| .trimmingCharacters(in: .whitespacesAndNewlines) | |
| return (value?.isEmpty == false) ? value : nil | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/Credentials.swift` around lines 39 - 56, The
keychainPassword(service:) function calls process.waitUntilExit() without a
timeout, causing credential refresh to block indefinitely if the
/usr/bin/security command hangs. Replace the unbounded process.waitUntilExit()
call with a timeout-aware mechanism using DispatchSemaphore or a polling loop
that monitors process termination with a configurable timeout duration (e.g., a
few seconds). If the timeout is exceeded before the process terminates,
terminate the process and return nil to allow the application to continue rather
than hanging indefinitely.
| let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] | ||
| var metrics: [UsageMetric] = [] | ||
|
|
||
| func window(_ key: String, name: String, period: Double) -> UsageMetric? { | ||
| guard let w = json[key] as? [String: Any], | ||
| let utilization = (w["utilization"] as? NSNumber)?.doubleValue | ||
| else { return nil } | ||
| let resetsAt = (w["resets_at"] as? String).flatMap(DateParsing.date(from:)) | ||
| return UsageMetric(name: name, percentage: utilization, resetsAt: resetsAt, periodSeconds: period) | ||
| } | ||
|
|
||
| if let m = window("five_hour", name: "5-hour", period: 5 * 3600) { metrics.append(m) } | ||
| if let m = window("seven_day", name: "Weekly", period: 7 * 24 * 3600) { metrics.append(m) } | ||
| if let m = window("seven_day_opus", name: "Opus", period: 7 * 24 * 3600), m.percentage > 0 { | ||
| metrics.append(m) | ||
| } | ||
|
|
||
| let maxUsage = metrics.map(\.percentage).max() ?? 0 | ||
| return ProviderStatus( | ||
| provider: .claude, | ||
| status: maxUsage >= 80 ? .warning : .ok, | ||
| plan: credentials.subscriptionType ?? "Pro", | ||
| metrics: metrics, | ||
| message: nil | ||
| ) |
There was a problem hiding this comment.
Fail fast when parsing yields no metrics.
Both providers can currently return a healthy status with metrics = [], which hides payload/schema breakage and silently drops the data pipeline.
Suggested fix
if let m = window("seven_day_opus", name: "Opus", period: 7 * 24 * 3600), m.percentage > 0 {
metrics.append(m)
}
+ guard !metrics.isEmpty else {
+ return .failure(.claude, "Unexpected API response format.")
+ }
+
let maxUsage = metrics.map(\.percentage).max() ?? 0
return ProviderStatus(
provider: .claude,
status: maxUsage >= 80 ? .warning : .ok,
plan: credentials.subscriptionType ?? "Pro",
@@
if let secondary = rateLimit?["secondary_window"] as? [String: Any],
let m = parse(secondary, primary: false) {
metrics.append(m)
}
+ guard !metrics.isEmpty else {
+ return .failure(.codex, "Unexpected API response format.")
+ }
+
let limitReached = (rateLimit?["limit_reached"] as? Bool) ?? false
let maxUsage = metrics.map(\.percentage).max() ?? 0
let planType = (json["plan_type"] as? String).map {
$0.prefix(1).uppercased() + $0.dropFirst()
} ?? "Unknown"Also applies to: 103-148
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/Providers.swift` around lines 41 - 65, The code currently
returns a healthy ProviderStatus even when no metrics are successfully
extracted, which silently masks schema changes or API failures. After collecting
metrics through the three window() function calls (five_hour, seven_day, and
seven_day_opus), add a check to ensure the metrics array is not empty before
returning the ProviderStatus. If metrics is empty after all window() attempts,
return a failure status instead of proceeding with the successful response.
Apply this same pattern to both provider status parsing blocks mentioned in the
comment (the claude provider section containing the window() function and the
second section referenced at lines 103-148).
| private func key(provider: ProviderName, metric: UsageMetric) -> String { | ||
| let reset = metric.resetsAt.map { String(Int($0.timeIntervalSince1970)) } ?? "none" | ||
| return "\(provider.rawValue)|\(metric.name)|\(reset)" | ||
| } |
There was a problem hiding this comment.
Do not persist samples for metrics without resetsAt.
These samples cannot be used by burndown computation and are never pruned (...|none keys bypass timestamp pruning), so history can grow without bound.
Suggested fix
`@discardableResult`
func record(provider: ProviderName, metric: UsageMetric, at date: Date = Date()) -> [UsageSample] {
+ guard metric.resetsAt != nil else { return [] }
queue.sync {
let k = key(provider: provider, metric: metric)
var samples = cache[k] ?? []Also applies to: 42-57, 61-70
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/UsageHistory.swift` around lines 34 - 37, Metrics without
a resetsAt value should not have their samples persisted because they cannot be
pruned and will accumulate indefinitely. Add a guard condition at the beginning
of all methods that persist or save samples (including the key method and the
methods referenced in lines 42-57 and 61-70) to check if the metric has a
resetsAt value, and return early or skip persistence if resetsAt is nil. This
prevents generating keys with "none" values that bypass timestamp-based pruning
logic.
| private func persist() { | ||
| guard let data = try? JSONEncoder().encode(cache) else { return } | ||
| try? data.write(to: fileURL) |
There was a problem hiding this comment.
Persist history.json atomically.
A non-atomic write can leave a truncated/corrupted file if the app is interrupted mid-write.
Suggested fix
private func persist() {
guard let data = try? JSONEncoder().encode(cache) else { return }
- try? data.write(to: fileURL)
+ try? data.write(to: fileURL, options: .atomic)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private func persist() { | |
| guard let data = try? JSONEncoder().encode(cache) else { return } | |
| try? data.write(to: fileURL) | |
| private func persist() { | |
| guard let data = try? JSONEncoder().encode(cache) else { return } | |
| try? data.write(to: fileURL, options: .atomic) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/UsageHistory.swift` around lines 73 - 75, The persist()
method currently performs a non-atomic write to fileURL that can result in a
corrupted or truncated file if the app is interrupted. Replace the current
write(to:) call with write(to:options:) and pass the .atomic option to ensure
the file is written atomically. This guarantees that either the entire file is
written successfully or no changes are applied, preventing data corruption from
interrupted writes.
| private var timer: Timer? | ||
| /// While set, scheduled (non-forced) refreshes are skipped to let a 429 clear. | ||
| private var backoffUntil: Date? | ||
| private var consecutiveRateLimits = 0 | ||
| private let providers: [ProviderName: UsageProvider] = [ | ||
| .claude: ClaudeProvider(), | ||
| .codex: CodexProvider(), | ||
| ] | ||
|
|
||
| init() { | ||
| if let raw = UserDefaults.standard.string(forKey: "selectedProvider"), | ||
| let provider = ProviderName(rawValue: raw) { | ||
| selectedProvider = provider | ||
| } else { | ||
| selectedProvider = .codex | ||
| } | ||
| start() | ||
| } | ||
|
|
||
| private func start() { | ||
| Task { await refresh(force: true) } | ||
| timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in | ||
| Task { await self?.refresh() } | ||
| } | ||
| } |
There was a problem hiding this comment.
Invalidate the repeating timer on teardown.
At Line 17-41, Timer is created but never invalidated. If this model is recreated, old timers can continue firing and duplicate network activity.
Suggested patch
final class UsageViewModel: ObservableObject {
@@
private var timer: Timer?
@@
+ deinit {
+ timer?.invalidate()
+ }
+
private func start() {
Task { await refresh(force: true) }
timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in
Task { await self?.refresh() }
}
}Also applies to: 125-125
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/UsageViewModel.swift` around lines 17 - 41, The timer
property created in the start() method is never invalidated, which can cause
duplicate network activity if the UsageViewModel is recreated. Add a deinit
method to the UsageViewModel class that invalidates the timer by calling
invalidate() on it and setting it to nil. Also check the location at line 125
mentioned in the comment to apply the same cleanup logic there as well.
| func refresh(force: Bool = false) async { | ||
| guard let provider = providers[selectedProvider] else { return } | ||
| if !force, let until = backoffUntil, Date() < until { return } | ||
| isLoading = true | ||
| let result = await provider.fetch() | ||
| let now = Date() | ||
|
|
There was a problem hiding this comment.
Prevent out-of-order refresh results from overwriting newer state.
At Line 57+, multiple refresh() calls can overlap across await provider.fetch(). A slower older call can later overwrite status/charts/lastUpdated from a newer call for the same provider.
Suggested direction
final class UsageViewModel: ObservableObject {
+ private var activeRefreshToken = UUID()
@@
func refresh(force: Bool = false) async {
+ let token = UUID()
+ activeRefreshToken = token
guard let provider = providers[selectedProvider] else { return }
if !force, let until = backoffUntil, Date() < until { return }
isLoading = true
let result = await provider.fetch()
+ guard token == activeRefreshToken else { return }
let now = Date()
@@
- status = result
- charts = built
- lastUpdated = now
- isLoading = false
+ status = result
+ charts = built
+ lastUpdated = now
+ isLoading = false
}
}Also applies to: 98-101
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AgentLimit/UsageViewModel.swift` around lines 57 - 63, The refresh()
method has a race condition where slower refresh calls can overwrite newer
results in status, charts, and lastUpdated properties. Add a tracking mechanism
such as a timestamp or request identifier captured before calling await
provider.fetch() for each provider, then compare this value before updating the
state after the fetch completes to ensure only the most recent refresh result is
applied. This applies both to the main refresh logic starting at Line 57 and the
additional instances at Lines 98-101 that update the same state properties.
A usage-varying icon width shifted the menu bar layout on every refresh. Pin the flame at 15pt; usage is still conveyed by the orange→red color and the fill/outline pace state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- release.sh: build → codesign (hardened runtime) → notarytool → staple → DMG, driven entirely by environment variables so it runs locally or in CI. - .github/workflows/release.yml: on a v* tag, import the Developer ID cert from secrets, build/sign/notarize, and publish the DMG to GitHub Releases. - README: Install (download the DMG) and Releasing (required secrets + tagging) sections; refresh stale menu-bar/icon/refresh descriptions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the release pipeline to mirror ../pear so one set of Apple secrets covers both repos: - Manual workflow_dispatch trigger (no tag push needed). - Date-based version YEAR.MONTH.N computed from the release count, stamped into Info.plist transiently (never committed). - Auto-generated changelog from commits since the last tag. - Notarize via App Store Connect API key (APPLE_API_KEY_*), sign via CSC_LINK/CSC_KEY_PASSWORD — the same secret names Pear uses. Throwaway CI keychain password is generated per-run; signing identity auto-detected. - Stable DMG name AgentLimit-arm64.dmg, published as latest so releases/latest/download/AgentLimit-arm64.dmg always resolves. - Publish to a draft then flip live, with a 3x notarization retry loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- App icon: a dark squircle with an orange→red gradient flame, matching the menu bar icon. Generated by scripts/make-icon.sh (renders the flame via scripts/generate-icon.swift, then sips + iconutil → App/AppIcon.icns). build.sh copies it into the bundle; Info.plist references it. - Quit: since the app is menu-bar-only (no Dock icon or app menu), add a subtle "Quit" button in the popover footer, bound to ⌘Q. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the footer Quit button with an invisible ⌘Q handler in the popover background, per request. Same behavior (quits while the popover is open) without the on-screen button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
scripts/generate-icon.swift (1)
34-36: ⚡ Quick winReplace force-unwrap/
try!write path with explicit error handling.The chained
!/try!can terminate the script without actionable diagnostics during packaging. Prefer guarded serialization anddo/catchwith clear stderr output and non-zero exit.Suggested patch
let out = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/tmp/icon_1024.png" -let png = NSBitmapImageRep(data: canvas.tiffRepresentation!)!.representation(using: .png, properties: [:])! -try! png.write(to: URL(fileURLWithPath: out)) +guard + let tiff = canvas.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) +else { + fputs("Failed to rasterize icon image data.\n", stderr) + exit(1) +} +do { + try png.write(to: URL(fileURLWithPath: out)) +} catch { + fputs("Failed to write icon to \(out): \(error)\n", stderr) + exit(1) +} print("wrote \(out)")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/generate-icon.swift` around lines 34 - 36, Replace the force-unwrap chains and try! statement in the canvas serialization and file writing section with proper error handling. Specifically, wrap the NSBitmapImageRep data conversion and the png.write call in a do/catch block that captures and prints detailed error information to stderr before exiting with a non-zero status code. Remove the chained force unwraps (!) from the NSBitmapImageRep initialization and representation method calls, and replace the try! on png.write with a throwing try statement inside the do block to enable actionable error diagnostics during packaging..github/workflows/release.yml (1)
17-20: ⚡ Quick winAdd
persist-credentials: falseto the checkout action.The checkout action persists credentials by default, which could be exposed if artifacts containing the
.gitdirectory are uploaded or if subsequent steps access git config. Addingpersist-credentials: falseis a low-effort security hardening.- name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/release.yml around lines 17 - 20, The checkout action in the release workflow is missing the persist-credentials security parameter. Add persist-credentials: false to the with section of the actions/checkout@v4 step to prevent git credentials from being persisted in the repository, which reduces the risk of credential exposure if artifacts containing the .git directory are uploaded or accessed in subsequent workflow steps.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@README.md`:
- Around line 44-45: The DMG download link uses a relative path
`../../releases/...` that walks above the repository root, causing it to break
on GitHub. Replace the relative path with an absolute repository-root relative
URL by using `/releases/latest/download/AgentLimit-arm64.dmg` instead, which
will correctly resolve on GitHub regardless of where the README is viewed from.
---
Nitpick comments:
In @.github/workflows/release.yml:
- Around line 17-20: The checkout action in the release workflow is missing the
persist-credentials security parameter. Add persist-credentials: false to the
with section of the actions/checkout@v4 step to prevent git credentials from
being persisted in the repository, which reduces the risk of credential exposure
if artifacts containing the .git directory are uploaded or accessed in
subsequent workflow steps.
In `@scripts/generate-icon.swift`:
- Around line 34-36: Replace the force-unwrap chains and try! statement in the
canvas serialization and file writing section with proper error handling.
Specifically, wrap the NSBitmapImageRep data conversion and the png.write call
in a do/catch block that captures and prints detailed error information to
stderr before exiting with a non-zero status code. Remove the chained force
unwraps (!) from the NSBitmapImageRep initialization and representation method
calls, and replace the try! on png.write with a throwing try statement inside
the do block to enable actionable error diagnostics during packaging.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 6bf1db1a-96b5-4b29-b0e6-3afd517b7253
📒 Files selected for processing (10)
.github/workflows/release.ymlApp/AppIcon.icnsApp/Info.plistREADME.mdSources/AgentLimit/AgentLimitApp.swiftSources/AgentLimit/ContentView.swiftbuild.shrelease.shscripts/generate-icon.swiftscripts/make-icon.sh
🚧 Files skipped from review as they are similar to previous changes (3)
- App/Info.plist
- build.sh
- Sources/AgentLimit/ContentView.swift
| > **[⬇ AgentLimit-arm64.dmg](../../releases/latest/download/AgentLimit-arm64.dmg)** | ||
| > (Apple Silicon) |
There was a problem hiding this comment.
Fix the DMG download link.
../../releases/... walks above the repo root here, so the link will break on GitHub. Use a repo-root relative link instead.
🔧 Suggested fix
-> **[⬇ AgentLimit-arm64.dmg](../../releases/latest/download/AgentLimit-arm64.dmg)**
+> **[⬇ AgentLimit-arm64.dmg](releases/latest/download/AgentLimit-arm64.dmg)**📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| > **[⬇ AgentLimit-arm64.dmg](../../releases/latest/download/AgentLimit-arm64.dmg)** | |
| > (Apple Silicon) | |
| > **[⬇ AgentLimit-arm64.dmg](releases/latest/download/AgentLimit-arm64.dmg)** | |
| > (Apple Silicon) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@README.md` around lines 44 - 45, The DMG download link uses a relative path
`../../releases/...` that walks above the repository root, causing it to break
on GitHub. Replace the relative path with an absolute repository-root relative
URL by using `/releases/latest/download/AgentLimit-arm64.dmg` instead, which
will correctly resolve on GitHub regardless of where the README is viewed from.
|
Superseded by AgentWorkforce/burn#478 — the app now lives in the burn monorepo at |
Summary
Visual + resilience pass on the menu bar app.
Menu bar icon
ImageRendererto a non-templateNSImageso the menu bar preserves the color instead of flattening it to monochrome (the%text stays adaptive).Provider icons (popover picker)
Rate-limit (429) handling
.rateLimitedstate: the panel keeps the last good reading with a quiet notice and backs off exponentially (capped at 15 min) instead of hammering the endpoint. Manual refresh / provider switch bypass the backoff.Housekeeping
node_modules/and.npm-cache/.Notes
./build.sh && open dist/AgentLimit.app.🤖 Generated with Claude Code