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
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ jobs:
sed -i '' 's/^ //' /tmp/homebrew-tap/Casks/codex-bar.rb

cd /tmp/homebrew-tap
git config user.name "Gordon Beeming"
git config user.email "me@gordonbeeming.com"
git config user.name "codex-bar-release[bot]"
git config user.email "codex-bar-release[bot]@users.noreply.github.com"
git config gpg.format ssh
git config user.signingkey ~/.ssh/commit_signing_key
git config commit.gpgsign true
Expand Down
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# CodexBar — agent notes

## Automation

After a code change that affects the built app, install it locally automatically—don't wait to be asked:

```bash
make install
```

`make install` builds a release binary, signs it with the available Apple Development identity, copies it to `~/Applications/CodexBar.app`, and stops the running instance so the next launch uses the change. Relaunch it with:

```bash
open ~/Applications/CodexBar.app
```

Skip installation only when explicitly asked not to, or when the change is limited to documentation or tests and there is nothing new to run.

## Build and test

- Use `swift build` and `swift test` for the quick development loop.
- Use `make bundle` to produce the signed app under `dist/` without installing it.
11 changes: 7 additions & 4 deletions Sources/CodexBar/CodexAppServerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ struct CodexAppServerClient: Sendable {
}
}
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 15, execute: timeout)
defer {
timeout.cancel()
if process.isRunning {
process.terminate()
}
}

let initialize: [String: Any] = [
"method": "initialize",
Expand All @@ -92,7 +98,6 @@ struct CodexAppServerClient: Sendable {

var response = Data()
guard read(until: [0], from: output.fileHandleForReading, into: &response) else {
timeout.cancel()
throw CodexClientError.processFailed
}

Expand All @@ -106,14 +111,12 @@ struct CodexAppServerClient: Sendable {
)

guard read(until: [1, 2], from: output.fileHandleForReading, into: &response) else {
timeout.cancel()
throw CodexClientError.processFailed
}

try input.fileHandleForWriting.close()
response.append(output.fileHandleForReading.readDataToEndOfFile())
process.waitUntilExit()
timeout.cancel()

guard process.terminationStatus == 0 || !response.isEmpty else {
throw CodexClientError.processFailed
Expand All @@ -126,7 +129,7 @@ struct CodexAppServerClient: Sendable {
data.append(try JSONSerialization.data(withJSONObject: message))
data.append(0x0A)
}
handle.write(request)
try handle.write(contentsOf: request)
}

private static func read(
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/LimitRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ struct LimitRowView: View {
Rectangle()
.fill(Color.primary.opacity(0.6))
.frame(width: markerWidth)
.offset(x: markerOffset(pace: pace, width: width))
.padding(.leading, markerOffset(pace: pace, width: width))
}
}
}
Expand Down
25 changes: 19 additions & 6 deletions Sources/CodexBar/MenuBarIconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import SwiftUI
@MainActor
enum MenuBarIconRenderer {
private static let height: CGFloat = 18
static let strongFlameColor = NSColor(srgbRed: 0.75, green: 0.12, blue: 0.12, alpha: 1)
private static let openAIKnotTemplateBase64 = "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAACPUlEQVR42nWUT2jPYRzHX5/vLDtYIjU1aav5Ezk4kEzyr5XTipYSCmVFi/zJgYODi3BQIpo/FwdcJg5KiNTCYfPvYGij1MhqhVqZvVw+P76t+V6e7/t5nt7P53l/3u8n+M+nzgOWAEPAw4gYGbdeRMRYBUdpofJfA5wGmoHPiX8BF4ACqAIeRcTgeLIKUXWOneodtTFxvdqvDqv31W61T91dqaxMUpVjs/pCnZx4v/pBPafWlfavVT+pW/+SVRjVdnVQPZu4S32qLk28ISttS7xefanWABQRMaYeAXYCj4DfqdcCoA34qt4C9gGvgWPqHeA78AOYXymzUX2nNqgdqVGoz7KqXnVIvaRWq5PUk3m1j+psNQqgBeiLiAGgDvgeEQLTskNtwGKgFngD7IiIQ8A64AuwPSIssqV/dQQWZQeHgMMR8S4iPgFHk3iX+gD4CWwBNqlNBXAPaFDnAOdzczcwAxgtHTIJ+AYsy/mOiOgD+oGWIiLeAzeAa8CUiFgNXATGgOOpwRzgBDA9IkaBV+NuMlZkh66mPvfVU8BlYGF2pge4mVUM5/5aYFBtAOYCd4uSsJ+BzcA8oBc4DjQBj4FWYC8wNSv4CSwHrgNXslGg1qS5WhJvVG+prYmXpjm7Ep9J87ZP5Oxt6oC6phSFuozHB/VAzk1We9QV5XhRDp66JwPZnQEdzsDOKpn3ttpZDvr4Z6QSl5nASuB3+qodqAZGgHrgCXAwManxP6KJHquKfsCq9NXziHg70UP4B6TL0SUpQ9DsAAAAAElFTkSuQmCC"
static let strongFlameColor = NSColor(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua, .vibrantLight, .vibrantDark])
if match == .darkAqua || match == .vibrantDark {
return NSColor(srgbRed: 0.95, green: 0.26, blue: 0.21, alpha: 1)
}
return NSColor(srgbRed: 0.75, green: 0.12, blue: 0.12, alpha: 1)
}
static let lightFlameColor = NSColor.systemOrange

static func image(percent: Int?, severity: Severity, flameColor: NSColor?) -> NSImage {
let configuration = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium)
let symbol = NSImage(
systemSymbolName: "chevron.left.forwardslash.chevron.right",
accessibilityDescription: "Codex usage"
)?.withSymbolConfiguration(configuration)
let symbol: NSImage? = codexRosette()

let flameSymbol = (percent != nil ? flameColor : nil).flatMap { flameImage(color: $0) }
let isTemplate = severity == .normal && flameSymbol == nil
Expand Down Expand Up @@ -58,6 +61,16 @@ enum MenuBarIconRenderer {
return image
}

private static func codexRosette() -> NSImage {
let data = Data(base64Encoded: openAIKnotTemplateBase64)
let image = data.flatMap(NSImage.init(data:)) ?? NSImage(
systemSymbolName: "circle.hexagongrid.fill",
accessibilityDescription: "Codex usage"
) ?? NSImage(size: NSSize(width: 18, height: 18))
image.isTemplate = true
return image
}

private static func flameImage(color: NSColor) -> NSImage? {
let configuration = NSImage.SymbolConfiguration(pointSize: 11, weight: .medium)
guard let base = NSImage(
Expand Down
52 changes: 29 additions & 23 deletions Sources/CodexBar/UsageMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,46 @@ struct UsageMenuView: View {
var settings: AppSettings

@Environment(\.openSettings) private var openSettings
@State private var now = Date()

var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
VStack(alignment: .leading, spacing: 8) {
header(now: context.date)
VStack(alignment: .leading, spacing: 8) {
header(now: now)

if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.secondary)
}
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.secondary)
}

Divider()
rows(now: context.date)
Divider()
Divider()
rows(now: now)
Divider()

HStack {
Button("Settings…") {
NSApp.activate(ignoringOtherApps: true)
openSettings()
}
Spacer()
Button("Quit") {
NSApp.terminate(nil)
}
.keyboardShortcut("q")
HStack {
Button("Settings…") {
NSApp.activate(ignoringOtherApps: true)
openSettings()
}
Spacer()
Button("Quit") {
NSApp.terminate(nil)
}
.keyboardShortcut("q")
}
.padding()
.frame(width: 320)
}
.padding()
.frame(width: 320)
.onAppear {
now = Date()
model.refreshIfStale()
}
.task {
while !Task.isCancelled {
guard (try? await Task.sleep(for: .seconds(1))) != nil else { break }
now = Date()
}
}
}

private func header(now: Date) -> some View {
Expand Down
15 changes: 11 additions & 4 deletions Sources/CodexBar/UsageViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ final class UsageViewModel {

pollTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refresh()
guard let self else { break }
await self.refresh()
try? await Task.sleep(for: .seconds(60))
}
}
Expand All @@ -68,10 +69,15 @@ final class UsageViewModel {
lastUpdated = Date()
lastError = nil

switch snapshotStabilizer.evaluate(snapshot.limits) {
switch snapshotStabilizer.evaluate(snapshot.limits, planType: snapshot.planType) {
case let .accepted(trustedLimits):
limits = trustedLimits
processCelebrations(for: trustedLimits, now: Date())
case let .acceptedPlanChange(trustedLimits):
limits = trustedLimits
hasSeededCelebrations = false
processCelebrations(for: trustedLimits, now: Date())
logger.notice("accepted usage immediately because the Codex plan changed")
case let .held(confirmations, required):
logger.notice(
"held suspicious usage regression pending confirmation (\(confirmations, privacy: .public)/\(required, privacy: .public))"
Expand Down Expand Up @@ -100,15 +106,16 @@ final class UsageViewModel {

private func processCelebrations(for limits: [UsageLimit], now: Date) {
let snapshots = Dictionary(
uniqueKeysWithValues: limits.map { limit in
limits.map { limit in
(
limit.celebrationKey,
LimitSnapshot(
percent: limit.percent,
overPace: UsageWindow.isAheadOfPace(for: limit, now: now)
)
)
}
},
uniquingKeysWith: { _, latest in latest }
)
defer { previousSnapshots = snapshots }

Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCore/UsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public struct UsageLimit: Identifiable, Sendable, Equatable {
self.windowDurationMinutes = windowDurationMinutes
}

public var severity: Severity {
public var defaultSeverity: Severity {
if percent >= 90 {
return .critical
}
Expand Down
29 changes: 26 additions & 3 deletions Sources/CodexBarCore/UsageSnapshotStabilizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
public struct UsageSnapshotStabilizer: Sendable {
public enum Decision: Sendable, Equatable {
case accepted([UsageLimit])
case acceptedPlanChange([UsageLimit])
case held(confirmations: Int, required: Int)
}

Expand All @@ -14,14 +15,30 @@ public struct UsageSnapshotStabilizer: Sendable {
private let requiredConsecutiveSamples: Int
private let suspiciousDropPercent: Double
private var trustedLimits: [UsageLimit]?
private var trustedPlanType: String?
private var pendingRegression: PendingRegression?

public init(requiredConsecutiveSamples: Int = 3, suspiciousDropPercent: Double = 10) {
self.requiredConsecutiveSamples = max(requiredConsecutiveSamples, 2)
self.suspiciousDropPercent = max(suspiciousDropPercent, 1)
}

public mutating func evaluate(_ candidate: [UsageLimit]) -> Decision {
public mutating func evaluate(_ candidate: [UsageLimit], planType: String? = nil) -> Decision {
if
let trustedPlanType,
let planType,
trustedPlanType.caseInsensitiveCompare(planType) != .orderedSame
{
self.trustedPlanType = planType
trustedLimits = candidate
pendingRegression = nil
return .acceptedPlanChange(candidate)
}

if trustedPlanType == nil {
trustedPlanType = planType
}

guard let trustedLimits else {
self.trustedLimits = candidate
return .accepted(candidate)
Expand Down Expand Up @@ -58,7 +75,10 @@ public struct UsageSnapshotStabilizer: Sendable {
_ candidate: [UsageLimit],
comparedWith trusted: [UsageLimit]
) -> Bool {
let trustedByID = Dictionary(uniqueKeysWithValues: trusted.map { ($0.id, $0) })
let trustedByID = Dictionary(
trusted.map { ($0.id, $0) },
uniquingKeysWith: { _, latest in latest }
)

return candidate.contains { current in
guard let previous = trustedByID[current.id] else { return false }
Expand All @@ -83,7 +103,10 @@ public struct UsageSnapshotStabilizer: Sendable {
}

private func hasSameWindows(_ lhs: [UsageLimit], as rhs: [UsageLimit]) -> Bool {
let rhsByID = Dictionary(uniqueKeysWithValues: rhs.map { ($0.id, $0) })
let rhsByID = Dictionary(
rhs.map { ($0.id, $0) },
uniquingKeysWith: { _, latest in latest }
)
guard Set(lhs.map(\.id)) == Set(rhsByID.keys) else { return false }

return lhs.allSatisfy { current in
Expand Down
16 changes: 16 additions & 0 deletions Tests/CodexBarCoreTests/UsageModelsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,20 @@ final class UsageModelsTests: XCTestCase {

XCTAssertEqual(limits.map(\.percent), [100, 0])
}

func testDefaultSeverityUsesDefaultThresholds() {
XCTAssertEqual(limit(percent: 74).defaultSeverity, .normal)
XCTAssertEqual(limit(percent: 75).defaultSeverity, .warning)
XCTAssertEqual(limit(percent: 90).defaultSeverity, .critical)
}

private func limit(percent: Double) -> UsageLimit {
UsageLimit(
id: "codex.primary",
name: "Session (5h)",
percent: percent,
resetsAt: nil,
windowDurationMinutes: 300
)
}
}
24 changes: 24 additions & 0 deletions Tests/CodexBarCoreTests/UsageSnapshotStabilizerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ final class UsageSnapshotStabilizerTests: XCTestCase {
XCTAssertEqual(stabilizer.evaluate(corrected), .accepted(corrected))
}

func testDuplicateLimitIDsDoNotCrashRegressionChecks() {
var stabilizer = UsageSnapshotStabilizer()
let trusted = limits(primary: 84, secondary: 13)
let duplicateTrusted = trusted + [trusted[0]]
let regressed = limits(primary: 5, secondary: 1, resetShift: 136)
let duplicateRegression = regressed + [regressed[0]]

XCTAssertEqual(stabilizer.evaluate(duplicateTrusted), .accepted(duplicateTrusted))
XCTAssertEqual(stabilizer.evaluate(duplicateRegression), .held(confirmations: 1, required: 3))
XCTAssertEqual(stabilizer.evaluate(duplicateRegression), .held(confirmations: 2, required: 3))
}

func testPlanChangeAcceptsNewUsageImmediately() {
var stabilizer = UsageSnapshotStabilizer()
let exhausted = limits(primary: 100, secondary: 17)
let upgraded = limits(primary: 0, secondary: 0, resetShift: 136)

XCTAssertEqual(stabilizer.evaluate(exhausted, planType: "plus"), .accepted(exhausted))
XCTAssertEqual(
stabilizer.evaluate(upgraded, planType: "prolite"),
.acceptedPlanChange(upgraded)
)
}

private func limits(
primary: Double,
secondary: Double,
Expand Down
Loading