mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
Merge d0b925bcf3 into 1aa05bdbbe
This commit is contained in:
commit
f0ebc2048a
8 changed files with 358 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
65
docs/verification/codeburn-restore-verification.md
Normal file
65
docs/verification/codeburn-restore-verification.md
Normal file
|
|
@ -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
mac/Scripts/smoke-popover.sh
Executable file
30
mac/Scripts/smoke-popover.sh
Executable file
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
}
|
||||
|
|
@ -116,6 +116,12 @@ 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
|
||||
|
|
@ -123,6 +129,7 @@ export type MenubarPayload = {
|
|||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
date: string
|
||||
models: Array<{ name: string; cost: number; savingsUSD: number }>
|
||||
}>
|
||||
|
|
@ -256,8 +263,40 @@ 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 => ({
|
||||
|
|
@ -265,16 +304,25 @@ function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['cur
|
|||
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,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -281,6 +281,11 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
cost: p.totalCostUSD,
|
||||
savingsUSD: p.totalSavingsUSD,
|
||||
sessions: p.sessions.length,
|
||||
inputTokens: p.sessions.reduce((s, session) => s + session.totalInputTokens, 0),
|
||||
outputTokens: p.sessions.reduce((s, session) => s + session.totalOutputTokens, 0),
|
||||
reasoningTokens: p.sessions.reduce((s, session) => s + session.totalReasoningTokens, 0),
|
||||
cacheReadTokens: p.sessions.reduce((s, session) => s + session.totalCacheReadTokens, 0),
|
||||
cacheWriteTokens: p.sessions.reduce((s, session) => s + session.totalCacheWriteTokens, 0),
|
||||
sessionDetails: [...p.sessions]
|
||||
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
|
||||
.slice(0, 10)
|
||||
|
|
@ -290,6 +295,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
calls: s.apiCalls,
|
||||
inputTokens: s.totalInputTokens,
|
||||
outputTokens: s.totalOutputTokens,
|
||||
reasoningTokens: s.totalReasoningTokens,
|
||||
date: s.firstTimestamp?.split('T')[0] ?? '',
|
||||
models: Object.entries(s.modelBreakdown)
|
||||
.map(([name, m]) => ({ name, cost: m.costUSD, savingsUSD: m.savingsUSD }))
|
||||
|
|
|
|||
|
|
@ -231,4 +231,74 @@ describe('buildMenubarPayload', () => {
|
|||
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
|
||||
expect(payload.current.providers).toEqual({ claude: 76.45 })
|
||||
})
|
||||
|
||||
it('groups duplicate top project rows and preserves real token totals', () => {
|
||||
const period: PeriodData = {
|
||||
...emptyPeriod('Today'),
|
||||
projects: [
|
||||
{
|
||||
name: 'codeburn',
|
||||
cost: 4,
|
||||
savingsUSD: 0,
|
||||
sessions: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
reasoningTokens: 7,
|
||||
cacheReadTokens: 300,
|
||||
cacheWriteTokens: 10,
|
||||
sessionDetails: [
|
||||
{
|
||||
cost: 4,
|
||||
savingsUSD: 0,
|
||||
calls: 2,
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
reasoningTokens: 7,
|
||||
date: '2026-06-23',
|
||||
models: [{ name: 'gpt-5.1-codex', cost: 4, savingsUSD: 0 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'codeburn',
|
||||
cost: 2,
|
||||
savingsUSD: 0,
|
||||
sessions: 2,
|
||||
inputTokens: 50,
|
||||
outputTokens: 15,
|
||||
reasoningTokens: 3,
|
||||
cacheReadTokens: 80,
|
||||
cacheWriteTokens: 5,
|
||||
sessionDetails: [
|
||||
{
|
||||
cost: 2,
|
||||
savingsUSD: 0,
|
||||
calls: 1,
|
||||
inputTokens: 50,
|
||||
outputTokens: 15,
|
||||
reasoningTokens: 3,
|
||||
date: '2026-06-23',
|
||||
models: [{ name: 'gpt-5.1-codex', cost: 2, savingsUSD: 0 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
|
||||
expect(payload.current.topProjects).toHaveLength(1)
|
||||
expect(payload.current.topProjects[0]).toMatchObject({
|
||||
name: 'codeburn',
|
||||
cost: 6,
|
||||
sessions: 3,
|
||||
inputTokens: 150,
|
||||
outputTokens: 35,
|
||||
reasoningTokens: 10,
|
||||
cacheReadTokens: 380,
|
||||
cacheWriteTokens: 15,
|
||||
totalTokens: 590,
|
||||
})
|
||||
expect(payload.current.topProjects[0]!.sessionDetails).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue