diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ef9481..e591793 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7e40ef1 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Sources/CodexBar/CodexAppServerClient.swift b/Sources/CodexBar/CodexAppServerClient.swift index 180ecb5..574dbeb 100644 --- a/Sources/CodexBar/CodexAppServerClient.swift +++ b/Sources/CodexBar/CodexAppServerClient.swift @@ -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", @@ -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 } @@ -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 @@ -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( diff --git a/Sources/CodexBar/LimitRowView.swift b/Sources/CodexBar/LimitRowView.swift index 5fcbbf8..2909e96 100644 --- a/Sources/CodexBar/LimitRowView.swift +++ b/Sources/CodexBar/LimitRowView.swift @@ -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)) } } } diff --git a/Sources/CodexBar/MenuBarIconRenderer.swift b/Sources/CodexBar/MenuBarIconRenderer.swift index f93da54..6daa243 100644 --- a/Sources/CodexBar/MenuBarIconRenderer.swift +++ b/Sources/CodexBar/MenuBarIconRenderer.swift @@ -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 @@ -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( diff --git a/Sources/CodexBar/UsageMenuView.swift b/Sources/CodexBar/UsageMenuView.swift index 56aef54..3be86dc 100644 --- a/Sources/CodexBar/UsageMenuView.swift +++ b/Sources/CodexBar/UsageMenuView.swift @@ -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 { diff --git a/Sources/CodexBar/UsageViewModel.swift b/Sources/CodexBar/UsageViewModel.swift index 6a5ac92..3472ec0 100644 --- a/Sources/CodexBar/UsageViewModel.swift +++ b/Sources/CodexBar/UsageViewModel.swift @@ -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)) } } @@ -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))" @@ -100,7 +106,7 @@ 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( @@ -108,7 +114,8 @@ final class UsageViewModel { overPace: UsageWindow.isAheadOfPace(for: limit, now: now) ) ) - } + }, + uniquingKeysWith: { _, latest in latest } ) defer { previousSnapshots = snapshots } diff --git a/Sources/CodexBarCore/UsageModels.swift b/Sources/CodexBarCore/UsageModels.swift index b2edd81..8874509 100644 --- a/Sources/CodexBarCore/UsageModels.swift +++ b/Sources/CodexBarCore/UsageModels.swift @@ -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 } diff --git a/Sources/CodexBarCore/UsageSnapshotStabilizer.swift b/Sources/CodexBarCore/UsageSnapshotStabilizer.swift index db8541a..99574ff 100644 --- a/Sources/CodexBarCore/UsageSnapshotStabilizer.swift +++ b/Sources/CodexBarCore/UsageSnapshotStabilizer.swift @@ -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) } @@ -14,6 +15,7 @@ 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) { @@ -21,7 +23,22 @@ public struct UsageSnapshotStabilizer: Sendable { 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) @@ -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 } @@ -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 diff --git a/Tests/CodexBarCoreTests/UsageModelsTests.swift b/Tests/CodexBarCoreTests/UsageModelsTests.swift index f6cb8c3..66bf09f 100644 --- a/Tests/CodexBarCoreTests/UsageModelsTests.swift +++ b/Tests/CodexBarCoreTests/UsageModelsTests.swift @@ -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 + ) + } } diff --git a/Tests/CodexBarCoreTests/UsageSnapshotStabilizerTests.swift b/Tests/CodexBarCoreTests/UsageSnapshotStabilizerTests.swift index 8091030..ef213a0 100644 --- a/Tests/CodexBarCoreTests/UsageSnapshotStabilizerTests.swift +++ b/Tests/CodexBarCoreTests/UsageSnapshotStabilizerTests.swift @@ -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,