Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
### Fixed (macOS menubar)
- **Keychain prompts.** Stop repeated keychain prompts on token refresh; read the
Claude keychain via the `security` CLI on silent refresh. (#490, #491)
- Menubar project rows are now grouped by project name with token totals preserved,
preventing duplicate project rows in the popover smoke path.
- Restore the right-click status-item menu on macOS 27. (#472, thanks @theparlor)
- Support installer HTTP proxies. (#475, thanks @sleicht)
- Surface the CLI's stdout/stderr on a decode failure so a stray banner is
Expand Down
65 changes: 65 additions & 0 deletions docs/verification/codeburn-restore-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# CodeBurn Menubar Restore Verification

Date: 2026-06-23
Branch: `codex/please-implement-this-plan-codeburn-rebased`

## Scope

Restore and verify the existing macOS menubar path without adding a second widget:

- preserve `codeburn status --format menubar-json`;
- keep Swift `MenubarPayload` backward-compatible;
- show real token/cost data in the menubar popover;
- prevent duplicate project rows when multiple sources resolve to the same project name.

## Data Checks

Run these commands after building:

```sh
npm run build
node dist/cli.js status --format menubar-json --period today --no-optimize
node dist/cli.js status --format menubar-json --period week --no-optimize
node dist/cli.js status --format menubar-json --period month --no-optimize
```

Expected:

- `current.cost`, `current.calls`, `current.inputTokens`, and `current.outputTokens` are numeric and not `NaN`;
- Codex-specific data appears through `current.providers.codex` and `current.codexCredits`;
- `current.topProjects` has no duplicate `name` values;
- each project row preserves `inputTokens`, `outputTokens`, `reasoningTokens`, cache token fields, and `totalTokens`.

## Smoke Check

```sh
CODEBURN_MENUBAR_SMOKE_OUTPUT=/tmp/codeburn-menubar-smoke mac/Scripts/smoke-popover.sh
```

Expected files:

- `/tmp/codeburn-menubar-smoke/report.json`;
- `/tmp/codeburn-menubar-smoke/popover-today-trend.png`.

Expected report values:

- `ok: true`;
- `selectedProvider: All`;
- `selectedPeriod: Today`;
- `currentCalls`, `currentInputTokens`, and `currentOutputTokens` are real numeric values;
- `topProjectDuplicateNames` is an empty array.

## Verification Log

Populate this table during final verification.

| Check | Result | Evidence |
| --- | --- | --- |
| Targeted tests | Pass | `npm test -- tests/menubar-json.test.ts tests/providers/codex.test.ts tests/usage-aggregator.test.ts`: 3 files, 38 tests passed |
| Full test suite | Pass with sequential rerun for known slow CLI tests | `npm test`: 102 files passed; 4 files timed out at 5000ms in parallel. `npx vitest tests/cli-export-date-range.test.ts tests/cli-json-daily.test.ts tests/cli-status-menubar.test.ts tests/parser-proxy-codex-only.test.ts --testTimeout 30000 --fileParallelism=false`: 4 files, 8 tests passed |
| Build | Pass | `npm run build`: passed; `openrouter skipped: fetch failed` and Vite chunk-size warning were non-fatal. Generated pricing snapshot drift was restored before commit |
| Swift build | Pass | `cd mac && swift build`: build complete |
| Swift tests | Blocked by local toolchain | `cd mac && swift test`: failed with `no such module 'Testing'` before exercising task changes |
| Menubar smoke | Pass | `mac/Scripts/smoke-popover.sh /Users/vadimirrosman/Documents/Codex/2026-06-23/new-chat-4/outputs/codeburn-menubar-smoke-rebased-20260623T1432Z`: `ok: true`, screenshot captured, `topProjectDuplicateNames: []` |
| CLI data check | Pass | `node dist/cli.js status --format menubar-json --period week --no-optimize`: cost `94.629252`, calls `138`, input `892306`, output `82204`, Codex credits `394.28855`, duplicate projects `[]`; `month` matched the same June 2026 data. `today` was valid but zero usage on 2026-06-23 |
| Final diff check | Pass | `git diff --check` |
30 changes: 30 additions & 0 deletions mac/Scripts/smoke-popover.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
OUT_DIR="${1:-/tmp/codeburn-menubar-smoke-$(date +%Y%m%d-%H%M%S)}"
TMP_CLI_DIR="$(mktemp -d /tmp/codeburn-cli.XXXXXX)"

cleanup() {
rm -rf "$TMP_CLI_DIR"
}
trap cleanup EXIT

mkdir -p "$OUT_DIR"

if [[ ! -x "$ROOT_DIR/dist/cli.js" ]]; then
(cd "$ROOT_DIR" && npm run build)
fi

ln -sf "$ROOT_DIR/dist/cli.js" "$TMP_CLI_DIR/cli.js"

(
cd "$ROOT_DIR/mac"
CODEBURN_ALLOW_DEV_BIN=1 \
CODEBURN_BIN="node $TMP_CLI_DIR/cli.js" \
CODEBURN_MENUBAR_SMOKE_OUTPUT="$OUT_DIR" \
swift run
)

echo "Smoke report: $OUT_DIR/report.json"
108 changes: 108 additions & 0 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ private let popoverWidth: CGFloat = 360
private let popoverHeight: CGFloat = 660
private let menubarTitleFontSize: CGFloat = 13

enum MenubarSmokeError: Error {
case missingPopoverView
case invalidPopoverBounds
case screenshotEncodingFailed
}

@main
struct CodeBurnApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
Expand Down Expand Up @@ -96,6 +102,108 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
registerLoginItemIfNeeded()
observeSubscriptionDisconnect()
Task { await updateChecker.checkIfNeeded() }
runMenubarSmokeIfRequested()
}

private func runMenubarSmokeIfRequested() {
guard let output = ProcessInfo.processInfo.environment["CODEBURN_MENUBAR_SMOKE_OUTPUT"],
!output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
let outputDir = URL(fileURLWithPath: output, isDirectory: true)
Task { [weak self] in
guard let self else { return }
await self.runMenubarSmoke(outputDir: outputDir)
}
}

private func smokeInsightMode() -> InsightMode {
guard let requested = ProcessInfo.processInfo.environment["CODEBURN_MENUBAR_SMOKE_INSIGHT"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!requested.isEmpty else { return .trend }
return InsightMode.allCases.first { $0.rawValue.caseInsensitiveCompare(requested) == .orderedSame } ?? .trend
}

private func runMenubarSmoke(outputDir: URL) async {
do {
try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
store.resetRefreshState(clearCache: true)
store.selectedProvider = .all
store.selectedPeriod = .today
store.selectedDays = []
let smokeInsight = smokeInsightMode()
store.selectedInsight = smokeInsight
await store.refresh(includeOptimize: false, force: true, showLoading: false)
refreshStatusButton()
showPopoverForSmoke()
try await Task.sleep(nanoseconds: 900_000_000)
let screenshotName = "popover-today-\(smokeInsight.rawValue.lowercased()).png"
let screenshotURL = outputDir.appendingPathComponent(screenshotName)
try capturePopoverScreenshot(to: screenshotURL)
try writeMenubarSmokeReport(to: outputDir.appendingPathComponent("report.json"), screenshotURL: screenshotURL)
} catch {
writeMenubarSmokeFailure(to: outputDir, error: error)
NSLog("CodeBurn: menubar smoke failed: \(error)")
}

if ProcessInfo.processInfo.environment["CODEBURN_MENUBAR_SMOKE_KEEP_OPEN"] != "1" {
NSApp.terminate(nil)
}
}

private func showPopoverForSmoke() {
guard let button = statusItem.button else { return }
if !popover.isShown {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
popover.contentViewController?.view.displayIfNeeded()
}

private func writeMenubarSmokeFailure(to outputDir: URL, error: Error) {
let payload: [String: Any] = ["ok": false, "error": String(describing: error)]
let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
try? data?.write(to: outputDir.appendingPathComponent("report.json"))
}

private func writeMenubarSmokeReport(to url: URL, screenshotURL: URL) throws {
let payload = store.payload
let duplicateProjectNames = Dictionary(grouping: payload.current.topProjects, by: \.name)
.filter { $0.value.count > 1 }
.map(\.key)
.sorted()
let report: [String: Any] = [
"ok": true,
"selectedProvider": store.selectedProvider.rawValue,
"selectedPeriod": store.selectedPeriod.rawValue,
"selectedInsight": store.selectedInsight.rawValue,
"currentLabel": payload.current.label,
"currentCost": payload.current.cost,
"currentCalls": payload.current.calls,
"currentSessions": payload.current.sessions,
"currentInputTokens": payload.current.inputTokens,
"currentOutputTokens": payload.current.outputTokens,
"currentCodexCredits": payload.current.codexCredits ?? 0,
"topProjectCount": payload.current.topProjects.count,
"topProjectDuplicateNames": duplicateProjectNames,
"screenshot": screenshotURL.path,
]
let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys])
try data.write(to: url)
}

private func capturePopoverScreenshot(to url: URL) throws {
guard let view = popover.contentViewController?.view else {
throw MenubarSmokeError.missingPopoverView
}
view.layoutSubtreeIfNeeded()
view.displayIfNeeded()
let bounds = view.bounds
guard !bounds.isEmpty else { throw MenubarSmokeError.invalidPopoverBounds }
guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
throw MenubarSmokeError.screenshotEncodingFailed
}
view.cacheDisplay(in: bounds, to: rep)
guard let png = rep.representation(using: .png, properties: [:]) else {
throw MenubarSmokeError.screenshotEncodingFailed
}
try png.write(to: url)
}

private func setupWakeObservers() {
Expand Down
19 changes: 17 additions & 2 deletions mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ struct SessionDetailEntry: Codable, Sendable {
let calls: Int
let inputTokens: Int
let outputTokens: Int
let reasoningTokens: Int
let date: String
let models: [SessionModelEntry]

Expand All @@ -262,12 +263,13 @@ struct SessionDetailEntry: Codable, Sendable {
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
reasoningTokens = try c.decodeIfPresent(Int.self, forKey: .reasoningTokens) ?? 0
date = try c.decode(String.self, forKey: .date)
models = try c.decodeIfPresent([SessionModelEntry].self, forKey: .models) ?? []
}

private enum CodingKeys: String, CodingKey {
case cost, savingsUSD, calls, inputTokens, outputTokens, date, models
case cost, savingsUSD, calls, inputTokens, outputTokens, reasoningTokens, date, models
}
}

Expand All @@ -276,6 +278,12 @@ struct ProjectEntry: Codable, Sendable {
let cost: Double
let savingsUSD: Double
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let reasoningTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let totalTokens: Int
let avgCostPerSession: Double
let sessionDetails: [SessionDetailEntry]

Expand All @@ -285,12 +293,19 @@ struct ProjectEntry: Codable, Sendable {
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
sessions = try c.decode(Int.self, forKey: .sessions)
inputTokens = try c.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0
outputTokens = try c.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0
reasoningTokens = try c.decodeIfPresent(Int.self, forKey: .reasoningTokens) ?? 0
cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) ?? 0
cacheWriteTokens = try c.decodeIfPresent(Int.self, forKey: .cacheWriteTokens) ?? 0
totalTokens = try c.decodeIfPresent(Int.self, forKey: .totalTokens) ??
(inputTokens + outputTokens + reasoningTokens + cacheReadTokens + cacheWriteTokens)
avgCostPerSession = try c.decode(Double.self, forKey: .avgCostPerSession)
sessionDetails = try c.decodeIfPresent([SessionDetailEntry].self, forKey: .sessionDetails) ?? []
}

private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, sessions, avgCostPerSession, sessionDetails
case name, cost, savingsUSD, sessions, inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWriteTokens, totalTokens, avgCostPerSession, sessionDetails
}
}

Expand Down
72 changes: 60 additions & 12 deletions src/menubar-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type PeriodData = {
codexCredits?: number
categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; savingsUSD: number; calls: number }>
projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }>
projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; reasoningTokens?: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }>
modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }>
topSessions?: Array<{ project: string; cost: number; savingsUSD: number; calls: number; date: string }>
}
Expand Down Expand Up @@ -116,13 +116,20 @@ export type MenubarPayload = {
cost: number
savingsUSD: number
sessions: number
inputTokens: number
outputTokens: number
reasoningTokens: number
cacheReadTokens: number
cacheWriteTokens: number
totalTokens: number
avgCostPerSession: number
sessionDetails: Array<{
cost: number
savingsUSD: number
calls: number
inputTokens: number
outputTokens: number
reasoningTokens: number
date: string
models: Array<{ name: string; cost: number; savingsUSD: number }>
}>
Expand Down Expand Up @@ -256,25 +263,66 @@ function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['h
}

function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['current']['topProjects'] {
return (projects ?? [])
.filter(p => p.cost > 0 || p.savingsUSD > 0)
const grouped = new Map<string, NonNullable<PeriodData['projects']>[number]>()
for (const project of projects ?? []) {
if (project.sessions <= 0) continue
const existing = grouped.get(project.name)
if (existing) {
existing.cost += project.cost
existing.savingsUSD += project.savingsUSD
existing.sessions += project.sessions
existing.inputTokens = (existing.inputTokens ?? 0) + (project.inputTokens ?? 0)
existing.outputTokens = (existing.outputTokens ?? 0) + (project.outputTokens ?? 0)
existing.reasoningTokens = (existing.reasoningTokens ?? 0) + (project.reasoningTokens ?? 0)
existing.cacheReadTokens = (existing.cacheReadTokens ?? 0) + (project.cacheReadTokens ?? 0)
existing.cacheWriteTokens = (existing.cacheWriteTokens ?? 0) + (project.cacheWriteTokens ?? 0)
existing.sessionDetails = [
...(existing.sessionDetails ?? []),
...(project.sessionDetails ?? []),
]
} else {
grouped.set(project.name, {
...project,
sessionDetails: [...(project.sessionDetails ?? [])],
})
}
}
return [...grouped.values()]
.filter(p =>
p.cost > 0 ||
p.savingsUSD > 0 ||
(p.inputTokens ?? 0) > 0 ||
(p.outputTokens ?? 0) > 0 ||
(p.reasoningTokens ?? 0) > 0 ||
(p.cacheReadTokens ?? 0) > 0 ||
(p.cacheWriteTokens ?? 0) > 0
)
.sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD))
.slice(0, TOP_PROJECTS_LIMIT)
.map(p => ({
name: p.name,
cost: p.cost,
savingsUSD: p.savingsUSD,
sessions: p.sessions,
inputTokens: p.inputTokens ?? 0,
outputTokens: p.outputTokens ?? 0,
reasoningTokens: p.reasoningTokens ?? 0,
cacheReadTokens: p.cacheReadTokens ?? 0,
cacheWriteTokens: p.cacheWriteTokens ?? 0,
totalTokens: (p.inputTokens ?? 0) + (p.outputTokens ?? 0) + (p.reasoningTokens ?? 0) + (p.cacheReadTokens ?? 0) + (p.cacheWriteTokens ?? 0),
avgCostPerSession: p.sessions > 0 ? p.cost / p.sessions : 0,
sessionDetails: (p.sessionDetails ?? []).map(s => ({
cost: s.cost,
savingsUSD: s.savingsUSD,
calls: s.calls,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
date: s.date,
models: s.models,
})),
sessionDetails: (p.sessionDetails ?? [])
.sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD))
.map(s => ({
cost: s.cost,
savingsUSD: s.savingsUSD,
calls: s.calls,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
reasoningTokens: s.reasoningTokens ?? 0,
date: s.date,
models: s.models,
})),
}))
}

Expand Down
Loading