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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ CodeCaps has two surfaces. Nothing renders in both.
<img src="docs/screenshots/glance-light.png" width="760" alt="Glance, light">
<img src="docs/screenshots/glance-dark.png" width="760" alt="Glance, dark">

**Console** is a resizable window, sidebar split into Quotas and Dashboard: Quotas is an all-platforms overview plus a per-platform drill-down with search; Dashboard holds the pages below. The window title reads **CodeCaps Dashboard** while a Dashboard page is showing.
**Console** is a resizable window, sidebar split into Quotas and Settings: Quotas is an all-platforms overview plus a per-platform drill-down with search; Settings holds the pages listed below. The window title is always **CodeCaps Dashboard**.

<img src="docs/screenshots/console-light.png" width="760" alt="Console, light">
<img src="docs/screenshots/console-dark.png" width="760" alt="Console, dark">
Expand All @@ -48,7 +48,7 @@ Antigravity sells two independent model pools, shown as two rows, **Gemini** (th

## Reset Alarms

A reset alarm is a notification, with the sound picked in Dashboard → Alerts & Alarms, when a quota window starts a new period:
A reset alarm is a notification, with the sound picked on the Alerts & Alarms page, when a quota window starts a new period:

- A provider's **largest** window — the weekly or monthly one — alarms every time it resets, even if usage never came near the cap, so you know a new week or month began.
- A **smaller** window — the 5-hour one — alarms on reset only if, during the period that just ended, it hit its cap or came within 20% of it.
Expand All @@ -70,11 +70,11 @@ Each reset alarms once. The state behind it is saved on every refresh, so a res

None of the above ever asks for a typed credential — every reader reuses a session or file the CLI already created.

CodeCaps reads Claude Code's saved login through macOS's own `security` tool, so a fresh install needs no setup and never raises a Keychain panel of its own. The **Allow Access To Claude Code** button on the Claude row (Console → Dashboard → Sources & Fleet) appears only if macOS explicitly refuses that read; press it and choose **Always Allow** in the panel macOS puts up. On a heavily loaded Mac the Claude row may briefly read "temporarily unavailable" and recover on the next refresh. CodeCaps only ever reads Claude Code's saved login: it never writes to, re-permissions, or removes it.
CodeCaps reads Claude Code's saved login through macOS's own `security` tool, so a fresh install needs no setup and never raises a Keychain panel of its own. The **Allow Access To Claude Code** button on the Claude row (Dashboard window → Sources & Fleet) appears only if macOS explicitly refuses that read; press it and choose **Always Allow** in the panel macOS puts up. On a heavily loaded Mac the Claude row may briefly read "temporarily unavailable" and recover on the next refresh. CodeCaps only ever reads Claude Code's saved login: it never writes to, re-permissions, or removes it.

## Fleet Push And Pull

Both off by default, configured on Console → Dashboard → Sources & Fleet.
Both off by default, configured under Sources & Fleet in the Dashboard window.

**Push formats** — two, chosen when pushing. The producer id is `codecaps` (with `agent-bar` retained as a recognized legacy alias for seamless backwards compatibility).

Expand Down Expand Up @@ -169,7 +169,7 @@ Grab the signed and notarized `CodeCaps.dmg` from the [latest release](https://g

**Updates**

CodeCaps updates itself. Every change merged to `main` becomes a signed, notarized release, and an installed copy checks hourly, downloads it in the background, and installs it the next time CodeCaps is not in front. Dashboard ▸ About has **Check For Updates…** for the impatient. How it works, and how to roll a release back: [`docs/AUTO-UPDATE.md`](docs/AUTO-UPDATE.md).
CodeCaps updates itself. Every change merged to `main` becomes a signed, notarized release, and an installed copy checks hourly, downloads it in the background, and installs it the next time CodeCaps is not in front. The About page has **Check For Updates…** for the impatient. How it works, and how to roll a release back: [`docs/AUTO-UPDATE.md`](docs/AUTO-UPDATE.md).

**Build From Source**

Expand All @@ -193,9 +193,9 @@ Other modes: `--install` (same, no relaunch); `--dev` (separate `.dev` identifie

**Icon.** The master in `assets/` is a full-bleed square, and stays that way. macOS before 26 does not mask an app icon, so `script/make_icon.swift` derives the macOS shape at build time — the master drawn inside an 824x824 rounded rectangle on a 1024 canvas, with the standard drop shadow — and the `.icns` is built from that. The master files are only ever read.

## Dashboard
## Settings

Console's sidebar has eight Dashboard pages.
Console's sidebar has eight Settings pages.

- **Menu Bar & PiP** — where the icon shows (menu bar, Dock, or both), plus its Displayed Quota picker (above) and the Picture-in-Picture HUD.
- **Platforms** — platform list order, shared by Glance, Console, and the menu bar.
Expand Down
53 changes: 47 additions & 6 deletions Sources/CodeCaps/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
// Read before anything can open a window: the launch Sparkle performs
// after installing an update stays in the background.
let relaunchedForUpdate = UpdateRelaunchMarker().consume()
// Warm the provider-mark cache off the main thread: custom marks read
// from disk and were always instant, while bundled marks sometimes
// drew placeholders first. The retry still covers a bundle that
// arrives after this runs.
DispatchQueue.global(qos: .utility).async {
PlatformLogoImage.preloadBundledMarks()
}
UNUserNotificationCenter.current().delegate = self
AppUpdater.shared.start()
configureMenu()
Expand Down Expand Up @@ -156,8 +163,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
// above others but left the app an accessory with no Dock icon, so
// the owner could not switch to CodeCaps at all. Dock + activate is
// what they actually wanted..store(in: &subscriptions)
consoleState.$page.removeDuplicates().sink { [weak self] page in
self?.consoleWindow?.title = page.isSettings ? "CodeCaps Dashboard" : "CodeCaps"
consoleState.$page.removeDuplicates().sink { [weak self] _ in
self?.consoleWindow?.title = "CodeCaps Dashboard"
}.store(in: &subscriptions)
model.$glanceView.removeDuplicates().sink { [weak self] _ in
DispatchQueue.main.async {
Expand Down Expand Up @@ -301,23 +308,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
private func checkPlanMismatches() {
guard !navigatingPlanWarning,
planWarningCheckPending || popover.isShown || consoleWindow?.isVisible == true || floatingGlance?.isVisible == true else { return }
// Accepted overrides are filtered inside warnings(), so an
// explicit choice never warns again no matter the caller.
let warnings = planWarningCycle.warnings(windows: model.detectedPlanWindows,
customInfo: model.platformCustomInfo, now: Date())
if model.lastChecked != nil { planWarningCheckPending = false }
guard !warnings.isEmpty else { return }
// A model change can re-enter while the modal is up: warnings()
// returns [] for a cycle already presenting, and finishing here
// would clear isPresenting under the visible alert and let a
// second modal stack. Only finish a cycle this call did not open.
guard !warnings.isEmpty else {
if !planWarningCycle.isPresenting { planWarningCycle.finishPresentation() }
return
}
// At most once an hour, no matter how often the app opens.
if let last = PlanMismatchSuppression.lastPresentedAt,
Date().timeIntervalSince(last) < 3600 {
planWarningCycle.finishPresentation()
return
}
Comment thread
swu-wise[bot] marked this conversation as resolved.
Resolved
// "Don't show this again" silences repeats, never first sightings: a
// mismatch with a new provider or a changed detected plan still shows.
if PlanMismatchSuppression.isSuppressed, PlanMismatchSuppression.isRepeat(warnings) {
planWarningCycle.finishPresentation()
return
}
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Plan Information Mismatch"
// The transparent colour mark, as on the About page, with CodeCaps
// beside it — not the app icon. NSAlert draws messageText bold to
// the right of the icon, which is exactly the requested lockup.
if let brandMark = BrandMarkImage.asset { alert.icon = brandMark }
alert.messageText = "CodeCaps"
let values = warnings.map {
"\($0.providerLabel): entered “\($0.enteredPlan)”; provider reports “\($0.detectedPlan)”."
}.joined(separator: "\n")
alert.informativeText = values + "\n\n"
alert.informativeText = "Plan Information Mismatch\n\n" + values + "\n\n"
+ "Your entered values have been kept." + sentenceGap
+ "Review the plan fields in Settings to resolve the difference."
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Continue")
alert.showsSuppressionButton = true
alert.suppressionButton?.title = "Don't show this again"
NSApp.activate(ignoringOtherApps: true)
let response = alert.runModal()
if alert.suppressionButton?.state == .on { PlanMismatchSuppression.isSuppressed = true }
// The hourly throttle reads this stamp; it only engages if the
// presentation actually writes it.
PlanMismatchSuppression.lastPresentedAt = Date()
PlanMismatchSuppression.record(warnings)
navigatingPlanWarning = true
Comment thread
swu-wise[bot] marked this conversation as resolved.
Resolved
planWarningCycle.finishPresentation()
if response == .alertFirstButtonReturn { showSettings() }
Expand Down Expand Up @@ -595,7 +634,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
window.setFrameAutosaveName("CodeCapsConsoleWindow")
consoleWindow = window
}
consoleWindow?.title = consoleState.page.isSettings ? "CodeCaps Dashboard" : "CodeCaps"
// The window is the Dashboard: it holds every platform alongside the
// settings pages, so the title never depends on the selected page.
consoleWindow?.title = "CodeCaps Dashboard"
// Derive the level unconditionally on every call. The old branch
// only ever raised and left a stale popUpMenuWindow + 1 in place when
// the popover had been dismissed by a path that does not also resign
Expand Down
80 changes: 74 additions & 6 deletions Sources/CodeCaps/ConsoleViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ final class ConsoleState: ObservableObject {

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// A Dashboard page is a destination, never a place to resume.
// A Settings page is a destination, never a place to resume.
let stored = defaults.string(forKey: "consoleLastPage").flatMap(ConsolePage.fromStorageKey)
page = (stored?.isSettings == false ? stored : nil) ?? .settingsSourcesFleet
if case .platform(let key) = page { pendingStoredKey = key }
Expand Down Expand Up @@ -547,7 +547,7 @@ struct ConsoleSidebar: View {
}
}
} header: {
Eyebrow("DASHBOARD")
Eyebrow("SETTINGS")
}
}
.listStyle(.sidebar)
Expand Down Expand Up @@ -606,7 +606,7 @@ struct ConsoleSidebar: View {
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
}

/// Quotas rows carry a trailing value; Dashboard rows do not. Two different
/// Quotas rows carry a trailing value; Settings rows do not. Two different
/// row views is what stops `.listStyle(.sidebar)` aligning them identically.
private func quotaRow(_ row: DisplaySection) -> some View {
HStack(spacing: 6) {
Expand Down Expand Up @@ -669,7 +669,7 @@ struct ConsoleSidebar: View {

/// The sidebar footer exists only for a problem worth interrupting for:
/// BotFleet quota sharing is unavailable and nothing else says so. The
/// owner had this pinned here as a second copy of a Dashboard switch that is
/// owner had this pinned here as a second copy of a Settings switch that is
/// on for almost everyone (2026-10-05), so a status line that never changes
/// was removed with it; the footer now appears only when there is an error
/// to show.
Expand Down Expand Up @@ -936,7 +936,7 @@ struct PlatformDetailPage: View {
}

/// Editing a platform's presentation happens on that platform's own page,
/// which is why Dashboard ▸ Platforms needs no row selection and no flush.
/// which is why Settings ▸ Platforms needs no row selection and no flush.
private var displaySection: some View {
VStack(alignment: .leading, spacing: 12) {
Eyebrow("DISPLAY")
Expand All @@ -946,7 +946,9 @@ struct PlatformDetailPage: View {
Divider()
Toggle("Display Plan, Cost and Renewal", isOn: $showCostAndRenewal)
.onChange(of: showCostAndRenewal) { _, _ in save() }
field("Plan Name", "e.g. Max 20x, Pro", $planName, disabled: !showCostAndRenewal)
field("Plan Name", "e.g. Max 20x, Pro", $planName,
caption: planNameCaption, disabled: !showCostAndRenewal)
planOverrideRow
field("Cost", "e.g. $20/mo", $costUsd, disabled: !showCostAndRenewal)
field("Renewal Date", "e.g. Oct 12 or Monthly", renewalField,
caption: renewalDate.isEmpty && !suggestedRenewal.isEmpty
Expand All @@ -960,6 +962,72 @@ struct PlatformDetailPage: View {
}
}

/// The provider-reported plan beside the owner's typed one, so a mismatch
/// can be resolved where the name is set instead of only in the popup.
private var detectedPlan: String? {
let key = row?.providerKey ?? customInfoKey
let entered = planName.trimmingCharacters(in: .whitespacesAndNewlines)
let candidates = model.detectedPlanWindows
.filter { $0.canonicalProviderKey == key }
.compactMap { $0.planName?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
// The page must name the same plan the popup warns about: prefer
// the window that actually disagrees with the entered value, as
// PlanWarningCycle.warnings does.
return candidates.first(where: {
PlanComparison.disagrees(detected: $0, entered: entered, providerKey: key)
}) ?? candidates.first
}
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved

private var planMismatch: Bool {
guard let detected = detectedPlan else { return false }
let entered = planName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !entered.isEmpty else { return false }
let key = row?.providerKey ?? customInfoKey
// An accepted pair is resolved, not a live mismatch: the page
// stops offering a resolution for a choice the owner already made.
guard !PlanMismatchSuppression.isAccepted(providerKey: key, entered: entered, detected: detected)
else { return false }
return PlanComparison.disagrees(detected: detected, entered: entered,
providerKey: key)
}
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved

private var planNameCaption: String? {
guard let detected = detectedPlan else { return nil }
if planMismatch {
return "Detected: \(detected)." + sentenceGap + "Does not match — fix the name or the popup keeps appearing."
}
return "Detected: \(detected)."
}

/// One-click resolution where the name is typed. "Use detected" fills
/// the field; "Keep mine" records the exact pair so an explicit choice
/// never warns again. Either edit re-arms the warning for new values.
@ViewBuilder
private var planOverrideRow: some View {
if let detected = detectedPlan, showCostAndRenewal,
(planName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || planMismatch) {
HStack(spacing: 10) {
Text("").frame(width: 150, alignment: .leading).accessibilityHidden(true)
Button("Use Detected") {
planName = detected
save()
}
.help("Replace the plan name with what the provider reports.")
.accessibilityLabel("Use detected plan name")
if planMismatch {
Button("Keep Mine") {
PlanMismatchSuppression.accept(providerKey: row?.providerKey ?? customInfoKey,
entered: planName, detected: detected)
}
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved
.help("Keep the entered name and stop warning about this exact mismatch.")
.accessibilityLabel("Keep my plan name, stop warning")
}
Spacer(minLength: 0)
}
}
}

/// Empty stored text means "keep tracking the billing cycle". The field
/// still shows that date, and typing anything else pins an override.
private var suggestedRenewal: String {
Expand Down
Loading
Loading