Imported from opgginc/opencode-bar (
AGENTS.md). Install upstream withnpx skills add opgginc/opencode-bar. Copyright stays with the author.
Important Restriction
- All of comments in code base, commit message, PR content and title should be written in English.
- If you find any Korean text, please translate it to English.
- UI Language
- All user-facing text in the app MUST be in English.
- Default review language: English.
Obey design decisions that specifically decided before by me
- All the decision is speicifed in
@AGENTS-design-decisions.mdfile.
Coding Rules
Branding Guides
- Official Brand:
OpenCode Bar- Don't use mis-capitalized or malformed forms like
Opencode Baror versions without the space (e.g.OpenCodeBar).
- Don't use mis-capitalized or malformed forms like
- File Name:
OpenCode Barif it allows whitespace.OpenCode-Barif it allows dash.opencode-bar(all lowercase) if the environment prefers lowercase file names.opencodebar(all lowercase, no separators) if it doesn't allow whitespace or dashes.
- Bundle ID:
com.copilotmonitor.CopilotMonitor
UI Styling Rules
- No colors for text emphasis: Do NOT use
NSColorattributes like.foregroundColorfor menu items or labels. - DO NOT USE SPACES TO ALIGN TEXT: Don't use spaces like " Words:" to align the spacing.
- Use instead:
- Bold:
NSFont.boldSystemFont(ofSize:)for important text - Underline:
.underlineStyle: NSUnderlineStyle.single.rawValuefor critical warnings - SF Symbols: Use
NSImage(systemSymbolName:accessibilityDescription:)for menu item icons - Emphasis: Use system colors like secondaryLabelColor and etc.
- Offset: Use offset for aligning text with other items and lines. Use the below constants.
- Bold:
- Do NOT use:
- Emoji: Never use emoji for menu item icons. Always use SF Symbols instead.
- RGB color: Use only pre-defined colors by system (systemGreen, systemOrange, and etc) to consider dark/light mode compatiblity.
- Exception:
- While you can't use color for text, progress bars and status indicators can use system color.
- You can use color for text which is right-aligned text only.
- Others
- Never use random spaces for separating label
- BEST:
- Left: "OpenRouter"
- Right: "$37.42" - Additional Custom View Label on the right with right-aligned text (by offset calculating)
- OK: "OpenRouter: $37.42"
- OK: "OpenRouter ($37.42)"
- NO: "OpenRouter $37.42" (stupid random spaces)
- BEST:
- USD
- Use only two decimals when expressing dollars. (e.g.
$00.00)
- Use only two decimals when expressing dollars. (e.g.
- Never use random spaces for separating label
Explicit 'used' or 'left'
- To avoid confusing of used % or left %, explicit if it's used or left on every labels.
Menu Item Layout Constants (MUST follow strictly)
All custom menu item views MUST use MenuDesignToken from Helpers/MenuDesignToken.swift:
// Usage examples
let width = MenuDesignToken.Dimension.menuWidth // 300
let height = MenuDesignToken.Dimension.itemHeight // 22
let fontSize = MenuDesignToken.Dimension.fontSize // 13
let iconSize = MenuDesignToken.Dimension.iconSize // 16
let geminiIconSize = MenuDesignToken.Dimension.geminiIconSize // 17
let leading = MenuDesignToken.Spacing.leadingOffset // 14
let leadingIcon = MenuDesignToken.Spacing.leadingWithIcon // 36
let trailing = MenuDesignToken.Spacing.trailingMargin // 14
let font = MenuDesignToken.Typography.defaultFont
let mono = MenuDesignToken.Typography.monospacedFont
let bold = MenuDesignToken.Typography.boldFont
let rightX = MenuDesignToken.rightElementX // 270 (computed)
- NEVER hardcode pixel values - always use
MenuDesignToken - ALWAYS reuse
createDisabledLabelView()when possible instead of creating custom NSView - When adding new constants, add them to
MenuDesignToken.swiftfirst, then update this section
Pre-Development Setup (MUST run before any work)
Before starting any development work, run:
make setup
This sets up git hooks for automated pre-commit checks:
- SwiftLint: Validates Swift code style
- action-validator: Validates GitHub Actions workflow YAML files
Build & Run Commands
Use the VSCode task "🐛 Debug: Kill + Build + Run" for development.
- Automatically detects DerivedData path via
xcodebuild -showBuildSettings - Works with multiple worktrees (each has its own DerivedData directory)
# Watch logs
log stream --predicate 'subsystem == "com.opencodeproviders"' --level debug
# or check file: cat /tmp/provider_debug.log
Instruction of each task
- In all changes, always write debugging log for actually printing before you confirming the feature is fully functional.
- After each change, follow:
- Clear cache and compile the binary
- Kill the existing process, and run the new app.
- Confirm if it works through logs.
Release Policy
- Workflow: STRICTLY follow
docs/RELEASE_WORKFLOW.mdfor versioning, building, signing, and notarizing. - Signing: All DMGs distributed via GitHub Releases MUST be signed with Developer ID and NOTARIZED to pass macOS Gatekeeper.
- Documentation: Update
README.mdand screenshots if UI changes significantly before release.
CI Universal Binary
- Release workflows must build universal macOS binaries (
arm64+x86_64) and verify withlipo -archsfor both the main app and the embedded CLI before notarization.
Tips
How to get quota usage?
- in
@/scripts/directory, you can see all of the scripts for every providers to get quota usage.
Architecture Patterns
Usage Authentication Ownership
- Usage collection reads existing provider credentials. Missing, expired, or rejected credentials must return authentication errors.
- Kiro quota queries must use the regional usage API and open its credential database with
SQLITE_OPEN_READONLY.
SwiftUI Shell with AppKit Core
The app uses a hybrid architecture:
- Entry Point:
App/ModernApp.swiftwith@mainattribute andMenuBarExtra - Menu System: NSMenu-based via
StatusBarControllerfor full native menu features - Bridge Pattern:
MenuBarExtraAccesslibrary connects SwiftUIMenuBarExtratoNSStatusItem
// ModernApp.swift bridges SwiftUI MenuBarExtra to NSMenu
MenuBarExtra { ... }
.menuBarExtraAccess(isPresented: $isMenuPresented) { statusItem in
controller.attachTo(statusItem) // Attach NSMenu to status item
}
Actor-Based Provider Architecture
All providers use Swift actors for thread-safe state management:
actor ProviderActor {
private var cache: CachedData?
private var isLoading = false
func fetchData() async throws -> ProviderUsage {
guard !isLoading else { return cachedData }
isLoading = true
defer { isLoading = false }
// fetch logic...
}
}
- Benefits: Eliminates data races, no manual locking needed
- Pattern: Use
@MainActorfor UI updates, actors for data fetching - Conversion: Replace
classwithactor, removeDispatchQueue.main.async
MenuDesignToken Usage
All menu item layouts MUST use MenuDesignToken constants from Menu/MenuDesignToken.swift:
// Use tokens instead of magic numbers
textField.frame.origin.x = MenuDesignToken.leadingOffset // NOT: 14
progressBar.frame.size.width = MenuDesignToken.progressBarWidth // NOT: 100
- Typography:
MenuDesignToken.primaryFont,MenuDesignToken.secondaryFont - Spacing:
MenuDesignToken.leadingOffset,MenuDesignToken.trailingMargin - Dimensions:
MenuDesignToken.menuWidth,MenuDesignToken.itemHeight
MenuBuilder Pattern
Use @MenuBuilder for declarative menu construction:
@MenuBuilder
func buildProviderSubmenu() -> [NSMenuItem] {
MenuItem("Refresh") { refresh() }
Separator()
ForEach(providers) { provider in
MenuItem(provider.name) { ... }
}
}
Error Handling & API Fallbacks
-
API Response Type Flexibility: External APIs may return different types than expected
- Numeric Fields Can Be Strings: Fields like
balancemay come as String instead of Double/Int - Optional Fields May Vary: Some providers return fields that others don't (e.g.,
reset_at,limit_window_seconds) - Pattern: Add computed properties for type conversion (e.g.,
balanceAsDoubleconverts String to Double) - Example Fix: Codex
balancereturned as String, addedbalanceAsDoublecomputed property for conversion
- Numeric Fields Can Be Strings: Fields like
-
Cache Fallback on External Command Failures: When external CLI/API commands fail, use cached data
- Graceful Degradation: Wrap external command calls in try-catch to prevent app crashes
- Fallback Calculation: Calculate metrics from cached data when external command fails
- Example: OpenCode Zen
stats --dayscommand failure falls back to cache for totalCost display - Pattern: Add
calculateTotalCostFromCache()method, wrap stats load in try-catch - UI Feedback: Show
[stats: cached]label to indicate fallback mode - Benefit: App remains functional even when external tools are temporarily unavailable
-
Appcast XML Generation in Workflows: Heredoc can cause XML parsing errors
- Problem: Using heredoc to write appcast.xml in GitHub Actions workflows causes parsing failures
- Root Cause: Heredoc indentation or whitespace issues corrupt XML structure
- Fix: Use
printfcommand instead of heredoc for XML generation - Example: Changed from
cat << 'EOF' > appcast.xmltoprintf '%s\n' '...' > appcast.xml - Pattern: Use printf for structured data files (XML, JSON) in CI/CD workflows
-
NSNumber Type Handling: API responses may return
NSNumberinstead ofIntorDouble- Always check for
NSNumbertype when parsing numeric values from API responses - Pattern:
value as? NSNumber→doubleValue/intValue - Example failure: Cost showing wrong value due to missing NSNumber handling
- Always check for
-
Menu Bar App (LSUIElement) Special Requirements:
- UI Display: Must call
NSApp.activate(ignoringOtherApps: true)before showing update dialogs - Target Assignment: Menu item targets must be explicitly set to
NSApp.delegate(notself) - Window Management: Close blank Settings windows on app launch
- UI Display: Must call
-
Swift Concurrency & Actor Isolation:
- Task Capture: Always use
[weak self]in Task blocks to avoid retain cycles - MainActor: Use
@MainActor [weak self]pattern when updating UI from async contexts - Pre-compute Values: Cache values like
refreshInterval.titlebefore closures to avoid actor isolation issues - NotificationCenter Pattern: Use
guard let self = self else { return }before Task, then[weak self]in Task capture list
- Task Capture: Always use
-
Usage Calculation Completeness:
- Total Requests: Always sum both
includedRequestsANDbilledRequestsfor accurate predictions - Prediction Algorithms: Use
totalRequests(not justincluded) for weighted average calculations - UI Display: Show total requests in daily usage breakdown, not just included
- Total Requests: Always sum both
-
Multi-Window Quota Summary Display:
- If a provider exposes multiple quota windows/limits, show all relevant usage percentages in the top-level quota row (comma-separated).
- Keep a stable order: primary window first (e.g., 5h) then secondary (weekly/MCP).
- Examples: Claude/Kimi (5h, 7d), Codex (5h, weekly), Z.AI (5h, MCP).
-
DMG Packaging Cleanliness:
- Staging Directory: Create clean staging dir containing ONLY app bundle and Applications symlink
- Exclude Files: Prevent
Packaging.log,DistributionSummary.plist, and other Xcode artifacts from DMG - Pattern:
mkdir -p staging; cp -R app.app staging/; ln -s /Applications staging/
-
Provider Type Classification:
- API Structure Determines Type: Provider billing model must match API response structure
- Quota-based Indicators:
used_percent,remaining,entitlementfields indicate quota-based model - Pay-as-you-go Indicators:
credits,usage,utilizationfields indicate pay-as-you-go model - Example Fix: Codex initially classified as pay-as-you-go, but API returns
used_percent→ corrected to quota-based - Test Alignment: When fixing provider type, update corresponding tests to match new implementation
-
Menu Bar Item Lifecycle Management:
- Hidden Items Keep Objects Alive: Use
isHidden = trueinstead of commenting out to prevent nil reference crashes - Implicitly Unwrapped Optionals:
NSMenuItem!properties require initialization even if hidden from UI - Example Failure: Commenting out menu items causes crashes when validation logic still references them
- Pattern: Always initialize menu items that may be referenced elsewhere, control visibility via
isHidden
- Hidden Items Keep Objects Alive: Use
-
Language Policy Enforcement:
- All Log Messages Must Be English: Including debug logs, error messages, and informational logging
- Pattern: After code changes, review log messages for Korean text and translate to English
- Example: Replace non-English log strings with clear English equivalents (e.g., "fetchUsage started", "API ID obtained successfully").
- Reference: AGENTS.md Language section requires all code comments, logs, and messages to be in English
-
Debug Code Cleanup:
- Remove Development Logging Before Commits: File-based debug logging (
/tmp/*.log) should be removed after troubleshooting - Debug Pattern: File I/O for debugging should use
#if DEBUGguard or be removed entirely - Example: Removed 110 lines of debug logging from
fetchMultiProviderData()andupdateMultiProviderMenu()after multi-provider feature stabilized - Pattern: Production code should only use structured logging (
os.log/Logger), not ad-hoc file writing
- Remove Development Logging Before Commits: File-based debug logging (
-
Access Modifier Awareness During Refactoring:
- Internal Access for Cross-Module Dependencies: Properties needed by external classes may need broader access (e.g.,
varinstead ofprivate var) - Example:
predictionPeriodMenuchanged fromprivate vartovarwhencreateCopilotHistorySubmenu()moved to separate builder - Example:
getHistoryUIState()changed fromprivate functofuncwhen accessed from external menu builders - Pattern: When extracting code to separate files, verify access modifiers still allow required dependencies
- Internal Access for Cross-Module Dependencies: Properties needed by external classes may need broader access (e.g.,
-
Process.waitUntilExit() Blocking Issue:
- Synchronous Blocking:
Process.waitUntilExit()is a blocking call even in async contexts - Multi-Provider Impact: Blocking providers prevent other providers from completing fetch operations
- Async Solution: Use
withCheckedThrowingContinuationwithterminationHandlerandreadabilityHandlerfor non-blocking process execution - Example: AntigravityProvider uses
runCommandAsync()wrapper; OpenCodeZenProvider useswithCheckedThrowingContinuationpattern - Pattern: Replace
waitUntilExit()with async closure-based APIs using Process.terminationHandler and Pipe.readabilityHandler - Parallel Fetching Enables: Once processes are non-blocking, all providers can fetch in parallel efficiently
- Synchronous Blocking:
-
Menu Rebuild Strategy:
- Tag-Based Item Removal: Use unique tags (e.g., tag 999) for dynamically generated menu items
- Clean Rebuild Pattern: Remove all items with specific tag, then rebuild menu section from scratch
- Separation of Concerns: Static menu items (tag 0) vs dynamic provider items (tag 999)
- Example:
menu.items.removeAll(where: { $0.tag == 999 })clears old provider data before fresh rebuild - Pattern: Tag-based filtering ensures clean menu updates without duplicating items
-
CI/CD Build Strategy:
- Export Archive Failure:
xcodebuild -exportArchivecan fail in CI environments with signing issues - Direct Copy Solution: Use
cp -Rto copy app bundle from archive directly instead of exportArchive for unsigned builds - Pattern: For unsigned builds in CI, copy from
build/*.xcarchive/Products/Applications/*.appto export directory - Code Signing Style: Set
CODE_SIGN_STYLE=Manualin CI to prevent automatic signing conflicts - Sparkle Signature Parsing: Use
awk -F '"' '{print $2}'to extract signature from sign_update output - Example Fix: Changed from exportArchive to direct copy to resolve CI build failures
- Export Archive Failure:
-
Provider Data Ordering:
- API Returns Unordered Data: Dictionary iteration order is not guaranteed in Swift
- Menu Display Issue: Provider items appearing in different order on each refresh cycle
- Root Cause: Looping over
[ProviderIdentifier: ProviderResult]dictionary without explicit ordering - Solution: Create explicit display order array or use sorted keys when iterating
- Example:
let providerDisplayOrder = ["opencode_zen", "gemini_cli", "claude", "openrouter", "antigravity"] - Pattern: Define display order independently of data source to maintain consistent UI
-
Z.AI API Date Format:
- Parameter Validation Error: Z.AI API returns 500 error when using milliseconds timestamp for
startTime/endTime - Requirement: API explicitly requires
yyyy-MM-dd HH:mm:ssformat string - Fix: Use
DateFormatterwithUTCtimezone instead ofInt64timestamps - Pattern: Always verify API time format requirements (timestamp vs ISO8601 vs custom string)
- Example:
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
- Parameter Validation Error: Z.AI API returns 500 error when using milliseconds timestamp for
-
Menu Item Reference Deadlock:
- Shared NSMenuItem Reference: Referencing the same NSMenuItem instance (like
predictionPeriodMenu) from multiple submenus can cause deadlocks - Manifestation: Menu becomes unresponsive or hangs when clicking on submenu items
- Root Cause: NSMenuItem is not thread-safe when shared across different menu hierarchies
- Solution: Always create fresh NSMenu instances for dynamically generated submenus instead of reusing shared references
- Example Failure:
predictionPeriodMenu.submenu = predictionPeriodMenucaused deadlock in history submenu - Fix: Create new
let periodSubmenu = NSMenu()and rebuild contents instead of referencing existing menu - Pattern: Use constructor pattern to create independent menu objects for each submenu instance
- Shared NSMenuItem Reference: Referencing the same NSMenuItem instance (like
-
Loading State Management in Parallel Async Operations:
- Parallel Provider Fetching: All providers should fetch in parallel to minimize total fetch time
- Loading State Tracking: Use
Set<ProviderIdentifier>to track providers currently fetching - Pre-Fetch Menu Update: Update menu before starting fetch to show "Loading..." state
- Post-Fetch Menu Update: Remove loading state and replace with actual data after fetch completes
- Loading Item Styling: Show "Loading..." text with disabled
isEnabled = falsestate - Pattern:
loadingProviders.insert(identifier)→updateMenu()→ await fetch →loadingProviders.remove(identifier)→updateMenu()
-
Daily History Cache Strategy:
- Hybrid Approach: Fetch fresh data for recent days (today/yesterday), serve older data from cache
- Timeout Risk Reduction: Reduce external CLI/API calls significantly (e.g., 7 calls → 2 calls = 71% reduction)
- UserDefaults Cache: Use Codable structures with JSON encoding for simple persistence
- Cache Validation: Check date before using cached data to avoid stale information
- Sequential Internal Loading: Each provider can load history day-by-day sequentially with caching making it acceptable
- Pattern: Load cache → fetch recent → merge → save updated cache
-
Multiline Text Handling in Custom Views:
- Long Path Truncation: Displaying long file paths or URLs in disabled label views can cause content truncation
- Pattern: Add
multilineparameter to custom view creation functions - Dynamic Height Calculation: When multiline is enabled, calculate view height based on text content size
- Implementation:
string.boundingRect(with: size, options: .usesLineFragmentOrigin, attributes: context) - Example: 'Token From:' display showing full auth file path instead of truncated version
-
Progressive Loading Race Condition Prevention:
- Loading State Must Precede Cleanup: Set loading state BEFORE any cleanup or killing logic
- Race Condition Pattern: Multiple fetch calls check state (not loading), spawn tasks, then all call
killExistingOpenCodeProcesses()before state is set - Symptom: Logs show repeated "Killed existing processes" and "Starting progressive fetch" messages
- Fix: Move loading state assignment to start of function, before any cleanup operations
- Or: Add second state check after cleanup to ensure only one instance proceeds
- Pattern:
isLoading = true→killExistingProcesses()→ proceed with fetch
-
Custom Menu View Layout Consistency with createDisabledLabelView:
- Vertical Alignment Mismatch:
createDisabledLabelViewusescenterYAnchor, custom views usingy: 3causes pixel misalignment - Text Color Mismatch:
createDisabledLabelViewusessecondaryLabelColor, usingdisabledControlTextColorin custom views causes color difference - Solution: When creating custom menu item views that should align with
createDisabledLabelViewitems:- Use
translatesAutoresizingMaskIntoConstraints = falseandcenterYAnchorfor vertical alignment - Use
secondaryLabelColorfor text color consistency - Use
indentparameter instead of spaces for horizontal indentation
- Use
- Pattern: Match exactly what
createDisabledLabelViewdoes internally - Example Fix: Pace row changed from
frame.y = 3tocenterYAnchor.constraint(equalTo: view.centerYAnchor)
- Vertical Alignment Mismatch:
-
Text Truncation in Custom Menu Views:
- Fixed-Width Problem: Hard-coding text field widths causes truncation for dynamic content (file paths, URLs, long labels)
- Solution: Use
NSTextField.sizeToFit()to calculate exact text width before positioning elements - Layout Pattern: Position elements from right edge (using
rightElementX) moving left to accommodate variable-width text - Pattern: Right-to-left layout prevents text overflow while maintaining alignment with fixed elements
-
Menu Bar Icon Appearance Detection:
- App Appearance Mismatch: Using
NSApp.effectiveAppearancefor menu bar icon color detection causes black text in light mode - Root Cause: Menu bar background can differ from app background appearance
- Solution: Use
self.effectiveAppearance(view's own appearance) for NSStatusBarButton contexts - Vertical Alignment: Adjust offset from
y:3toy:4/5for better visual alignment with other menu bar items - Pattern: Always check appearance at the view level, not the app level
- App Appearance Mismatch: Using
-
Status Bar Provider Icon Layering & Gemini Scale:
- Keep Original Icon: The primary OpenCode Bar status icon must remain visible at all times
- Additive Provider Icon: Provider identity icon is appended next to the primary icon, not substituted
- Setting Label: Use
Show Provider Iconnaming for status bar option labels - Gemini Scale Rule: Gemini provider icon should render slightly larger than default provider icons
- Token Rule: Use
MenuDesignToken.Dimension.geminiIconSizefor Gemini-sized assets
-
Cache Timezone Consistency:
- UTC vs Local Timezone Mismatch: Cache dates stored in UTC but calendar was using local timezone (KST)
- Comparison Failures:
isDate(..., inSameDayAs:)comparisons failing due to timezone mismatch - Consequence: Cache saved but never used during progressive loading, causing unnecessary API calls
- Solution: Use UTC calendar for all date comparisons to match cache storage format
- Pattern:
calendar.timeZone = TimeZone(abbreviation: "UTC") ?? TimeZone.current
-
ISO8601 Date Parsing Flexibility:
- Fractional Seconds in API: API responses may include fractional seconds (e.g., "2026-02-05T14:59:30.123456Z")
- Parsing Failure: Basic ISO8601DateFormatter() doesn't handle fractional seconds by default
- Solution: Try parsing with
.withFractionalSecondsfirst, then fallback to without - Pattern: Define helper function that attempts multiple format options sequentially
- Example:
formatterWithFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
Process.readabilityHandler Concurrent Mutation:
- Swift Concurrency Error: Modifying local variable in readabilityHandler triggers actor isolation error
- Pattern: Use
nonisolated(unsafe)foroutputDatain async Process handlers - Safety Guarantee: Handlers are serialized by Process lifecycle, making this safe
- Example Fix: OpenCodeZenProvider and AntigravityProvider both use this pattern
- Implementation:
nonisolated(unsafe) var outputData = Data()
-
Error Status Display Instead of Loading: Show meaningful status when auth is missing or errors occur
- Authentication Errors: Use
isAuthenticationError()function to detect missing credentials - Status Labels: Show "No Credentials" for auth errors, "Error" for general errors
- Infinite Loading Prevention: Remove failed providers from
loadingProvidersset to prevent repeated attempts - Example Fix: Pay-as-you-go, Quota Status, and Gemini CLI sections show proper error states
- Pattern: Try-catch wrapper → check error type → update UI with status → remove from loading set
- Authentication Errors: Use
-
SwiftUI MenuBarExtra + AppKit NSStatusItem Duplication:
- Problem: Using both SwiftUI
MenuBarExtraAND creatingNSStatusItemdirectly causes TWO menu bar icons - Root Cause: Each approach independently creates a status bar item
- Solution: Use ONLY ONE approach - either SwiftUI MenuBarExtra with bridge, or pure AppKit NSStatusItem
- Current Pattern: SwiftUI MenuBarExtra with
isInserted: $isMenuEnabledset tofalse, AppKit NSStatusItem handles everything - Anti-Pattern: Creating
NSStatusBar.system.statusItem()while also usingMenuBarExtra { } - Example Fix: Set
@State private var isMenuEnabled = falseand useMenuBarExtra(isInserted: $isMenuEnabled)
- Problem: Using both SwiftUI
-
Prediction Range Boundary Safety:
- Negative Range Assertion: Counting remaining days can result in
remainingDays <= 0on month-end - Crash Location:
1...remainingDaysrange assertion fails when negative or zero - Solution: Add guard clause before range iteration to return early
- Pattern:
guard remainingDays > 0 else { return (0, 0) } - Context: Usage prediction on last day of month causes EXC_BREAKPOINT crash within 1-3 seconds of app launch
- Negative Range Assertion: Counting remaining days can result in
-
TimeZone Force Unwrap Safety:
- Force Unwrap Risk:
TimeZone(identifier: "UTC")!can crash if identifier is invalid - Safe Initialization: Use optional binding with fallback to system timezone
- Pattern:
if let utc = TimeZone(identifier: "UTC") { cal.timeZone = utc } else { cal.timeZone = TimeZone.current } - Application: All calendar instances that require UTC timezone for date calculations
- Example Fix: UsagePredictor, UsageHistory, StatusBarController updated with safe initialization
- Force Unwrap Risk:
-
Loading Menu Item Style Consistency:
- Standard NSMenuItem: Use
NSMenuItem(title:action:keyEquivalent:)instead ofcreateDisabledLabelViewfor loading states - Disabled State: Set
isEnabled = falseto visually indicate loading without custom views - Alignment Benefits: Standard NSMenuItem aligns perfectly with regular menu items, avoiding custom view pixel mismatches
- Pattern:
let item = NSMenuItem(title: "Loading...", action: nil, keyEquivalent: ""); item.isEnabled = false - Example Fix: Pay-as-you-go, Quota Status, and Gemini CLI loading items unified to use standard NSMenuItem
- Standard NSMenuItem: Use
-
GitHub Actions YAML Indentation Syntax Errors:
- Strict Indentation Rules: YAML is whitespace-sensitive and incorrect indentation causes workflow failures
- Common Mistake: Excessive indentation makes steps appear nested incorrectly (e.g., 14 spaces instead of 6)
- Validation: GitHub Actions checks YAML syntax before execution and reports indentation errors
- Pattern: Each job step should be at consistent indentation level (typically 6 spaces for top-level job steps)
- Example Fix: Fixed
- name: Create Releasestep indentation from 14 spaces to 6 spaces - Affected Files:
build-release.yml,manual-release.ymlwhen adding release steps - Prevention: Use YAML linters or validate syntax with
yamllintbefore committing
-
Menu Update Debouncing:
- Redundant Rebuild Issue: Multiple
updateMultiProviderMenucalls occurring in quick succession - Symptoms: Logs showing repeated menu structure outputs with identical data
- Root Cause: No debouncing mechanism to prevent concurrent or rapid successive update requests
- Solution: Implement debounce timer or check if update is already in progress before triggering new rebuild
- Pattern:
updatePending = false→ schedule update → check!updatePendingbefore proceeding → setupdatePending = true
- Redundant Rebuild Issue: Multiple
-
GitHub Actions Workflow Input Handling:
- Event Type Dependencies: Workflow inputs may not be available on all event types
- Example Failure:
inputs.versionundefined on push/PR events, causing workflow failure - Solution: Use environment variable gating with
event_namechecks before accessing inputs - Pattern: Check
github.event_nameto determine if dispatch event with manual inputs - Prevention: Use conditional logic or provide default values for non-dispatch events
-
Progressive Loading Performance Optimization:
- Problem: Each progressive loading step triggers full menu rebuild, causing UI flicker and performance degradation
- Symptom: Logs show repeated "updateMultiProviderMenu: started" with "Loading day X/30..." messages
- Root Cause: OpenCode Zen fetches 30 days sequentially, calling full menu update after each day
- Solution: Implement partial menu updates during progressive loading
- Update only the specific submenu item (e.g., "Loading day X/30...")
- Avoid rebuilding entire menu structure for incremental updates
- Use menu item references to update in-place instead of full rebuild
- Performance Impact: 30 days × full rebuild = ~30x slower than necessary
- Pattern:
updateLoadingProgress(dayIndex)→ update single loading item →updateMenu()only on completion
-
Debug Log Frequency Control:
- Excessive Logging:
logMenuStructure()called on every menu update produces massive log output - Symptom: Logs show repeated identical menu structures hundreds of times during progressive loading
- Solution: Use debug guards or log level controls for verbose structure logging
- Pattern:
#if DEBUG logMenuStructure()or move to trace-level logging instead of debug - Recommendation: Disable structure logging after initial validation, enable only when debugging menu issues
- Excessive Logging:
-
Pace Row Overlap Prevention:
- Overlap Risk: Left and right labels can collide when the right-side status text grows long
- Solution: Use Auto Layout with compression resistance priorities and right-aligned constraints
- Pattern: Left label
.defaultLow, right label.required, andlessThanOrEqualTospacing
-
Used-Up Pace Label Suppression:
- Clarity Issue: Showing "Pace: Used Up" alongside wait text is redundant and noisy
- Solution: Hide the pace label entirely when usage is exhausted
- Pattern:
leftTextField.isHidden = truewhenisExhaustedis true
-
Wait Time Formatting Rules:
- Consistency Need: Different wait durations should display with predictable granularity
- Rule: Show
Xd Yhfor 1d+,Xhfor 1h+, andXmfor under 1h - Pattern: Centralize in a single formatter function for all used-up messages
-
Silent Error Handling for Non-Critical Operations:
- Empty Catch Blocks: Use
catch {}for operations where failure doesn't impact core functionality - Appropriate Use Cases: Optional config file reading, CLI binary detection, environment discovery
- Safety Consideration: Only use when operation is truly non-critical and has fallback behavior
- Example: TokenManager uses silent catches for finding opencode binary - app still works if not found
- Pattern:
try? readOptionalConfig()or} catch { // Ignore non-critical failures }
- Empty Catch Blocks: Use
-
AppleScript Path Escaping for Security:
- Command Injection Risk: Direct string interpolation in AppleScript allows path manipulation attacks
- Solution: Use AppleScript's
quoted form ofto safely escape paths with spaces and special characters - Security Impact: Prevents privilege escalation via crafted app bundle paths
- Implementation Pattern:
set cliPath to "\(cliPath)" do shell script "mkdir -p /usr/local/bin && cp " & quoted form of cliPath & " /usr/local/bin/opencodebar && chmod +x /usr/local/bin/opencodebar" - Example Fix: CLI installation AppleScript now safely handles paths containing spaces or special characters
-
CLI Exit Code Consistency:
- Empty Results Handling: Exit with error code 1 when no provider data is available (not 0)
- Error Output: Write error messages to stderr instead of stdout for proper scripting error detection
- Pattern: Check
guard !results.isEmpty, write toFileHandle.standardError, exit withCLIExitCode.generalError.rawValue - Automation Benefit: Scripts can detect failures via exit code 1 instead of success code 0
-
Timeout-Based Error Handling with withThrowingTaskGroup:
- Prevent API Hangs: Use
withThrowingTaskGroupwith timeout task to prevent indefinite waits - Configurable Timeout: Default 30s timeout allows slow APIs to complete but prevents deadlocks
- Cancellation: First success cancels all other tasks (timeout or fetch)
- Pattern: Add timeout task with
Task.sleep(nanoseconds:)that throws after configured duration - Example: ProviderManager.swift fetchWithTimeout() prevents unresponsive providers from blocking entire fetch cycle
- Prevent API Hangs: Use
-
Retry Pattern with Exponential Backoff:
- Transient Failure Recovery: Retry operations that may fail temporarily with increasing delays
- Max Retries: Typically 3 attempts before giving up
- Backoff Delay: 500ms between retries is common pattern in this codebase
- Pattern:
for attempt in 1...maxRetries { try { return op } catch { await Task.sleep(backoff) } } - Example: OpenCodeZenProvider retries stats command up to 3 times with 500ms delays
-
Graceful Degradation for Multi-Account Providers:
- Partial Success Handling: Continue processing when some accounts fail but others succeed
- Multi-Provider Architecture: Gemini CLI supports multiple accounts, treat as partial success if any succeed
- Failure Condition: Only throw error if ALL accounts fail, not just some
- Pattern: Loop through accounts with try-catch, collect successes, throw only if results array empty
- Example: GeminiCLIProvider shows quota for working accounts even if one account's auth fails
-
Specific Error Type Catching:
- Precise Error Classification: Catch specific error types for better error messages and handling
- Distinguish Failure Modes: Separate decoding errors from network errors vs authentication failures
- Pattern:
catch let error as DecodingError { throw ProviderError.decodingError(...) } catch { ... } - Example: ClaudeProvider catches DecodingError separately to provide more helpful parsing error messages
-
Debug Log Emoji Prefix Convention:
- Standardized Status Indicators: Use consistent emoji for log message types across all components
- Status Emojis: 🔵 (blue) for start, 🟡 (yellow) for in-progress, 🟢 (green) for success, 🔴 (red) for failure
- Action Emojis: ⌨️ for keyboard shortcuts, 📋 for menu structure, 🔄 for refresh operations
- Result Emojis: ✅ for success results, ❌ for errors, ⚠️ for warnings
- Pattern:
[🔵 ComponentName] Starting operation,[🟢 ComponentName] Operation completed: 42 items - Benefit: Easy visual scanning of logs to track execution flow and identify issues quickly
-
Consistent Debug Log Function Pattern:
- Reusable Helper: Each component has
debugLog()function for centralized logging control - Append-Only: Always append to existing log file using
seekToEndOfFile()to preserve history - Timestamp Prefix: Include
[\(Date())]at start of each message for chronological tracking - Component Identification: Prefix messages with component name (e.g., "ProviderManager:", "OpenCodeZen:")
- Guard with #if DEBUG: Completely removes debug code from Release builds automatically
- Pattern: File-based logging to /tmp/provider_debug.log with graceful silent failure
- Safety: Use
try?for file operations to avoid crashing if log file is inaccessible
- Reusable Helper: Each component has
-
Dynamic Binary Discovery for External Tools:
- Hardcoded Path Problem: Single hardcoded path like
~/.opencode/bin/opencodefails when users install via Homebrew or other methods - Multi-Strategy Search: Implement fallback search pattern with multiple discovery methods
- Search Priority: 1) 'which opencode' in current PATH, 2) Login shell PATH (captures shell profile additions), 3) Common install locations (Homebrew, OpenCode default, pip/pipx)
- Lazy Loading: Cache binary path on first successful discovery to avoid repeated searches
- Debug Logging: Show which discovery method found the binary for troubleshooting
- Example Fix: OpenCodeZenProvider changed from hardcoded path to dynamic multi-strategy search
- Pattern: Replace single path with search function that tries multiple locations in priority order
- Hardcoded Path Problem: Single hardcoded path like
-
Multi-Strategy Auth File Discovery:
- Single Path Problem: Hardcoded
~/.local/share/opencode/auth.jsonfails when users have different OpenCode configurations - XDG Standard Support: Respect $XDG_DATA_HOME environment variable when set (highest priority)
- Fallback Priority: 1) $XDG_DATA_HOME/opencode/auth.json (if set), 2) ~/.local/share/opencode/auth.json (XDG default), 3) ~/Library/Application Support/opencode/auth.json (macOS convention)
- Path Tracking: Store
lastFoundAuthPathto show users which file was loaded for troubleshooting - Sequential Try Pattern: Loop through paths and use first one that exists and is valid
- Example Fix: TokenManager added getAuthFilePaths() function and sequential try-catch loop
- Pattern: Define priority array of possible paths, try each in order, return first valid result
- Single Path Problem: Hardcoded
-
Sparkle Auto-Relaunch for Menu Bar Apps:
- LSUIElement Special Case: Menu bar apps (LSUIElement=true) don't automatically relaunch after Sparkle updates
- Missing Relaunch: After update completes, app quits but doesn't restart, requiring manual launch
- Solution: Add SUAllowsAutomaticUpdates to Info.plist and implement SPUUpdaterDelegate with lifecycle hooks
- Required Methods:
updaterWillRelaunchApplication(_:)andupdaterDidRelaunchApplication(_:)in AppDelegate - Pattern: Sparkle framework handles restart automatically when delegate is properly configured
- Example Fix: Added SUAllowsAutomaticUpdates=Yes to Info.plist and SPUUpdaterDelegate implementation
-
Dependency Version Pinning for CI Compatibility:
- Experimental Feature Error: Newer package versions may require Swift experimental features not available in CI
- Example Failure: ArgumentParser 1.7.0 required 'AccessLevelOnImport' experimental feature
- CI Compatibility Solution: Pin to specific version using exactVersion instead of upToNextMajorVersion
- Pattern: Use exact version in Package.resolved to ensure consistent builds across environments
- Trade-off: Sacrifice latest features for build stability, update pinning only after verifying CI compatibility
- Example Fix: Pinned ArgumentParser from 1.7.0 to 1.5.0 in Package.resolved
-
Subscription Preset Comparison:
- Name Uniqueness Problem: Comparing subscription presets by name can fail when multiple presets share the same name
- Example Failure: 'MAX' preset exists at both $100 and $200 cost levels, causing incorrect selection
- Solution: Compare by numeric cost value instead of string name to ensure unique identification
- Pattern: Use
costfield or numeric identifier when comparing/selecting presets with potentially duplicate names - Context: Menu item selection relies on accurate preset matching for correct pricing display
-
Copilot Quota Reset Date Tracking:
- Missing Reset Date: Copilot quota reset date wasn't tracked in DetailedUsage struct
- Usage Prediction Impact: Without reset date, cannot accurately predict end-of-month quota usage
- Fix: Add
copilotQuotaResetDateUTCfield to DetailedUsage model and update encoding/decoding - Pattern: When adding provider-specific data, ensure Codable conformance includes all new fields
- Example: CopilotProvider now passes
usage.quotaResetDateUTCto DetailedUsage constructor
-
Test Model Alignment:
- Test-Implementation Mismatch: Tests using wrong billing model (payAsYouGo vs quotaBased) cause assertion failures
- Example Failure: CodexProviderTests used payAsYouGo model while provider is quotaBased
- Fix: Update test models to match actual provider implementation (remaining/entitlement vs utilization/cost)
- Pattern: Verify test models match provider type when fixing provider bugs
- Validation: Check both assertions and expected field types (Int vs Double, optional vs required)
-
Code Signing Timestamp Service Retry:
- Timestamp Service Failures:
codesign --timestampmay fail due to external service unavailability in CI/CD - Build Impact: Xcode archive and code signing operations fail when timestamp service is down
- Solution: Implement retry logic with exponential delays for both build and signing stages
- Retry Pattern:
- Build: Max 3 retries with 30s delay between attempts
- Signing: Max 3 retries with 10s delay between attempts
- Function: Create
sign_with_retry()wrapper function for all codesign calls
- Example: GitHub Actions workflow uses for-loop with attempt counter and sleep delays
- Benefit: Improves CI/CD reliability when external timestamp services experience temporary outages
- Timestamp Service Failures:
-
Common Deduplication Logic for Multi-Account Providers:
- Code Duplication: Each provider had its own
dedupeCandidatesmethod with identical logic - Maintenance Burden: Changes require updating 4+ provider files separately (Claude, Codex, Copilot, Gemini)
- Fix: Extract shared
CandidateDedupe.merge()helper toProviderResult.swift - Generic Implementation: Use closures for
accountId,isSameUsage,priorityto make it type-agnostic - Pattern: Replace private methods with shared generic helpers for cross-provider operations
- Example: All 4 providers now call
CandidateDedupe.merge()instead of their own implementations
- Code Duplication: Each provider had its own
-
File Readability Check Before Access:
- Silent Failures: Files exist but are not readable due to permissions cause runtime errors
- Missing Validation:
FileManager.fileExists()only checks existence, not readability - Fix: Add
isReadableFile(atPath:)check before attempting to read file contents - Safety Pattern:
guard fileManager.fileExists(atPath: path), guard fileManager.isReadableFile(atPath: path) - Warning Logging: Log warning when file is not readable for troubleshooting
- Implementation: Add readability check before reading configuration files (auth files, account databases)
- Benefit: Prevents crashes and provides better error messages for permission issues
-
Mixed Provider Schema Handling in auth.json:
- Problem: Single provider entry with unexpected schema (e.g.,
openaistored as API key instead of OAuth) causes entire auth.json decoding to fail - Impact: App cannot read any providers even if only one entry has schema mismatch, preventing app from functioning
- Fix: Implement per-entry graceful failure decoding that doesn't propagate errors to root level
- Pattern: Use
decodeLossyIfPresent()wrapper that returns nil instead of throwing when decoding fails - Type Flexibility: Support multiple numeric types (Int64, Int, Double, String) and string types (String, Int, Int64, Double)
- Example: Implemented flexible decoding functions that try each type sequentially
- Benefit: Mixed schema auth.json files can be partially parsed, allowing valid entries to work despite invalid entries
- Test Coverage: Add tests for unexpected schemas to ensure graceful degradation continues to work
- Problem: Single provider entry with unexpected schema (e.g.,
-
Rolling Window Forecast Stability Floor:
- Spike Problem: Weekly/monthly rolling windows show extreme usage spikes right after a new window starts (very little elapsed time inflates speed/predict values)
- Root Cause: Dividing small usage by very small elapsed time produces unrealistically high pace predictions
- Fix: Apply a minimum elapsed-time ratio before computing speed/predict (e.g., 50% for weekly, 25% for daily, 5% for hourly)
- Pattern: let elapsedSeconds = max(boundedElapsedSeconds, windowSeconds * minElapsedRatioForForecast)
- Benefit: Pace and Predict values remain stable and meaningful during the early phase of each window
-
Predict Text Overage Calculation:
- Double-Counting Bug: Displaying predictedFinalUsage directly as overage percent includes the base 100%, showing inflated numbers
- Fix: Subtract 100 from predicted usage before formatting the overage string
- Pattern: let overagePercent = max(0.0, predictedFinalUsage - 100.0) then format as +%.0f%%
- Context: Predict row in quota-based provider submenus
-
Predict Color Suppression When Safe:
- Noise Issue: Predict value was always rendered in emphasis (warning) color, even when usage pace is normal
- Fix: Only apply warning color when pace status is slightlyFast or tooFast; use secondaryLabelColor otherwise
- Pattern: let isPredictWarning = paceInfo.status == .slightlyFast || paceInfo.status == .tooFast
- Benefit: Reduces visual noise and makes warnings more meaningful
-
Reset Date Fallback for Sub-Model Windows:
- Missing Reset Date: Sub-model quota windows (e.g., Sonnet, Opus) may not carry their own reset date in the API response
- Impact: Usage window row shows no reset time, making it unclear when quota refreshes
- Fix: Fall back to the parent weekly reset date when the sub-model reset is nil
- Pattern: let sonnetReset = details.sonnetReset ?? details.sevenDayReset
- Logging: Add debug log when fallback is used to aid future diagnosis
-
Multi-Source Account Deduplication by Email:
- Two-Pass Dedup Problem: First-pass dedup by accountId leaves duplicates when one source has accountId and another does not (e.g., Codex auth vs codex-lb)
- Fix: Add a second-pass email-based merge after the primary accountId dedup
- Priority: Higher-priority source wins; lower-priority source fields are used as fallback via mergeOpenAIAccount(primary:fallback:)
- Pattern: Build emailIndexMap after primary dedup, merge accounts sharing the same normalized lowercase email
- Benefit: Prevents duplicate provider rows when the same user account is discovered from multiple auth sources
-
Sparkle Update Check Interval Override:
- Preference Overwrite Problem: Setting automaticallyChecksForUpdates and automaticallyDownloadsUpdates on every launch overwrites user preferences stored by Sparkle
- Fix: Only update updateCheckInterval when it differs from the desired value; never force-set the boolean flags
- Remove: Custom Timer-based background update check loop is redundant when Sparkle manages its own schedule
- Pattern: Read current values, log them, only write updateCheckInterval if it needs changing
- Benefit: Respects user opt-out of automatic updates and avoids redundant update checks
-
Status Bar Icon Width Dynamic Sizing:
- Fixed-Width Problem: Hardcoding status bar icon view width (e.g., 70pt) wastes space when text is short and clips when text is long
- Fix: Use NSStatusItem.variableLength and compute width from intrinsicContentSize of the custom view
- Callback Pattern: Add onIntrinsicContentSizeDidChange closure on the icon view; call updateStatusItemLayout() whenever content changes
- Pattern: statusItem.length = max(minWidth, ceil(iconView.intrinsicContentSize.width))
- Benefit: Status bar icon always fits its content without wasted space or clipping
-
Provider Name Removed from Status Bar Text:
- Redundancy Issue: Showing provider name as text prefix (e.g., Claude 77%) wastes limited status bar space
- Fix: Remove provider name from all status bar text formatters; show only the metric value (e.g., 77%)
- Provider Identity: Conveyed via the additive provider icon instead of text
- Affected Formatters: formatProviderForStatusBar, formatAlertText, formatRecentChangeText
- Pattern: Return only the metric string; let the icon layer handle provider identity
-
Antigravity Local Cache Reverse Parsing:
- API Dependency Removed: Antigravity provider no longer calls the localhost language server API
- New Approach: Parse usage directly from the local SQLite cache (state.vscdb) using protobuf binary decoding
- Benefit: No localhost server dependency, works even when the extension is not running
- Key Data: Auth status (email, apiKey, userStatusProtoBinaryBase64) read from cache; protobuf decoded for quota info
- Pattern: Read cached auth blob -> base64 decode -> protobuf parse -> extract model quotas and reset times
-
Dynamic Binary Discovery in Shell Scripts:
- Same Pattern as Swift App: Shell query scripts must also use multi-strategy binary discovery, not hardcoded paths
- Strategy Order: 1) command -v opencode in current PATH, 2) login shell PATH via SHELL -lc which opencode, 3) common fallback paths (Homebrew, default install, pip/pipx)
- Error Output: Write discovery errors to stderr so stdout remains clean for data consumers
- Pattern: Wrap discovery in a find_opencode_bin() shell function; assign result to OPENCODE_BIN variable
-
OAuth Token Expiration Field Interpretation:
- Problem:
expires_invalues (e.g., 3600 seconds) incorrectly interpreted as epoch timestamps, resulting in 1970-01-01 dates - Symptom: Tokens always appear expired, triggering unnecessary refresh attempts on every fetch
- Root Cause: OAuth providers may return either relative seconds (
expires_in) or absolute timestamp (expires_at) - Solution: Use threshold-based detection (1 billion) to distinguish:
- Values < 1B: relative seconds from now (treat as
expires_in) - Values >= 1B: epoch timestamp (treat as
expires_at)
- Values < 1B: relative seconds from now (treat as
- Pattern:
if value < 1_000_000_000 { expiry = Date().addingTimeInterval(value) } else { expiry = Date(timeIntervalSince1970: value) } - Example Fix: TokenManager now correctly handles both field types for token expiration calculation
- Problem:
-
Keychain-First OAuth Credential Parsing:
- Problem: OAuth credentials stored in multiple locations (auth.json, system keychain) with different formats
- Priority Order: Check system Keychain first for OAuth tokens, then fall back to auth.json files
- Benefit: Captures tokens from GUI-based authentication flows that store to Keychain
- Pattern:
if let keychainToken = KeychainService.read(service: "provider") { use token } else { parseAuthJSON() } - Example: ClaudeProvider now reads OAuth tokens from macOS Keychain before checking auth.json
-
Claude OAuth Refresh Token Must Not Be Consumed:
- Problem: Claude's refresh tokens are single-use. Calling the token refresh endpoint invalidates the refresh token stored by OpenCode, causing OpenCode to lose authentication permanently.
- Decision: OpenCode Bar MUST NOT call the Claude OAuth token refresh endpoint (
https://platform.claude.com/v1/oauth/token). - Behavior: Use the access token from auth.json as-is. If it is expired (401), surface the error to the user without attempting a refresh.
- Removed:
ClaudeOAuthRefreshResponse,claudeOAuthRefreshEndpoint,claudeOAuthClientID,isAccessTokenExpired(),isTokenExpiredError(),refreshClaudeAccessToken()were all removed fromClaudeProvider.swift. - Anti-Pattern: NEVER add back token refresh logic for Claude OAuth — it breaks OpenCode authentication for the user.
-
Subscription Key Stability (Email-First Rule):
- Problem: Subscription keys stored in UserDefaults as
subscription_v2.{provider}.{accountId}become orphaned whenaccountIdchanges between fetches (e.g., identity API returns UUID on success but falls back to email on failure) - Rule: ALL subscription key derivation MUST prefer email over accountId/UUID
- Priority:
email.lowercased()→accountId→ provider-only key (no account suffix) - Where:
ProviderAccountResult.subscriptionId(computed property),collectVisibleSubscriptionKeys(), alladdSubscriptionItems()call sites, Gemini special cases - Anti-Pattern: NEVER use API-resolved UUIDs as the primary subscription key — they depend on API availability
- Pattern:
let stableKey = email?.lowercased() ?? accountIdfor every subscription key derivation point - Applies To: All providers universally (Claude, Codex, Copilot, Gemini, etc.)
- Problem: Subscription keys stored in UserDefaults as
