diff --git a/.github/pr-assets/macos-menu-header-update.png b/.github/pr-assets/macos-menu-header-update.png new file mode 100644 index 00000000000..df8b6bf63d1 Binary files /dev/null and b/.github/pr-assets/macos-menu-header-update.png differ diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 0960ad0a55d..f6b3a973309 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -40,6 +40,7 @@ public actor PollingCoordinator { /// Attempt time, distinct from success time: a persistently failing quota endpoint /// must still respect the slower cadence. private var lastQuotaAttempt: Date? + private var lastUsageAttempt: Date? public init(client: ProxyClient, endpoint: ProxyEndpoint) { self.client = client @@ -174,6 +175,9 @@ public actor PollingCoordinator { } if popoverOpen { + let previousOpenCodeGoActivity = Set(snapshot.activity?.activities.compactMap { activity in + activity.provider?.lowercased() == "opencode-go" ? activity.id : nil + } ?? []) await refreshActivity(cycle: cycle) guard isCurrent(cycle) else { refreshInFlight = false @@ -184,6 +188,12 @@ public actor PollingCoordinator { // tick would turn a rarely changing endpoint into a two-second poller. if includeHeavy { await refreshOnOpen(cycle: cycle) } + let currentOpenCodeGoActivity = Set(snapshot.activity?.activities.compactMap { activity in + activity.provider?.lowercased() == "opencode-go" ? activity.id : nil + } ?? []) + let completedOpenCodeGoRequest = snapshot.activityLoaded + && !previousOpenCodeGoActivity.isSubset(of: currentOpenCodeGoActivity) + // Rate-limit on ATTEMPT, not success, so a persistently failing endpoint is // not retried on every two-second activity cycle. let quotaDue = forceQuotaRefresh || (lastQuotaAttempt.map { @@ -193,6 +203,12 @@ public actor PollingCoordinator { lastQuotaAttempt = Date() await refreshQuotas(cycle: cycle, forceRefresh: forceQuotaRefresh) } + let usageDue = includeHeavy || forceQuotaRefresh || completedOpenCodeGoRequest || (lastUsageAttempt.map { + Date().timeIntervalSince($0) >= Self.heavyInterval + } ?? true) + if usageDue, isCurrent(cycle) { + await refreshOpenCodeGoUsage(cycle: cycle) + } } if cycle == generation { publish() } @@ -292,6 +308,29 @@ public actor PollingCoordinator { } } + /// Usage is a completed-request ledger. Fetch on open, on a finished Go + /// request, and at the heavy cadence; never poll it every two seconds. + private func refreshOpenCodeGoUsage(cycle: Int) async { + guard isCurrent(cycle) else { return } + guard snapshot.providersLoaded else { return } + guard snapshot.providers.contains(where: { $0.name.lowercased() == "opencode-go" && $0.isEnabled }) else { + snapshot.openCodeGoModelUsage = [] + snapshot.openCodeGoUsageLoaded = false + snapshot.openCodeGoUsageUpdatedAt = nil + return + } + lastUsageAttempt = Date() + guard let usage = try? await client.usageLast30Days(), isCurrent(cycle) else { return } + snapshot.openCodeGoModelUsage = usage.models.filter { + $0.provider.lowercased() == "opencode-go" + && !$0.model.isEmpty + && $0.requests >= 0 + && $0.totalTokens >= 0 + } + snapshot.openCodeGoUsageLoaded = true + snapshot.openCodeGoUsageUpdatedAt = Date() + } + /// Readiness is an orthogonal observation. Once authenticated startup health has /// succeeded, a public-probe transport or contract failure means "unavailable"—it /// must not be allowed to rewrite the live process as stopped or degraded. diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index f1d7db37a8c..b37bc6fbf66 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -299,6 +299,28 @@ public actor ProxyClient { try await authenticatedGet("api/codex-routing") } + public func codexCatalogStatus() async throws -> CodexCatalogStatus { + try await authenticatedGet("api/codex-catalog/status") + } + + /// Explicit panel refresh uses the same non-disruptive sync as `ccx sync`. + /// Catalog gathering can exceed ordinary management mutation timeouts. + public func syncCodexCatalog() async throws -> CodexCatalogSyncOutcome { + let data = try await authenticatedSend( + method: "POST", + path: "api/sync", + body: nil as EmptyBody?, + timeout: 90 + ) + let response: CodexCatalogSyncResponse + do { + response = try JSONDecoder().decode(CodexCatalogSyncResponse.self, from: data) + } catch { + throw ProxyError.decoding + } + return response.outcome + } + /// Public post-startup readiness. This request intentionally carries no management /// credential, and accepts the endpoint's contractually meaningful 503 response for /// `pending` and `failed` observations. @@ -356,6 +378,13 @@ public actor ProxyClient { ) } + public func usageLast30Days() async throws -> ProviderUsageEnvelope { + try await authenticatedGet( + "api/usage", + query: [URLQueryItem(name: "range", value: "30d")] + ) + } + public func restart() async throws -> RestartAccepted { let data = try await authenticatedSend( method: "POST", @@ -494,7 +523,8 @@ public actor ProxyClient { method: String, path: String, query: [URLQueryItem] = [], - body: Body? + body: Body?, + timeout: TimeInterval? = nil ) async throws -> Data { // First attempt: fresh descriptors + identity check immediately before the // credential-bearing request. @@ -505,7 +535,8 @@ public actor ProxyClient { method: method, path: path, query: query, - body: body + body: body, + timeout: timeout ) } catch ProxyError.unauthorized { // Exactly one retry. Rediscovery may pick up a rotated token or replacement @@ -516,7 +547,8 @@ public actor ProxyClient { method: method, path: path, query: query, - body: body + body: body, + timeout: timeout ) } } @@ -526,7 +558,8 @@ public actor ProxyClient { method: String, path: String, query: [URLQueryItem], - body: Body? + body: Body?, + timeout: TimeInterval? ) async throws -> Data { guard installation.credential?.isEmpty == false else { throw ProxyError.authenticationUnavailable @@ -558,7 +591,7 @@ public actor ProxyClient { query: query, body: body, credential: credential, - timeout: method == "GET" ? 4 : 8 + timeout: timeout ?? (method == "GET" ? 4 : 8) ) } diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift index bf5e4427959..e93803086ca 100644 --- a/app/Sources/MenuBarCore/ProxyModels.swift +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -148,6 +148,89 @@ public struct CodexRouteStatus: Decodable, Equatable, Sendable { } } +/// Read-only activation observation from `GET /api/codex-catalog/status`. +/// A restart prompt requires both a published catalog and confirmed stale workers; +/// an unknown or pending status must not tell the user a restart will fix it. +public enum CatalogReloadStatus: Equatable, Sendable { + case unknown + case current + case restartRequired(staleWorkerCount: Int) +} + +/// Closed result contract from authenticated `POST /api/sync`. +public enum CodexCatalogSyncStatus: String, Decodable, Equatable, Sendable { + case applied + case skipped + case refused +} + +public enum CodexCatalogSyncSkipReason: String, Decodable, Equatable, Sendable { + case desiredDisabled = "desired_disabled" + case externalProvider = "external_provider" +} + +public struct CodexCatalogSyncResponse: Decodable, Equatable, Sendable { + public let status: CodexCatalogSyncStatus + public let ok: Bool + public let skippedReason: CodexCatalogSyncSkipReason? + public let warning: String? + + public var outcome: CodexCatalogSyncOutcome { + guard ok else { return .failed } + switch status { + case .applied: + return .applied(warning: warning) + case .skipped: + guard let skippedReason else { return .failed } + return .skipped(skippedReason) + case .refused: + return .failed + } + } +} + +public enum CodexCatalogSyncOutcome: Equatable, Sendable { + case applied(warning: String?) + case skipped(CodexCatalogSyncSkipReason) + case failed +} + +public struct CodexCatalogStatus: Decodable, Sendable { + public struct Activation: Decodable, Sendable { + public struct State: Decodable, Sendable { + public let status: String + } + + public struct Workers: Decodable, Sendable { + public let status: String + public let staleCount: Int + } + + public let schemaVersion: Int + public let catalog: State + public let routing: State + public let workers: Workers + } + + public let activation: Activation + + public var reloadStatus: CatalogReloadStatus { + guard activation.schemaVersion == 1, + activation.catalog.status == "current", + activation.routing.status == "current" + else { return .unknown } + + switch activation.workers.status { + case "reload_required" where activation.workers.staleCount > 0: + return .restartRequired(staleWorkerCount: activation.workers.staleCount) + case "current", "not_running": + return .current + default: + return .unknown + } + } +} + /// `GET /api/startup-health` public struct StartupHealth: Decodable, Equatable, Sendable { public let status: String @@ -265,13 +348,13 @@ public struct QuotaWindow: Decodable, Equatable, Sendable { public let resetAt: Double? } -/// A published provider cap paired with observations from this local CodexCommander usage -/// log. This is reference data, not a provider-reported balance or remaining percent. +/// Observations from this local CodexCommander usage log. Older proxy versions may +/// include a published cap, but it is not a provider-reported balance or current limit. public struct QuotaReferenceWindow: Decodable, Equatable, Sendable { public let id: String public let label: String public let windowSeconds: Double - public let publishedLimitUsd: Double + public let publishedLimitUsd: Double? public let observedSpendUsd: Double? public let observedTokens: Int64 public let observedRequests: Int @@ -381,6 +464,24 @@ public struct ProviderQuotaEnvelope: Decodable, Equatable, Sendable { public let availability: [ProviderQuotaAvailability] } +/// Completed local requests in the management usage log. These are observations, +/// not an OpenCode Go account balance or a live token counter. +public struct ProviderModelUsage: Decodable, Equatable, Sendable { + public let provider: String + public let model: String + public let requests: Int + public let measuredRequests: Int + public let totalTokens: Int64 + public let inputTokens: Int64 + public let outputTokens: Int64 + public let estimatedCostUsd: Double? +} + +public struct ProviderUsageEnvelope: Decodable, Equatable, Sendable { + public let generatedAt: Double + public let models: [ProviderModelUsage] +} + public enum AgentActivityRole: String, Decodable, Equatable, Sendable { case primary case subagent diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift index a9d03af3bf8..dc6e8e0a14d 100644 --- a/app/Sources/MenuBarCore/ProxySnapshot.swift +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -125,6 +125,10 @@ public struct ProxySnapshot: Equatable, Sendable { public var quotaAvailability: [ProviderQuotaAvailability] public var activity: AgentActivitySnapshot? public var providers: [ProviderSummary] + /// Completed OpenCode Go requests over the last 30 days, from /api/usage. + public var openCodeGoModelUsage: [ProviderModelUsage] + public var openCodeGoUsageLoaded: Bool + public var openCodeGoUsageUpdatedAt: Date? public var lastUpdated: Date? public var consecutiveFailures: Int /// Remembered from the last successful health read, so a stopped proxy can still @@ -149,6 +153,9 @@ public struct ProxySnapshot: Equatable, Sendable { quotaAvailability: [ProviderQuotaAvailability] = [], activity: AgentActivitySnapshot? = nil, providers: [ProviderSummary] = [], + openCodeGoModelUsage: [ProviderModelUsage] = [], + openCodeGoUsageLoaded: Bool = false, + openCodeGoUsageUpdatedAt: Date? = nil, lastUpdated: Date? = nil, consecutiveFailures: Int = 0, lastKnownStartCommand: String? = nil, @@ -166,6 +173,9 @@ public struct ProxySnapshot: Equatable, Sendable { self.quotaAvailability = quotaAvailability self.activity = activity self.providers = providers + self.openCodeGoModelUsage = openCodeGoModelUsage + self.openCodeGoUsageLoaded = openCodeGoUsageLoaded + self.openCodeGoUsageUpdatedAt = openCodeGoUsageUpdatedAt self.lastUpdated = lastUpdated self.consecutiveFailures = consecutiveFailures self.lastKnownStartCommand = lastKnownStartCommand diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift index c9e325992c0..a81a664b7f6 100644 --- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -123,6 +123,53 @@ enum ModelDecodingSuite { ) } + t.test("catalog sync: classifies applied and intentional skip results") { + let applied = try decode( + CodexCatalogSyncResponse.self, + #"{"status":"applied","ok":true,"catalogWritten":true}"# + ) + t.equal(applied.outcome, .applied(warning: nil)) + let degraded = try decode( + CodexCatalogSyncResponse.self, + #"{"status":"applied","ok":true,"warning":"Catalog data may be incomplete."}"# + ) + t.equal(degraded.outcome, .applied(warning: "Catalog data may be incomplete.")) + + let disabled = try decode( + CodexCatalogSyncResponse.self, + #"{"status":"skipped","ok":true,"skippedReason":"desired_disabled"}"# + ) + t.equal(disabled.outcome, .skipped(.desiredDisabled)) + + let external = try decode( + CodexCatalogSyncResponse.self, + #"{"status":"skipped","ok":true,"skippedReason":"external_provider"}"# + ) + t.equal(external.outcome, .skipped(.externalProvider)) + } + + t.test("catalog sync: rejects failures and unknown or incomplete statuses") { + let refused = try decode( + CodexCatalogSyncResponse.self, + #"{"status":"refused","ok":false}"# + ) + t.equal(refused.outcome, .failed, "refused response must not report success") + let failedApplied = try decode( + CodexCatalogSyncResponse.self, + #"{"status":"applied","ok":false}"# + ) + t.equal(failedApplied.outcome, .failed, "ok:false must not report success") + t.expect( + rejects(CodexCatalogSyncResponse.self, #"{"status":"future","ok":true}"#), + "unknown statuses must fail closed" + ) + let missingReason = try decode( + CodexCatalogSyncResponse.self, + #"{"status":"skipped","ok":true}"# + ) + t.equal(missingReason.outcome, .failed, "skip without a recognized reason must fail closed") + } + t.test("route status: requires the exact v1 contract and consistent ownership") { let native = try decode( CodexRouteStatus.self, @@ -149,6 +196,26 @@ enum ModelDecodingSuite { } } + t.test("catalog status: restart requires a published catalog and stale workers") { + let stale = try decode( + CodexCatalogStatus.self, + #"{"activation":{"schemaVersion":1,"catalog":{"status":"current"},"routing":{"status":"current"},"workers":{"status":"reload_required","staleCount":2}}}"# + ) + t.equal(stale.reloadStatus, .restartRequired(staleWorkerCount: 2)) + + let pending = try decode( + CodexCatalogStatus.self, + #"{"activation":{"schemaVersion":1,"catalog":{"status":"pending"},"routing":{"status":"current"},"workers":{"status":"reload_required","staleCount":2}}}"# + ) + t.equal(pending.reloadStatus, .unknown) + + let current = try decode( + CodexCatalogStatus.self, + #"{"activation":{"schemaVersion":1,"catalog":{"status":"current"},"routing":{"status":"current"},"workers":{"status":"current","staleCount":0}}}"# + ) + t.equal(current.reloadStatus, .current) + } + t.test("restart: rejects a partial accepted response") { t.expect( rejects(RestartAccepted.self, #"{"success":true,"activeTurnCount":0,"drainTimeoutMs":1000,"alreadyDraining":false}"#), @@ -280,6 +347,15 @@ enum ModelDecodingSuite { t.equal(report.normalized().percent, 61) } + t.test("quotas: OpenCode Go live usage exposes rolling, weekly, and monthly windows") { + let json = report(provider: "opencode-go", label: "OpenCode Go", quota: #"{"updatedAt":1,"fiveHourPercent":12.5,"fiveHourResetAt":1784928599718,"weeklyPercent":40,"weeklyResetAt":1785265199718,"monthlyPercent":65,"monthlyResetAt":1785542400000}"#) + let quota = try decode(QuotaReport.self, json) + let windows = quota.normalizedWindows() + t.equal(windows.map(\.windowLabel), ["5h", "week", "month"]) + t.equal(windows.map(\.percent), [12.5, 40, 65]) + t.equal(quota.normalized().windowLabel, "month") + } + t.test("quotas: multiple custom windows are all retained") { let json = report(provider: "cursor", label: "Cursor", quota: #"{"updatedAt":1,"monthlyPercent":10,"monthlyResetAt":1785256304000,"customWindows":[{"label":"First-party models","percent":4,"resetAt":1785256304000},{"label":"API usage","percent":1,"resetAt":1785256304000}]}"#) let report = try decode(QuotaReport.self, json) @@ -327,22 +403,22 @@ enum ModelDecodingSuite { t.isNil(normalized.resetAt, "resetAt") } - t.test("quotas: OpenCode Go reference caps decode without inventing a percentage") { + t.test("quotas: OpenCode Go local observations decode without inventing a percentage") { let json = """ {"provider":"opencode-go","label":"OpenCode Go","updatedAt":1784915090763, - "source":"opencode-go:published-caps+local-estimate","quota":{ + "source":"opencode-go:local-observation","quota":{ "updatedAt":1784915090763, "referenceWindows":[ {"id":"five_hour","label":"5-hour","windowSeconds":18000, - "publishedLimitUsd":12,"observedSpendUsd":0.3, + "observedSpendUsd":0.3, "observedTokens":1000120,"observedRequests":3,"pricedRequests":3, "unpricedRequests":0,"unmeasuredRequests":0,"coverage":"complete"}, {"id":"weekly","label":"7-day","windowSeconds":604800, - "publishedLimitUsd":30,"observedSpendUsd":1.1, + "observedSpendUsd":1.1, "observedTokens":2400000,"observedRequests":4,"pricedRequests":2, "unpricedRequests":1,"unmeasuredRequests":1,"coverage":"partial"}, {"id":"monthly","label":"30-day","windowSeconds":2592000, - "publishedLimitUsd":60,"observedTokens":0,"observedRequests":0, + "observedTokens":0,"observedRequests":0, "pricedRequests":0,"unpricedRequests":0,"unmeasuredRequests":0, "coverage":"none"}], "observedLimitEvent":{"limitName":"weekly","observedAt":1784915090763, @@ -350,7 +426,7 @@ enum ModelDecodingSuite { """ let report = try decode(QuotaReport.self, json) t.equal(report.referenceWindows.count, 3) - t.equal(report.referenceWindows.map(\.publishedLimitUsd), [12, 30, 60]) + t.expect(report.referenceWindows.allSatisfy { $0.publishedLimitUsd == nil }, "new observations omit obsolete caps") t.equal(report.referenceWindows.map(\.observationQuality), [.estimate, .partial, .none]) t.equal(report.observedLimitEvent?.limitName, "weekly") @@ -359,6 +435,20 @@ enum ModelDecodingSuite { t.isNil(report.normalized().percent, "reference percent") } + t.test("quotas: older OpenCode Go payloads with caps still decode") { + let json = """ + {"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1, + "quota":{"updatedAt":1,"referenceWindows":[{ + "id":"five_hour","label":"5-hour","windowSeconds":18000, + "publishedLimitUsd":12,"observedSpendUsd":0.3,"observedTokens":100, + "observedRequests":1,"pricedRequests":1,"unpricedRequests":0, + "unmeasuredRequests":0,"coverage":"complete"}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.referenceWindows.first?.publishedLimitUsd, 12) + t.equal(report.referenceWindows.first?.observationQuality, .estimate) + } + t.test("quotas: inconsistent complete coverage degrades to Partial") { let json = """ {"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1,"quota":{"updatedAt":1,"referenceWindows":[{ diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index 6caf3bf5340..04514acaf92 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -51,6 +51,55 @@ enum PollingSuite { t.equal(sync { await coordinator.current }.activityLoaded, false) } + t.test("polling: OpenCode Go loads completed model usage only while open") { + let activity = """ + {"schemaVersion":1,"generatedAt":1,"proxyState":"active","activeTurnCount":1, + "displayedActivityCount":1,"unattributedActiveCount":0,"truncated":false, + "activities":[{"id":"go","role":"primary","provider":"opencode-go", + "model":"glm-5.2","phase":"running","startedAt":1}]} + """ + let providers = """ + [{"name":"opencode-go","adapter":"openai-chat","authMode":"key", + "hasApiKey":true,"disabled":false,"quotaCapable":true}] + """ + let quotas = """ + {"generatedAt":1,"reports":[{"provider":"opencode-go","label":"OpenCode Go", + "source":"test","updatedAt":1,"quota":{"updatedAt":1}}],"availability":[]} + """ + let usage = """ + {"generatedAt":1,"models":[ + {"provider":"opencode-go","model":"glm-5.2","requests":2, + "measuredRequests":2,"totalTokens":1200,"inputTokens":1000, + "outputTokens":200,"estimatedCostUsd":0.4}, + {"provider":"kimi","model":"k3","requests":1,"measuredRequests":1, + "totalTokens":50,"inputTokens":40,"outputTokens":10}]} + """ + StubProtocol.reset( + startupResponses(startupHealth(status: "protected", diagnosticStale: false)) + + healthResponses(activity) + + healthResponses(providers) + + healthResponses(quotas) + + healthResponses(usage) + ) + let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! + let client = ProxyClient( + endpoint: endpoint, + session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret + ) + let coordinator = PollingCoordinator(client: client, endpoint: endpoint) + sync { await coordinator.setPopoverOpen(true) } + let snapshot = sync { await coordinator.current } + t.equal(snapshot.openCodeGoUsageLoaded, true) + t.equal(snapshot.openCodeGoModelUsage.count, 1) + t.equal(snapshot.openCodeGoModelUsage.first?.model, "glm-5.2") + let request = StubProtocol.recorded.first { $0.url?.path == "/api/usage" } + t.equal(request?.url?.query, "range=30d") + sync { await coordinator.setPopoverOpen(false) } + t.equal(sync { await coordinator.current }.openCodeGoUsageLoaded, true) + } + t.test("polling: connection refusal is stopped; repeated failures back off") { let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! StubProtocol.reset([ diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index a9805001421..81788c0f447 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -356,6 +356,41 @@ enum TransportSuite { t.equal(requests[1].cachePolicy, .reloadIgnoringLocalAndRemoteCacheData) } + t.test("transport: catalog check and manual sync use attested management paths") { + StubProtocol.reset([ + .init(status: 200, body: identity), + .init(status: 200, body: #"{"activation":{"schemaVersion":1,"catalog":{"status":"current"},"routing":{"status":"current"},"workers":{"status":"reload_required","staleCount":1}}}"#), + .init(status: 200, body: identity), + .init(status: 200, body: #"{"status":"applied","ok":true}"#), + .init(status: 200, body: identity), + .init(status: 200, body: #"{"status":"skipped","ok":true,"skippedReason":"desired_disabled"}"#), + .init(status: 200, body: identity), + .init(status: 200, body: #"{"status":"applied","ok":false}"#), + ]) + let client = makeClient(credential: "admin-secret") + let status: CodexCatalogStatus? = sync { try? await client.codexCatalogStatus() } + t.equal(status?.reloadStatus, .restartRequired(staleWorkerCount: 1)) + let applied: CodexCatalogSyncOutcome? = sync { try? await client.syncCodexCatalog() } + let skipped: CodexCatalogSyncOutcome? = sync { try? await client.syncCodexCatalog() } + let rejected: CodexCatalogSyncOutcome? = sync { try? await client.syncCodexCatalog() } + t.equal(applied, .applied(warning: nil)) + t.equal(skipped, .skipped(.desiredDisabled)) + t.equal(rejected, .failed) + + let requests = StubProtocol.recorded + t.equal(requests.map { $0.url?.path ?? "" }, [ + "/healthz", "/api/codex-catalog/status", + "/healthz", "/api/sync", + "/healthz", "/api/sync", + "/healthz", "/api/sync", + ]) + t.equal(requests[1].httpMethod, "GET") + t.equal(requests[3].httpMethod, "POST") + t.equal(requests[3].value(forHTTPHeaderField: "x-codexcommander-api-key"), "admin-secret") + t.equal(requests[5].httpMethod, "POST") + t.equal(requests[7].httpMethod, "POST") + } + t.test("transport: public readiness accepts ready, pending, and failed without credentials") { StubProtocol.reset([ .init(status: 200, body: readinessBody(status: "ready")), diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 28c796211d1..6dd9bd87875 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -25,6 +25,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid private var lifecycleInFlight = false private var catalogActionInFlight = false private var catalogUpdateReady = false + private var catalogStatusCheckInFlight = false + private var catalogStatusCheckPending = false + private var lastCatalogStatusCheck: Date? private var companionHeartbeat: CompanionHeartbeat? private let launchAtLoginController = LaunchAtLoginController() private let appBundleLocation: AppBundleLocation @@ -306,7 +309,95 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } private func refreshNow() { - Task { [coordinator] in await coordinator?.forceRefresh() } + guard latest?.state.isRunning == true, let client, + !catalogActionInFlight, !lifecycleInFlight, !restartInFlight + else { + Task { [coordinator] in await coordinator?.forceRefresh() } + return + } + + catalogActionInFlight = true + controller.setLifecycleControlsEnabled(false) + controller.setCatalogApplyEnabled(false) + controller.showProgress("Refreshing model catalog…") + Task { [weak self, coordinator] in + let result: Result + do { + result = .success(try await client.syncCodexCatalog()) + } catch { + result = .failure(error) + } + await coordinator?.forceRefresh() + await MainActor.run { + guard let self else { return } + self.catalogActionInFlight = false + self.controller.setLifecycleControlsEnabled(true) + self.refreshCatalogApplyAvailability() + switch result { + case .success(.applied(let warning)): + if let warning, !warning.isEmpty { + self.controller.showResult(warning, isError: true) + } else { + self.controller.showResult("Model sync finished. Restart ChatGPT if prompted below.", isError: false) + } + case .success(.skipped(.desiredDisabled)): + self.controller.showResult("Model sync was skipped because Codex integration is off.", isError: false) + case .success(.skipped(.externalProvider)): + self.controller.showResult("Model sync was skipped because Codex uses an external provider.", isError: false) + case .success(.failed): + self.controller.showResult("The Codex model catalog could not be refreshed.", isError: true) + case .failure(let error): + let detail = (error as? ProxyError)?.userMessage ?? "The model catalog could not be refreshed." + self.controller.showResult(detail, isError: true) + } + self.checkCatalogStatus(force: true) + } + } + } + + /// Observe the already-published catalog without mutating it. This catches a + /// `ccx sync` run outside the menu app and keeps the restart card current. + private func checkCatalogStatus(force: Bool = false) { + guard panel.isShown, latest?.state.isRunning == true, let client, + !catalogActionInFlight + else { return } + if catalogStatusCheckInFlight { + if force { catalogStatusCheckPending = true } + return + } + let now = Date() + if !force, let lastCatalogStatusCheck, + now.timeIntervalSince(lastCatalogStatusCheck) < PollingCoordinator.heavyInterval { + return + } + if force { catalogStatusCheckPending = false } + lastCatalogStatusCheck = now + catalogStatusCheckInFlight = true + Task { [weak self] in + let status = try? await client.codexCatalogStatus().reloadStatus + await MainActor.run { + guard let self else { return } + self.catalogStatusCheckInFlight = false + if self.catalogActionInFlight { + self.catalogStatusCheckPending = true + return + } + if self.panel.isShown, self.latest?.state.isRunning == true { + switch status { + case .restartRequired(let count): + self.presentCatalogUpdate(staleWorkerCount: count) + case .current: + if self.catalogUpdateReady { self.clearCatalogUpdate() } + case .unknown, .none: + break + } + } + if self.catalogStatusCheckPending { + self.catalogStatusCheckPending = false + self.checkCatalogStatus(force: true) + } + } + } } @MainActor @@ -317,6 +408,11 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid let build = sourceRevision.map { " · build \($0)" } ?? "" statusItem?.button?.toolTip = "CodexCommander — \(snapshot.state.title) (\(snapshot.endpoint.display))\(build)" controller.apply(snapshot) + if snapshot.state.isRunning { + if panel.isShown { checkCatalogStatus() } + } else if catalogUpdateReady { + clearCatalogUpdate() + } if !restartInFlight && !lifecycleInFlight && !catalogActionInFlight { controller.setRestartEnabled(snapshot.state.isRunning) controller.setLifecycleControlsEnabled(true) @@ -348,6 +444,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid ) panel.present(from: button) installEscapeMonitor() + checkCatalogStatus(force: true) Task { [coordinator] in await coordinator?.setPopoverOpen(true) } } diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index 44c1505dee3..cb3e3815267 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -9,26 +9,33 @@ public final class PopoverViewController: NSViewController { public required init?(coder: NSCoder) { nil } - // Fixed chrome - private let header = StatusHeaderView() - private let dashboardButton = NSButton() - private let logsButton = NSButton() - private let refreshButton = NSButton() + // Fixed identity, controls, and one-line status + private let header = BrandHeaderView() + private let statusFooter = StatusFooterView() + private let moreActionsMenu = NSMenu(title: "More actions") + private let refreshMenuItem = NSMenuItem() + private let logsMenuItem = NSMenuItem() + private let updateSeparator = NSMenuItem.separator() + private let updateMenuItem = NSMenuItem() private let lifecycleButton = NSButton() private let restartButton = NSButton() private let restoreNativeButton = NSButton() private let routeThroughProxyButton = NSButton() private let stopAndQuitButton = NSButton() - private let updateButton = NSButton() private let updateMessage = NSTextField(wrappingLabelWithString: "") private var updateBlocksLifecycle = false private let startupMode = StartupModeView() private let headerSeparator = makeSeparator() private let operationStatus = OperationStatusView() - private let footerActions = NSStackView() + private let controlActions = NSStackView() + private let primaryActions = NSStackView() + private let separateRouteActions = NSStackView() + private let primaryActionSpacer = NSView() + private let separateRouteSpacer = NSView() + private var routeActionsAreSeparate = false private let column = NSStackView() - // Scrolling body + // One scrolling monitoring area below the controls private let scrollView = NSScrollView() private let body = NSStackView() private let catalogUpdate = CatalogUpdateView() @@ -44,7 +51,7 @@ public final class PopoverViewController: NSViewController { private let startupOptionsButton = NSButton() private let commandField = NSTextField(labelWithString: "") private let activitySeparator = makeSeparator() - private let quotaSeparator = makeSeparator() + private let footerSeparator = makeSeparator() public var onDashboard: (() -> Void)? public var onLogs: (() -> Void)? @@ -74,6 +81,9 @@ public final class PopoverViewController: NSViewController { public override func loadView() { configureControls() + header.onDashboard = { [weak self] in self?.onDashboard?() } + header.onMoreActions = { [weak self] sender in self?.showMoreActions(from: sender) } + header.onUpdate = { [weak self] in self?.onCheckForUpdates?() } startupOptionsButton.isHidden = true quotas.onManage = { [weak self] provider in @@ -99,11 +109,13 @@ public final class PopoverViewController: NSViewController { body.alignment = .leading body.spacing = Theme.sectionGap body.setViews( - [catalogUpdate, activity, activitySeparator, quotas, guidanceLabel, startupOptionsButton, commandField], + [catalogUpdate, guidanceLabel, startupOptionsButton, commandField, + quotas, activitySeparator, activity], in: .top ) body.translatesAutoresizingMaskIntoConstraints = false - for item in [catalogUpdate, activity, activitySeparator, quotas, guidanceLabel, startupOptionsButton, commandField] { + for item in [catalogUpdate, guidanceLabel, startupOptionsButton, commandField, + quotas, activitySeparator, activity] { item.translatesAutoresizingMaskIntoConstraints = false item.widthAnchor.constraint(equalTo: body.widthAnchor).isActive = true } @@ -116,26 +128,20 @@ public final class PopoverViewController: NSViewController { scrollView.borderType = .noBorder scrollView.translatesAutoresizingMaskIntoConstraints = false - let navigationActions = NSStackView(views: [ - dashboardButton, logsButton, refreshButton, NSView() - ]) - navigationActions.orientation = .horizontal - navigationActions.spacing = Theme.rowGap - navigationActions.alignment = .centerY + primaryActions.setViews( + [lifecycleButton, restartButton, restoreNativeButton, + routeThroughProxyButton, primaryActionSpacer], + in: .leading + ) + primaryActions.orientation = .horizontal + primaryActions.spacing = Theme.rowGap + primaryActions.alignment = .centerY - let lifecycleActions = NSStackView(views: [ - lifecycleButton, restartButton, NSView() - ]) - lifecycleActions.orientation = .horizontal - lifecycleActions.spacing = Theme.rowGap - lifecycleActions.alignment = .centerY - - let codexRouteActions = NSStackView(views: [ - restoreNativeButton, routeThroughProxyButton, NSView() - ]) - codexRouteActions.orientation = .horizontal - codexRouteActions.spacing = Theme.rowGap - codexRouteActions.alignment = .centerY + separateRouteActions.setViews([separateRouteSpacer], in: .leading) + separateRouteActions.orientation = .horizontal + separateRouteActions.spacing = Theme.rowGap + separateRouteActions.alignment = .centerY + separateRouteActions.isHidden = true let exitActions = NSStackView(views: [ stopAndQuitButton, NSView() @@ -144,16 +150,17 @@ public final class PopoverViewController: NSViewController { exitActions.spacing = Theme.rowGap exitActions.alignment = .centerY - footerActions.setViews( - [navigationActions, lifecycleActions, codexRouteActions, updateButton, updateMessage, exitActions], + controlActions.setViews( + [primaryActions, separateRouteActions, updateMessage, exitActions], in: .top ) - footerActions.orientation = .vertical - footerActions.spacing = 5 - footerActions.alignment = .leading + controlActions.orientation = .vertical + controlActions.spacing = 5 + controlActions.alignment = .leading column.setViews( - [header, headerSeparator, operationStatus, scrollView, quotaSeparator, startupMode, footerActions], + [header, headerSeparator, operationStatus, controlActions, + startupMode, scrollView, footerSeparator, statusFooter], in: .top ) column.orientation = .vertical @@ -179,9 +186,10 @@ public final class PopoverViewController: NSViewController { header.widthAnchor.constraint(equalToConstant: contentWidth), headerSeparator.widthAnchor.constraint(equalToConstant: contentWidth), operationStatus.widthAnchor.constraint(equalToConstant: contentWidth), - quotaSeparator.widthAnchor.constraint(equalToConstant: contentWidth), + footerSeparator.widthAnchor.constraint(equalToConstant: contentWidth), startupMode.widthAnchor.constraint(equalToConstant: contentWidth), - footerActions.widthAnchor.constraint(equalToConstant: contentWidth), + statusFooter.widthAnchor.constraint(equalToConstant: contentWidth), + controlActions.widthAnchor.constraint(equalToConstant: contentWidth), scrollView.widthAnchor.constraint(equalToConstant: contentWidth), body.widthAnchor.constraint(equalToConstant: scrollingContentWidth), ]) @@ -191,25 +199,36 @@ public final class PopoverViewController: NSViewController { scrollHeight = heightConstraint view = root + applyCodexRouteVisibility() preferredContentSize = NSSize(width: Theme.width, height: Theme.preferredHeight) } private func configureControls() { - styleFooterButton(updateButton, title: "Check for Updates…", symbol: "arrow.down.circle") - updateButton.target = self - updateButton.action = #selector(updateTapped) - updateButton.keyEquivalent = "u" - updateButton.keyEquivalentModifierMask = [.command, .shift] + moreActionsMenu.autoenablesItems = false + refreshMenuItem.title = "Refresh Models" + refreshMenuItem.action = #selector(refreshTapped) + refreshMenuItem.target = self + refreshMenuItem.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: nil) + refreshMenuItem.toolTip = "Check for model changes; ChatGPT may need to restart to load them" + logsMenuItem.title = "Logs" + logsMenuItem.action = #selector(logsTapped) + logsMenuItem.target = self + logsMenuItem.image = NSImage(systemSymbolName: "list.bullet.rectangle", accessibilityDescription: nil) + updateMenuItem.title = "Check for Updates…" + updateMenuItem.action = #selector(updateTapped) + updateMenuItem.target = self + updateMenuItem.image = NSImage(systemSymbolName: "arrow.down.circle", accessibilityDescription: nil) + moreActionsMenu.addItem(refreshMenuItem) + moreActionsMenu.addItem(logsMenuItem) + moreActionsMenu.addItem(updateSeparator) + moreActionsMenu.addItem(updateMenuItem) updateMessage.font = Theme.caption updateMessage.textColor = Theme.muted updateMessage.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 updateMessage.isHidden = true - styleFooterButton(dashboardButton, title: "Dashboard", symbol: "square.grid.2x2") - styleFooterButton(logsButton, title: "Logs", symbol: "list.bullet.rectangle") - styleFooterButton(refreshButton, title: "Refresh", symbol: "arrow.clockwise") styleFooterButton(startupOptionsButton, title: "Startup options…", symbol: "gearshape.2") styleFooterButton(lifecycleButton, title: "Start Proxy", symbol: "play.fill") - styleFooterButton(restartButton, title: "Restart Proxy…", symbol: "power") + styleFooterButton(restartButton, title: "Restart…", symbol: "power") styleFooterButton( restoreNativeButton, title: "Restore Native Codex", @@ -227,9 +246,6 @@ public final class PopoverViewController: NSViewController { ) stopAndQuitButton.contentTintColor = Theme.red - dashboardButton.action = #selector(dashboardTapped) - logsButton.action = #selector(logsTapped) - refreshButton.action = #selector(refreshTapped) startupOptionsButton.action = #selector(startupOptionsTapped) lifecycleButton.action = #selector(lifecycleTapped) restartButton.action = #selector(restartTapped) @@ -240,9 +256,6 @@ public final class PopoverViewController: NSViewController { stopAndQuitButton.keyEquivalent = CompanionShortcut.keyEquivalent stopAndQuitButton.keyEquivalentModifierMask = CompanionShortcut.stopAndQuitModifiers - dashboardButton.setAccessibilityLabel("Open dashboard") - logsButton.setAccessibilityLabel("Open logs") - refreshButton.setAccessibilityLabel("Refresh") startupOptionsButton.setAccessibilityLabel("Open startup options in the dashboard") lifecycleButton.setAccessibilityLabel("Start CodexCommander proxy") restartButton.setAccessibilityLabel("Restart CodexCommander proxy") @@ -250,6 +263,8 @@ public final class PopoverViewController: NSViewController { routeThroughProxyButton.setAccessibilityLabel( "Route Codex through the CodexCommander proxy" ) + restoreNativeButton.toolTip = "Restore Native Codex" + routeThroughProxyButton.toolTip = "Route Codex Through Proxy" stopAndQuitButton.setAccessibilityLabel( "Stop the CodexCommander proxy and quit the menu bar app" ) @@ -276,7 +291,7 @@ public final class PopoverViewController: NSViewController { public func apply(_ snapshot: ProxySnapshot) { self.snapshot = snapshot - header.apply(snapshot) + statusFooter.apply(snapshot) activity.isHidden = false activity.apply(snapshot) @@ -284,7 +299,7 @@ public final class PopoverViewController: NSViewController { quotas.apply(snapshot) activitySeparator.isHidden = activity.isHidden - quotaSeparator.isHidden = false + footerSeparator.isHidden = false applyGuidance(snapshot) applyActions(snapshot) @@ -390,8 +405,11 @@ public final class PopoverViewController: NSViewController { public func setLifecycleControlsEnabled(_ enabled: Bool) { lifecycleControlsAllowed = enabled + refreshMenuItem.isEnabled = enabled && snapshot?.state.isRunning == true + updateMoreActionsAvailability() lifecycleButton.isEnabled = enabled && snapshot.map { lifecycleActionable($0.state) } == true restartButton.isEnabled = enabled && snapshot?.state.isRunning == true + restartButton.isHidden = snapshot?.state.isRunning != true applyCodexRouteAvailability() stopAndQuitButton.isEnabled = LifecycleActionAvailability.canStopAndQuit( state: snapshot?.state, @@ -407,20 +425,25 @@ public final class PopoverViewController: NSViewController { public func applyUpdatePresentation(title: String, enabled: Bool, blocked: Bool, message: String) { _ = view - updateButton.title = title - updateButton.setAccessibilityLabel(title) - updateButton.isEnabled = enabled - updateMessage.stringValue = message - updateMessage.isHidden = message.isEmpty + let needsAttention = title == "Update Available…" || title == "Finish Update…" + header.setUpdateAction(title: needsAttention ? title : nil, enabled: enabled, message: message) + updateSeparator.isHidden = needsAttention + updateMenuItem.isHidden = needsAttention + updateMenuItem.title = "Check for Updates…" + updateMenuItem.isEnabled = enabled && !needsAttention + updateMenuItem.toolTip = !enabled && !message.isEmpty ? message : nil + updateMoreActionsAvailability() + updateMessage.stringValue = blocked ? message : "" + updateMessage.isHidden = !blocked || message.isEmpty updateBlocksLifecycle = blocked if let snapshot { applyGuidance(snapshot) } setLifecycleControlsEnabled(lifecycleControlsAllowed) refreshSize() } - package var updateActionTitleForTesting: String { updateButton.title } - package var updateActionEnabledForTesting: Bool { updateButton.isEnabled } - package func clickUpdateForTesting() { updateButton.performClick(nil) } + package var headerUpdateTitleForTesting: String? { header.updateActionTitleForTesting } + package var headerUpdateEnabledForTesting: Bool { header.updateActionEnabledForTesting } + package func clickHeaderUpdateForTesting() { header.clickUpdateForTesting() } private func applyUpdateLifecycleGuard() { guard updateBlocksLifecycle else { return } @@ -475,9 +498,10 @@ public final class PopoverViewController: NSViewController { private func applyActions(_ snapshot: ProxySnapshot) { let definitelyStopped = snapshot.state == .unreachable let stopIntent = lifecycleStops(snapshot.state) - dashboardButton.isEnabled = !definitelyStopped - logsButton.isEnabled = !definitelyStopped - refreshButton.isEnabled = true + header.setDashboardEnabled(!definitelyStopped) + logsMenuItem.isEnabled = !definitelyStopped + refreshMenuItem.isEnabled = lifecycleControlsAllowed && snapshot.state.isRunning + updateMoreActionsAvailability() lifecycleButton.title = stopIntent ? "Stop Proxy…" : "Start Proxy" lifecycleButton.image = NSImage( systemSymbolName: stopIntent ? "stop.fill" : "play.fill", @@ -489,8 +513,10 @@ public final class PopoverViewController: NSViewController { lifecycleButton.isEnabled = lifecycleControlsAllowed && lifecycleActionable(snapshot.state) lifecycleButton.alphaValue = lifecycleButton.isEnabled ? 1 : 0.45 restartButton.isEnabled = lifecycleControlsAllowed && snapshot.state.isRunning + restartButton.isHidden = !snapshot.state.isRunning restartButton.alphaValue = restartButton.isEnabled ? 1 : 0.45 applyCodexRouteAvailability() + applyCodexRouteVisibility() restoreNativeButton.alphaValue = restoreNativeButton.isEnabled ? 1 : 0.45 routeThroughProxyButton.alphaValue = routeThroughProxyButton.isEnabled ? 1 : 0.45 stopAndQuitButton.isEnabled = LifecycleActionAvailability.canStopAndQuit( @@ -504,13 +530,12 @@ public final class PopoverViewController: NSViewController { private func resize() { view.layoutSubtreeIfNeeded() let bodyHeight = ceil(body.fittingSize.height) - let fixedViewsHeight = ceil(header.fittingSize.height) - + ceil(headerSeparator.fittingSize.height) - + (operationStatus.isHidden ? 0 : ceil(operationStatus.fittingSize.height)) - + ceil(quotaSeparator.fittingSize.height) - + ceil(startupMode.fittingSize.height) - + ceil(footerActions.fittingSize.height) - let stackGaps = column.spacing * CGFloat(max(0, column.views.count - 1)) + let fixedViews: [NSView] = [header, headerSeparator, operationStatus, + controlActions, startupMode, footerSeparator, statusFooter] + let fixedViewsHeight = fixedViews.filter { !$0.isHidden } + .reduce(CGFloat.zero) { $0 + ceil($1.fittingSize.height) } + let visibleColumnCount = column.views.filter { !$0.isHidden }.count + let stackGaps = column.spacing * CGFloat(max(0, visibleColumnCount - 1)) let chrome = fixedViewsHeight + stackGaps + column.edgeInsets.top @@ -558,11 +583,60 @@ public final class PopoverViewController: NSViewController { routeThroughProxyButton.isEnabled = !usesProxy } + /// Keep the one available route switch beside proxy controls. When route + /// truth is uncertain, put both recovery choices on their own row. + private func applyCodexRouteVisibility() { + let route: CodexRoutingKind? + switch snapshot?.codexRoute { + case .confirmed(let status): + route = status.routingKind + case .unobserved: + if case .running(let health) = snapshot?.state, !health.diagnosticStale { + route = CodexRoutingKind(rawValue: health.routingKind) + } else { + route = nil + } + case .confirmationUnavailable, nil: + route = nil + } + restoreNativeButton.isHidden = route == .native + routeThroughProxyButton.isHidden = route == .codexCommanderLocal + let needsSeparateRow = !restoreNativeButton.isHidden && !routeThroughProxyButton.isHidden + if needsSeparateRow != routeActionsAreSeparate { + let source = needsSeparateRow ? primaryActions : separateRouteActions + let destination = needsSeparateRow ? separateRouteActions : primaryActions + source.removeView(restoreNativeButton) + source.removeView(routeThroughProxyButton) + destination.insertView(restoreNativeButton, at: needsSeparateRow ? 0 : 2, in: .leading) + destination.insertView(routeThroughProxyButton, at: needsSeparateRow ? 1 : 3, in: .leading) + routeActionsAreSeparate = needsSeparateRow + } + restoreNativeButton.title = needsSeparateRow ? "Restore Native Codex" : "Use Native" + routeThroughProxyButton.title = needsSeparateRow ? "Route Codex Through Proxy" : "Use Commander" + separateRouteActions.isHidden = !needsSeparateRow + } + // MARK: - Actions - @objc private func dashboardTapped() { onDashboard?() } @objc private func logsTapped() { onLogs?() } @objc private func refreshTapped() { onRefresh?() } + private func showMoreActions(from sender: NSButton) { + moreActionsMenu.popUp( + positioning: nil, + at: NSPoint(x: 0, y: sender.bounds.minY), + in: sender + ) + } + private func updateMoreActionsAvailability() { + let enabled = refreshMenuItem.isEnabled || logsMenuItem.isEnabled + || (!updateMenuItem.isHidden && updateMenuItem.isEnabled) + header.setMoreActionsEnabled( + enabled, + label: updateMenuItem.isHidden + ? "More actions: Refresh Models and Logs" + : "More actions: Refresh Models, Logs, and updates" + ) + } @objc private func startupOptionsTapped() { onOpenStartupOptions?() } @objc private func lifecycleTapped() { guard let state = snapshot?.state else { return } @@ -614,7 +688,34 @@ public final class PopoverViewController: NSViewController { return scrollView.bounds.width } package var hasVerticalScroller: Bool { scrollView.hasVerticalScroller } - package var headerView: StatusHeaderView { header } + package var monitoringPrecedesPanelFooter: Bool { + guard let controlsIndex = column.arrangedSubviews.firstIndex(of: controlActions), + let statusIndex = column.arrangedSubviews.firstIndex(of: statusFooter), + let separatorIndex = column.arrangedSubviews.firstIndex(of: footerSeparator), + let scrollIndex = column.arrangedSubviews.firstIndex(of: scrollView), + let activityIndex = body.arrangedSubviews.firstIndex(of: activity), + let quotaIndex = body.arrangedSubviews.firstIndex(of: quotas) else { return false } + return statusIndex == column.arrangedSubviews.count - 1 + && controlsIndex < scrollIndex && scrollIndex < separatorIndex && separatorIndex < statusIndex + && quotaIndex < activityIndex + } + package var visibleRouteActionTitles: [String] { + [restoreNativeButton, routeThroughProxyButton] + .filter { !$0.isHidden } + .map(\.title) + } + package var primaryActionTitles: [String] { + primaryActions.arrangedSubviews.compactMap { $0 as? NSButton } + .filter { !$0.isHidden } + .map(\.title) + } + package var separateRouteRowVisible: Bool { !separateRouteActions.isHidden } + package var visibleUpperControlRowCount: Int { + controlActions.arrangedSubviews.filter { !$0.isHidden }.count + + (startupMode.isHidden ? 0 : 1) + } + package var headerView: BrandHeaderView { header } + package var statusFooterView: StatusFooterView { statusFooter } package var operationStatusView: OperationStatusView { operationStatus } package var operationStatusTitle: String { operationStatus.titleText } package var operationStatusDetail: String? { operationStatus.detailText } @@ -650,9 +751,9 @@ public final class PopoverViewController: NSViewController { } package var footerTitles: [String] { [ - dashboardButton.title, - logsButton.title, - refreshButton.title, + "Dashboard", + logsMenuItem.title, + refreshMenuItem.title, lifecycleButton.title, restartButton.title, restoreNativeButton.title, @@ -661,29 +762,58 @@ public final class PopoverViewController: NSViewController { ] } package var footerEnabledStates: [Bool] { - footerButtons.map(\.isEnabled) + [header.dashboardEnabledForTesting, logsMenuItem.isEnabled, refreshMenuItem.isEnabled, + lifecycleButton.isEnabled, restartButton.isEnabled, restoreNativeButton.isEnabled, + routeThroughProxyButton.isEnabled, stopAndQuitButton.isEnabled] } package var footerAccessibilityLabels: [String?] { - footerButtons.map { $0.accessibilityLabel() } + [header.dashboardAccessibilityLabelForTesting, "Open logs", "Refresh models and proxy status", + lifecycleButton.accessibilityLabel(), restartButton.accessibilityLabel(), + restoreNativeButton.accessibilityLabel(), routeThroughProxyButton.accessibilityLabel(), + stopAndQuitButton.accessibilityLabel()] } package var footerKeyEquivalents: [(String, NSEvent.ModifierFlags)] { - footerButtons.map { ($0.keyEquivalent, $0.keyEquivalentModifierMask) } + [ + ("", []), + ("", []), + ("", []), + (lifecycleButton.keyEquivalent, lifecycleButton.keyEquivalentModifierMask), + (restartButton.keyEquivalent, restartButton.keyEquivalentModifierMask), + (restoreNativeButton.keyEquivalent, restoreNativeButton.keyEquivalentModifierMask), + (routeThroughProxyButton.keyEquivalent, routeThroughProxyButton.keyEquivalentModifierMask), + (stopAndQuitButton.keyEquivalent, stopAndQuitButton.keyEquivalentModifierMask), + ] } package func activateFooterForTesting(_ index: Int) { - guard footerButtons.indices.contains(index) else { return } - footerButtons[index].performClick(nil) + switch index { + case 0: header.clickDashboardForTesting() + case 1: activateMoreActionForTesting(1) + case 2: activateMoreActionForTesting(0) + case 3: lifecycleButton.performClick(nil) + case 4: restartButton.performClick(nil) + case 5: restoreNativeButton.performClick(nil) + case 6: routeThroughProxyButton.performClick(nil) + case 7: stopAndQuitButton.performClick(nil) + default: break + } } - private var footerButtons: [NSButton] { - [ - dashboardButton, - logsButton, - refreshButton, - lifecycleButton, - restartButton, - restoreNativeButton, - routeThroughProxyButton, - stopAndQuitButton, - ] + package var visibleHeaderActionTitles: [String] { + ["Dashboard", "More actions"] + } + package var moreActionsAccessibilityLabel: String? { + header.moreActionsAccessibilityLabelForTesting + } + package var moreActionTitles: [String] { + let titles = [refreshMenuItem.title, logsMenuItem.title] + return updateMenuItem.isHidden ? titles : titles + [updateMenuItem.title] + } + package var moreActionEnabledStates: [Bool] { + let states = [refreshMenuItem.isEnabled, logsMenuItem.isEnabled] + return updateMenuItem.isHidden ? states : states + [updateMenuItem.isEnabled] + } + package func activateMoreActionForTesting(_ index: Int) { + guard moreActionEnabledStates.indices.contains(index), moreActionEnabledStates[index] else { return } + moreActionsMenu.performActionForItem(at: index == 2 ? 3 : index) } } diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift index ec86b213480..6a58140e6aa 100644 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -12,6 +12,10 @@ public final class ProviderQuotaAccordionView: NSView { private var expandedProviders: Set = [] private var didSeedExpansion = false private var currentRows: [ProviderQuotaRow] = [] + private var currentOpenCodeGoModels: [ProviderModelUsage] = [] + private var currentOpenCodeGoUsageLoaded = false + private var currentOpenCodeGoUsageUpdatedAt: Date? + private var currentOpenCodeGoActiveModels: Set = [] public var onManage: ((String) -> Void)? public var onViewAll: (() -> Void)? @@ -68,6 +72,14 @@ public final class ProviderQuotaAccordionView: NSView { public required init?(coder: NSCoder) { nil } public func apply(_ snapshot: ProxySnapshot) { + currentOpenCodeGoModels = snapshot.openCodeGoModelUsage + currentOpenCodeGoUsageLoaded = snapshot.openCodeGoUsageLoaded + currentOpenCodeGoUsageUpdatedAt = snapshot.openCodeGoUsageUpdatedAt + currentOpenCodeGoActiveModels = Set(snapshot.activity?.activities.compactMap { activity in + guard activity.provider?.lowercased() == "opencode-go", + let model = activity.model, !model.isEmpty else { return nil } + return model + } ?? []) for view in rows.arrangedSubviews { rows.removeArrangedSubview(view) view.removeFromSuperview() @@ -111,6 +123,10 @@ public final class ProviderQuotaAccordionView: NSView { let row = ProviderQuotaRowView( row: quotaRow, expanded: expanded, + modelUsage: quotaRow.provider.lowercased() == "opencode-go" ? currentOpenCodeGoModels : [], + modelUsageLoaded: currentOpenCodeGoUsageLoaded, + modelUsageUpdatedAt: currentOpenCodeGoUsageUpdatedAt, + activeModels: currentOpenCodeGoActiveModels, onToggle: { [weak self] in self?.toggle(quotaRow.provider) }, onManage: { [weak self] in self?.onManage?(quotaRow.provider) } ) @@ -169,6 +185,10 @@ public final class ProviderQuotaAccordionView: NSView { let row = ProviderQuotaRowView( row: quotaRow, expanded: expanded, + modelUsage: quotaRow.provider.lowercased() == "opencode-go" ? currentOpenCodeGoModels : [], + modelUsageLoaded: currentOpenCodeGoUsageLoaded, + modelUsageUpdatedAt: currentOpenCodeGoUsageUpdatedAt, + activeModels: currentOpenCodeGoActiveModels, onToggle: { [weak self] in self?.toggle(quotaRow.provider) }, onManage: { [weak self] in self?.onManage?(quotaRow.provider) } ) @@ -196,6 +216,12 @@ public final class ProviderQuotaAccordionView: NSView { .first(where: { $0.providerID == provider })? .accessibilityLabel() } + package func modelUsageTextForTesting(_ provider: String) -> [String] { + rows.arrangedSubviews + .compactMap { $0 as? ProviderQuotaRowView } + .first(where: { $0.providerID == provider })? + .modelUsageText ?? [] + } package func toggleForTesting(_ provider: String) { toggle(provider) } package func triggerViewAllForTesting() { onViewAll?() } package func triggerManageForTesting(_ provider: String) { onManage?(provider) } @@ -209,11 +235,16 @@ public final class ProviderQuotaAccordionView: NSView { } final class ProviderQuotaRowView: NSView { + private(set) var modelUsageText: [String] = [] let providerID: String init( row: ProviderQuotaRow, expanded: Bool, + modelUsage: [ProviderModelUsage], + modelUsageLoaded: Bool, + modelUsageUpdatedAt: Date?, + activeModels: Set, onToggle: @escaping () -> Void, onManage: @escaping () -> Void ) { @@ -245,7 +276,20 @@ final class ProviderQuotaRowView: NSView { chevron.widthAnchor.constraint(equalToConstant: 12).isActive = true chevron.heightAnchor.constraint(equalToConstant: 12).isActive = true - let summary = makeLabel(collapsedSummary(row), font: Theme.micro, color: Theme.faint) + let compactSummary: String + if row.provider.lowercased() == "opencode-go", !activeModels.isEmpty { + let activeSummary = activeModels.count == 1 + ? "Using \(activeModels.sorted()[0])" + : "\(activeModels.count) models in flight" + if let quota = row.report?.normalized(), quota.hasPercent { + compactSummary = "\(activeSummary) · \(quota.windowLabel) \(Format.percent(quota.percent))" + } else { + compactSummary = activeSummary + } + } else { + compactSummary = collapsedSummary(row) + } + let summary = makeLabel(compactSummary, font: Theme.micro, color: Theme.faint) let manage = ActionButton(title: row.isUnavailable ? "SETTINGS" : "PROVIDER", handler: onManage) manage.bezelStyle = .texturedRounded @@ -322,6 +366,49 @@ final class ProviderQuotaRowView: NSView { } } } + if row.provider.lowercased() == "opencode-go" { + let freshness = modelUsageUpdatedAt.map { " · updated \(Format.age($0))" } ?? "" + let heading = makeLabel("Model usage · last 30 days\(freshness)", font: Theme.captionMedium, color: Theme.muted) + arranged.append(heading) + let active = activeModels.sorted() + for model in active { + let text = "In flight · \(model)" + modelUsageText.append(text) + arranged.append(makeLabel(text, font: Theme.captionMedium, color: Theme.green)) + } + if !modelUsageLoaded { + let text = "Completed usage unavailable" + modelUsageText.append(text) + arranged.append(makeLabel(text, font: Theme.caption, color: Theme.muted)) + } else if modelUsage.isEmpty { + let text = "No completed requests in the last 30 days" + modelUsageText.append(text) + arranged.append(makeLabel(text, font: Theme.caption, color: Theme.muted)) + } else { + let ordered = modelUsage.sorted { lhs, rhs in + let leftActive = activeModels.contains(lhs.model) + let rightActive = activeModels.contains(rhs.model) + if leftActive != rightActive { return leftActive } + if lhs.totalTokens != rhs.totalTokens { return lhs.totalTokens > rhs.totalTokens } + return lhs.model < rhs.model + } + for usage in ordered { + let tokens = usage.measuredRequests > 0 + ? Format.count(usage.totalTokens) + (usage.measuredRequests < usage.requests + ? " measured tokens (partial)" : " tokens") + : "tokens unknown" + let requests = "\(usage.requests) completed request\(usage.requests == 1 ? "" : "s")" + let cost = usage.estimatedCostUsd.map { " · estimated \(Format.usdEstimate($0))" } ?? "" + let text = "\(usage.model) · \(requests) · \(tokens)\(cost)" + modelUsageText.append(text) + let label = makeLabel(text, font: Theme.caption, color: Theme.text) + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 2 + label.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - 20 + arranged.append(label) + } + } + } } let column = NSStackView(views: arranged) @@ -434,8 +521,8 @@ final class ProviderQuotaRowView: NSView { } let references = report.referenceWindows if !references.isEmpty { - let caps = references.prefix(3).map(ReferenceQuotaPresentation.compactCapText) - return "Caps \(caps.joined(separator: " · "))" + let observed = references.first(where: { $0.observationQuality != .none }) ?? references[0] + return "Local usage · \(ReferenceQuotaPresentation.compactObservationText(observed))" } return "Unavailable" } diff --git a/app/Sources/MenuBarUI/StartupModeView.swift b/app/Sources/MenuBarUI/StartupModeView.swift index 0148b9909d0..7aeef8b6251 100644 --- a/app/Sources/MenuBarUI/StartupModeView.swift +++ b/app/Sources/MenuBarUI/StartupModeView.swift @@ -87,7 +87,16 @@ public final class StartupModeView: NSView { detail.stringValue = presentation.errorMessage ?? summary detail.textColor = presentation.errorMessage == nil ? Theme.faint : Theme.red } + let needsVisibleGuidance = presentation.relocationRequired + || presentation.errorMessage != nil + || presentation.needsApproval + || !presentation.isToggleEnabled + detail.isHidden = !needsVisibleGuidance + title.toolTip = detail.stringValue + toggle.toolTip = detail.stringValue + toolTip = detail.stringValue setAccessibilityLabel("CodexCommander startup mode, \(detail.stringValue)") + toggle.setAccessibilityLabel("Launch CodexCommander at login, \(detail.stringValue)") applying = false } @@ -131,6 +140,8 @@ public final class StartupModeView: NSView { package var modeText: String { detail.stringValue } package var modeTextColor: NSColor? { detail.textColor } + package var modeDetailVisible: Bool { !detail.isHidden } + package var modeToolTip: String? { toggle.toolTip } package var isLaunchAtLoginOn: Bool { toggle.state == .on } package var isLaunchAtLoginToggleEnabled: Bool { toggle.isEnabled } package var showsRemediationButton: Bool { !remediationButton.isHidden } diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift index 6ed8b2bf634..4f63c8b2c64 100644 --- a/app/Sources/MenuBarUI/Views.swift +++ b/app/Sources/MenuBarUI/Views.swift @@ -133,26 +133,18 @@ public final class CatalogUpdateView: NSView { package func activateForTesting() { applyButton.performClick(nil) } } -// MARK: - Status header +// MARK: - Brand header and status footer -/// Brand mark + separate proxy-health, readiness, in-flight-request, and Codex-route status. -public final class StatusHeaderView: NSView { +/// App identity with a compact update action when a release needs attention. +public final class BrandHeaderView: NSView { private let brand = NSImageView() private let title = makeLabel("CodexCommander", font: Theme.title, color: Theme.text) - private let dot = StatusDotView() - private let status = makeLabel("", font: Theme.captionMedium, color: Theme.muted) - private let requestCount = makeLabel("", font: Theme.caption, color: Theme.faint) - private let readiness = makeLabel("", font: Theme.micro, color: Theme.faint) - private let codexRoute = makeLabel("", font: Theme.micro, color: Theme.faint) - private let divider: NSView = { - let view = NSView() - view.wantsLayer = true - view.layer?.backgroundColor = Theme.cardBorder.cgColor - view.translatesAutoresizingMaskIntoConstraints = false - view.widthAnchor.constraint(equalToConstant: 1).isActive = true - view.heightAnchor.constraint(equalToConstant: 18).isActive = true - return view - }() + private let dashboardButton = NSButton() + private let moreActionsButton = NSButton() + private let updateButton = NSButton() + var onDashboard: (() -> Void)? + var onMoreActions: ((NSButton) -> Void)? + var onUpdate: (() -> Void)? init() { super.init(frame: .zero) @@ -164,16 +156,25 @@ public final class StatusHeaderView: NSView { brand.widthAnchor.constraint(equalToConstant: 25).isActive = true brand.heightAnchor.constraint(equalToConstant: 25).isActive = true - let left = makeRow([brand, title], spacing: 8) - let proxyState = makeRow([dot, status, divider, requestCount], spacing: 7) - let right = NSStackView(views: [proxyState, readiness, codexRoute]) - right.orientation = .vertical - right.alignment = .trailing - right.spacing = 1 - let row = NSStackView(views: [left, NSView(), right]) - row.orientation = .horizontal - row.alignment = .centerY - row.distribution = .fill + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + configureIconButton(dashboardButton, symbol: "square.grid.2x2", label: "Open dashboard") + dashboardButton.action = #selector(dashboardTapped) + configureIconButton(moreActionsButton, symbol: "ellipsis.circle", label: "More actions") + moreActionsButton.action = #selector(moreActionsTapped) + + updateButton.image = NSImage(systemSymbolName: "arrow.down.circle", accessibilityDescription: nil) + updateButton.imagePosition = .imageLeading + updateButton.bezelStyle = .recessed + updateButton.isBordered = false + updateButton.controlSize = .small + updateButton.font = Theme.captionMedium + updateButton.contentTintColor = Theme.amber + updateButton.setButtonType(.momentaryPushIn) + updateButton.target = self + updateButton.action = #selector(updateTapped) + updateButton.isHidden = true + + let row = makeRow([brand, title, NSView(), dashboardButton, moreActionsButton, updateButton], spacing: 7) row.translatesAutoresizingMaskIntoConstraints = false addSubview(row) NSLayoutConstraint.activate([ @@ -185,63 +186,141 @@ public final class StatusHeaderView: NSView { ]) setAccessibilityElement(true) setAccessibilityRole(.group) - setAccessibilityLabel("CodexCommander status") + setAccessibilityLabel("CodexCommander") } required init?(coder: NSCoder) { nil } - func apply(_ snapshot: ProxySnapshot) { - let state = snapshot.state - let activeCount = snapshot.activity?.activeTurnCount - status.stringValue = "Proxy \(state.title.lowercased())" - status.textColor = Theme.color(for: bridge(state.tone)) - dot.tone = bridge(state.tone) - - if let activeCount, snapshot.activityLoaded { - requestCount.stringValue = activeCount == 1 ? "1 in flight" : "\(activeCount) in flight" - requestCount.isHidden = false - divider.isHidden = false - } else { - requestCount.stringValue = "" - requestCount.isHidden = true - divider.isHidden = true - } + private func configureIconButton(_ button: NSButton, symbol: String, label: String) { + button.title = "" + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + button.imagePosition = .imageOnly + button.bezelStyle = .recessed + button.isBordered = false + button.controlSize = .small + button.contentTintColor = Theme.text + button.setButtonType(.momentaryPushIn) + button.target = self + button.setAccessibilityLabel(label) + button.toolTip = label + button.widthAnchor.constraint(equalToConstant: 27).isActive = true + } + + func setDashboardEnabled(_ enabled: Bool) { + dashboardButton.isEnabled = enabled + dashboardButton.alphaValue = enabled ? 1 : 0.45 + } + + func setMoreActionsEnabled(_ enabled: Bool, label: String) { + moreActionsButton.isEnabled = enabled + moreActionsButton.alphaValue = enabled ? 1 : 0.45 + moreActionsButton.setAccessibilityLabel(label) + moreActionsButton.toolTip = label + } + + func setUpdateAction(title: String?, enabled: Bool, message: String) { + updateButton.isHidden = title == nil + guard let title else { return } + updateButton.title = title == "Finish Update…" ? "Finish Update" : "Update" + updateButton.isEnabled = enabled + updateButton.alphaValue = enabled ? 1 : 0.45 + updateButton.setAccessibilityLabel(title) + updateButton.toolTip = message.isEmpty ? title : message + } + + var updateActionTitleForTesting: String? { + updateButton.isHidden ? nil : updateButton.accessibilityLabel() + } + var updateActionEnabledForTesting: Bool { updateButton.isEnabled } + var dashboardEnabledForTesting: Bool { dashboardButton.isEnabled } + var dashboardAccessibilityLabelForTesting: String? { dashboardButton.accessibilityLabel() } + var moreActionsEnabledForTesting: Bool { moreActionsButton.isEnabled } + var moreActionsAccessibilityLabelForTesting: String? { moreActionsButton.accessibilityLabel() } + package var actionLayoutFitsForTesting: Bool { + layoutSubtreeIfNeeded() + let controls = [dashboardButton, moreActionsButton, updateButton].filter { !$0.isHidden } + return title.frame.width > 0 + && title.frame.maxX <= dashboardButton.frame.minX + && zip(controls, controls.dropFirst()).allSatisfy { pair in + pair.0.frame.maxX <= pair.1.frame.minX + } + && controls.last.map { $0.frame.maxX <= bounds.maxX } == true + } + func clickDashboardForTesting() { dashboardButton.performClick(nil) } + func clickUpdateForTesting() { updateButton.performClick(nil) } - let readinessText = "Readiness · \(Self.readinessName(snapshot.readiness))" - readiness.stringValue = readinessText + @objc private func dashboardTapped() { onDashboard?() } + @objc private func moreActionsTapped() { onMoreActions?(moreActionsButton) } + @objc private func updateTapped() { onUpdate?() } +} - let routeText: String? - switch snapshot.codexRoute { - case .confirmed(let route): - routeText = "Codex route · \(Self.codexRouteName(route))" - case .confirmationUnavailable: - routeText = "Codex route · Unconfirmed" - case .unobserved: - if case .running(let health) = state { - routeText = "Codex route · \(Self.codexRouteName(health))" - } else { - routeText = nil - } - } +/// One compact verdict for proxy liveness and readiness, plus independent Codex route context. +public final class StatusFooterView: NSView { + private let dot = StatusDotView() + private let status = makeLabel("Checking", font: Theme.captionMedium, color: Theme.muted) + private let codexRoute = makeLabel("", font: Theme.micro, color: Theme.faint) + + init() { + super.init(frame: .zero) + let row = makeRow([dot, status, NSView(), codexRoute], spacing: 7) + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + NSLayoutConstraint.activate([ + row.topAnchor.constraint(equalTo: topAnchor), + row.leadingAnchor.constraint(equalTo: leadingAnchor), + row.trailingAnchor.constraint(equalTo: trailingAnchor), + row.bottomAnchor.constraint(equalTo: bottomAnchor), + heightAnchor.constraint(greaterThanOrEqualToConstant: 20), + ]) + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityLabel("Proxy checking") + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + let presentation = Self.presentation(for: snapshot) + status.stringValue = presentation.label + status.textColor = Theme.color(for: presentation.tone) + dot.tone = presentation.tone + + let routeText = Self.routeText(for: snapshot) codexRoute.stringValue = routeText ?? "" codexRoute.isHidden = routeText == nil - - var label = "CodexCommander, proxy \(state.title.lowercased())" - if let activeCount, snapshot.activityLoaded { - label += ", \(activeCount) request\(activeCount == 1 ? "" : "s") in flight" - } - label += ", \(readinessText)" + var label = "Proxy \(presentation.label.lowercased())" if let routeText { label += ", \(routeText)" } setAccessibilityLabel(label) } - private static func readinessName(_ state: ProxyReadinessState) -> String { - switch state { - case .unknown: return "Checking" - case .unavailable: return "Unavailable" - case .pending: return "Starting" - case .ready: return "Ready" - case .failed: return "Startup failed" + private static func presentation(for snapshot: ProxySnapshot) -> (label: String, tone: ProxyToneBridge) { + switch snapshot.state { + case .loading: return ("Checking", .neutral) + case .unreachable: return ("Stopped", .bad) + case .unauthorized: return ("Authentication needed", .warning) + case .degraded: return ("Connection issue", .warning) + case .running(let health): + if snapshot.readiness == .failed { return ("Sync failed", .warning) } + if health.status == "at-risk" { return ("Startup at risk", .warning) } + switch snapshot.readiness { + case .unknown: return ("Checking", .neutral) + case .unavailable: return ("Readiness unavailable", .warning) + case .pending: return ("Starting", .neutral) + case .ready: return ("Ready", bridge(snapshot.state.tone)) + case .failed: return ("Sync failed", .warning) + } + } + } + + private static func routeText(for snapshot: ProxySnapshot) -> String? { + guard case .running(let health) = snapshot.state else { + return snapshot.state == .loading ? nil : "Codex: Unconfirmed" + } + switch snapshot.codexRoute { + case .confirmed(let route): return "Codex: \(codexRouteName(route))" + case .confirmationUnavailable: return "Codex: Unconfirmed" + case .unobserved: + return health.diagnosticStale ? "Codex: Unconfirmed" : "Codex: \(codexRouteName(health))" } } @@ -266,7 +345,7 @@ public final class StatusHeaderView: NSView { } } - private func bridge(_ tone: ProxyState.Tone) -> ProxyToneBridge { + private static func bridge(_ tone: ProxyState.Tone) -> ProxyToneBridge { switch tone { case .neutral: return .neutral case .good: return .good @@ -276,10 +355,6 @@ public final class StatusHeaderView: NSView { } package var statusText: String { status.stringValue } - package var requestCountText: String? { - requestCount.isHidden ? nil : requestCount.stringValue - } - package var readinessText: String { readiness.stringValue } package var codexRouteText: String? { codexRoute.isHidden ? nil : codexRoute.stringValue } @@ -315,6 +390,7 @@ final class StatusDotView: NSView { /// One-level tree of in-flight primary/child turns; orphan child requests stand alone. public final class AgentActivityView: NSView { private let heading = makeLabel("Live proxy requests", font: Theme.captionMedium, color: Theme.muted) + private let count = makeLabel("", font: Theme.caption, color: Theme.faint) private let body = NSStackView() private let empty = makeLabel("", font: Theme.caption, color: Theme.muted) @@ -327,7 +403,9 @@ public final class AgentActivityView: NSView { empty.maximumNumberOfLines = 2 empty.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - let stack = NSStackView(views: [heading, body, empty]) + count.isHidden = true + let header = makeRow([heading, NSView(), count]) + let stack = NSStackView(views: [header, body, empty]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = Theme.tightGap @@ -338,6 +416,7 @@ public final class AgentActivityView: NSView { stack.leadingAnchor.constraint(equalTo: leadingAnchor), stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), + header.widthAnchor.constraint(equalTo: stack.widthAnchor), body.widthAnchor.constraint(equalTo: stack.widthAnchor), empty.widthAnchor.constraint(equalTo: stack.widthAnchor), ]) @@ -355,6 +434,7 @@ public final class AgentActivityView: NSView { } guard snapshot.activityLoaded else { + count.isHidden = true body.isHidden = true empty.isHidden = false empty.stringValue = "Request activity unavailable" @@ -363,6 +443,7 @@ public final class AgentActivityView: NSView { } guard let activity = snapshot.activity, activity.isSupported else { + count.isHidden = true body.isHidden = true empty.isHidden = false empty.stringValue = "Request activity unavailable" @@ -373,12 +454,15 @@ public final class AgentActivityView: NSView { let visible = activity.activities.filter { $0.phase == .starting || $0.phase == .running } + let activeCount = max(0, activity.activeTurnCount) + count.stringValue = activeCount == 1 ? "1 in flight" : "\(activeCount) in flight" + count.isHidden = false if visible.isEmpty { body.isHidden = true - empty.isHidden = false - empty.stringValue = "No requests in flight" - setAccessibilityLabel(empty.stringValue) + empty.isHidden = true + empty.stringValue = "" + setAccessibilityLabel("Live proxy requests, \(count.stringValue)") return } @@ -420,7 +504,7 @@ public final class AgentActivityView: NSView { body.addArrangedSubview(note) } - setAccessibilityLabel("Live proxy requests, \(visible.count) shown") + setAccessibilityLabel("Live proxy requests, \(count.stringValue), \(visible.count) shown") } private func appendRow(_ activity: AgentActivity, indented: Bool) { @@ -431,6 +515,7 @@ public final class AgentActivityView: NSView { } package var headingText: String { heading.stringValue } + package var countText: String? { count.isHidden ? nil : count.stringValue } package var emptyText: String? { empty.isHidden ? nil : empty.stringValue } } @@ -654,7 +739,7 @@ final class QuotaWindowRowView: NSView { } } -/// Text contract for published caps and local observations. Kept separate from +/// Text contract for local observations. Kept separate from /// `NormalizedQuota`: these values must never acquire a percentage bar unless the /// upstream provider actually supplied one. package enum ReferenceQuotaPresentation { @@ -669,12 +754,23 @@ package enum ReferenceQuotaPresentation { } } - package static func capText(_ window: QuotaReferenceWindow) -> String { - "\(horizon(window)) · Published cap \(Format.usdCap(window.publishedLimitUsd))" + package static func title(_ window: QuotaReferenceWindow) -> String { + "\(horizon(window)) · Local usage" } - package static func compactCapText(_ window: QuotaReferenceWindow) -> String { - "\(Format.usdCap(window.publishedLimitUsd))/\(horizon(window))" + package static func compactObservationText(_ window: QuotaReferenceWindow) -> String { + let horizon = horizon(window) + switch window.observationQuality { + case .none: + return "\(horizon) no local usage" + case .estimate: + return "\(horizon) estimated \(Format.usdEstimate(window.observedSpendUsd))" + case .partial: + if let spend = window.observedSpendUsd { + return "\(horizon) partial estimate \(Format.usdEstimate(spend))" + } + return "\(horizon) partial · \(Format.count(window.observedTokens)) tokens" + } } package static func observationText(_ window: QuotaReferenceWindow) -> String { @@ -719,14 +815,14 @@ package enum ReferenceQuotaPresentation { } } -/// A published cap with local estimate/coverage text. No progress bar is rendered: -/// dividing observed spend by this cap would manufacture provider quota state. +/// Local estimate/coverage text. No progress bar is rendered because local +/// observations cannot establish a provider quota percentage. final class ReferenceQuotaWindowRowView: NSView { init(window: QuotaReferenceWindow) { super.init(frame: .zero) - let cap = makeLabel( - ReferenceQuotaPresentation.capText(window), + let title = makeLabel( + ReferenceQuotaPresentation.title(window), font: Theme.captionMedium, color: Theme.text ) @@ -736,7 +832,7 @@ final class ReferenceQuotaWindowRowView: NSView { color: Theme.muted ) observation.lineBreakMode = .byTruncatingTail - let column = NSStackView(views: [cap, observation]) + let column = NSStackView(views: [title, observation]) column.orientation = .vertical column.alignment = .leading column.spacing = 2 @@ -754,7 +850,7 @@ final class ReferenceQuotaWindowRowView: NSView { setAccessibilityElement(true) setAccessibilityRole(.staticText) setAccessibilityLabel( - "\(ReferenceQuotaPresentation.capText(window)). \(ReferenceQuotaPresentation.observationText(window))." + "\(ReferenceQuotaPresentation.title(window)). \(ReferenceQuotaPresentation.observationText(window))." ) } diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index 49688df877a..03e40610ec2 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -55,14 +55,15 @@ func quotaJSON( label: String, fiveHour: Double? = nil, weekly: Double? = nil, - monthly: Double? = nil + monthly: Double? = nil, + updatedAt: Int64 = 1 ) -> String { var parts: [String] = [] if let fiveHour { parts.append("\"fiveHourPercent\":\(fiveHour)") } if let weekly { parts.append("\"weeklyPercent\":\(weekly)") } if let monthly { parts.append("\"monthlyPercent\":\(monthly)") } - let quota = "{\"updatedAt\":1\(parts.isEmpty ? "" : "," + parts.joined(separator: ","))}" - return "{\"provider\":\"\(provider)\",\"label\":\"\(label)\",\"source\":\"test\",\"quota\":\(quota),\"updatedAt\":1}" + let quota = "{\"updatedAt\":\(updatedAt)\(parts.isEmpty ? "" : "," + parts.joined(separator: ","))}" + return "{\"provider\":\"\(provider)\",\"label\":\"\(label)\",\"source\":\"test\",\"quota\":\(quota),\"updatedAt\":\(updatedAt)}" } func decodeQuotas(_ json: String) -> [QuotaReport] { @@ -143,7 +144,10 @@ func makeSnapshot( quotaAvailability: [ProviderQuotaAvailability] = [], activity: AgentActivitySnapshot? = nil, providers: [ProviderSummary] = [], + openCodeGoModelUsage: [ProviderModelUsage] = [], + openCodeGoUsageLoaded: Bool = false, health: StartupHealth = currentHealth(), + state: ProxyState? = nil, readiness: ProxyReadinessState = .unknown, recommendedCommand: String? = nil, providersLoaded: Bool = false, @@ -151,13 +155,15 @@ func makeSnapshot( activityLoaded: Bool = true ) -> ProxySnapshot { ProxySnapshot( - state: .running(health), + state: state ?? .running(health), readiness: readiness, endpoint: .default, quotas: quotas, quotaAvailability: quotaAvailability, activity: activity, providers: providers, + openCodeGoModelUsage: openCodeGoModelUsage, + openCodeGoUsageLoaded: openCodeGoUsageLoaded, lastUpdated: Date(), recommendedCommand: recommendedCommand, providersLoaded: providersLoaded, @@ -166,11 +172,51 @@ func makeSnapshot( ) } +func menuPreviewSnapshot() -> ProxySnapshot { + let now = Int64(Date().timeIntervalSince1970 * 1_000) + let quotas = decodeQuotas(""" + [ + \(quotaJSON(provider: "openai", label: "ChatGPT", weekly: 49, updatedAt: now)), + \(quotaJSON(provider: "opencode-go", label: "OpenCode Go", fiveHour: 4, weekly: 2, monthly: 1, updatedAt: now)) + ] + """) + let activity = activitySnapshot(activities: """ + {"id":"preview-primary","role":"primary","provider":"opencode-go", + "model":"deepseek-v4.1-flash","phase":"running","startedAt":\(now - 120_000)}, + {"id":"preview-child","role":"subagent","provider":"opencode-go", + "model":"gpt-6-luna","phase":"running","startedAt":\(now - 45_000)} + """, activeTurnCount: 2) + return makeSnapshot(quotas: quotas, activity: activity, readiness: .ready) +} + func textFields(in view: NSView) -> [NSTextField] { let own = (view as? NSTextField).map { [$0] } ?? [] return own + view.subviews.flatMap(textFields(in:)) } +func writePanelScreenshot(_ controller: PopoverViewController, to output: String) { + let window = NSWindow(contentRect: controller.view.bounds, styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: .darkAqua) + window.contentView = controller.view + window.backgroundColor = NSColor(calibratedWhite: 0.12, alpha: 1) + controller.view.layoutSubtreeIfNeeded() + window.displayIfNeeded() + guard let bitmap = controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds) else { return } + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + let rendered = NSImage(size: controller.view.bounds.size) + rendered.lockFocus() + NSColor(calibratedWhite: 0.12, alpha: 1).setFill() + controller.view.bounds.fill() + let foreground = NSImage(size: controller.view.bounds.size) + foreground.addRepresentation(bitmap) + NSGraphicsContext.current?.imageInterpolation = .high + foreground.draw(in: controller.view.bounds, from: .zero, operation: .sourceOver, fraction: 1) + rendered.unlockFocus() + if let tiff = rendered.tiffRepresentation, let opaque = NSBitmapImageRep(data: tiff) { + try? opaque.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: output)) + } +} + // MARK: - Hierarchy / sizing runner.test("ui: panel prefers the approved width") { @@ -217,19 +263,103 @@ runner.test("ui: running proxy keeps the terminal glyph regardless of service pr runner.equal(Set(symbols).count, states.count, "every other operational state stays distinct") } -runner.test("ui: footer exposes navigation, lifecycle, Codex routing, and a single exit") { +runner.test("ui: quotas precede live requests and the status is the panel footer") { let controller = PopoverViewController() _ = controller.view + runner.expect(controller.monitoringPrecedesPanelFooter, + "quota and request scrolling ends above the fixed status footer") + runner.equal(controller.visibleHeaderActionTitles, ["Dashboard", "More actions"], + "dashboard remains prominent without a separate navigation row") + runner.equal(controller.moreActionTitles, ["Refresh Models", "Logs", "Check for Updates…"], + "secondary actions live in More actions") + runner.equal(controller.headerUpdateTitleForTesting, nil, + "the header has no update action without an available update") let titles = controller.footerTitles runner.equal( titles, [ - "Dashboard", "Logs", "Refresh", "Start Proxy", "Restart Proxy…", + "Dashboard", "Logs", "Refresh Models", "Start Proxy", "Restart…", "Restore Native Codex", "Route Codex Through Proxy", "Stop CodexCommander and Quit…", ], "footer titles" ) + controller.apply(makeSnapshot()) + runner.equal(controller.visibleRouteActionTitles, ["Use Native"], + "confirmed Commander route shows its available alternative") + runner.equal(controller.primaryActionTitles, ["Stop Proxy…", "Restart…", "Use Native"], + "proxy and the available route action share the first row") + runner.equal(controller.separateRouteRowVisible, false) + runner.equal(controller.visibleUpperControlRowCount, 3, + "normal controls use two action rows and the login row") + if let output = ProcessInfo.processInfo.environment["CCX_ACTION_LAYOUT_SCREENSHOT"] { + controller.apply(menuPreviewSnapshot()) + writePanelScreenshot(controller, to: output) + } + runner.equal(controller.footerEnabledStates[2], true, "model refresh is available while proxy runs") + controller.setLifecycleControlsEnabled(false) + runner.equal(controller.footerEnabledStates[2], false, "model refresh is guarded during lifecycle actions") + controller.setLifecycleControlsEnabled(true) + controller.apply(makeSnapshot(state: .unreachable)) + runner.equal(controller.footerEnabledStates[2], false, "model refresh is unavailable when proxy is stopped") + runner.equal(controller.moreActionEnabledStates, [false, false, true], + "stopped proxy disables refresh and Logs while leaving update checks available") +} + +runner.test("ui: update action moves between the header and More actions") { + let controller = PopoverViewController() + _ = controller.view + controller.apply(makeSnapshot()) + var checks = 0 + controller.onCheckForUpdates = { checks += 1 } + controller.applyUpdatePresentation(title: "Update Available…", enabled: true, + blocked: false, message: "") + runner.equal(controller.headerUpdateTitleForTesting, "Update Available…") + runner.equal(controller.moreActionTitles, ["Refresh Models", "Logs"]) + runner.expect(controller.headerView.actionLayoutFitsForTesting, + "dashboard and available update fit beside the app title") + if let output = ProcessInfo.processInfo.environment["CCX_ACTION_LAYOUT_UPDATE_SCREENSHOT"] { + controller.apply(menuPreviewSnapshot()) + writePanelScreenshot(controller, to: output) + } + controller.clickHeaderUpdateForTesting() + runner.equal(checks, 1) + + controller.applyUpdatePresentation(title: "Check for Updates…", enabled: true, + blocked: false, message: "") + runner.equal(controller.headerUpdateTitleForTesting, nil) + runner.equal(controller.moreActionTitles, ["Refresh Models", "Logs", "Check for Updates…"]) + controller.activateMoreActionForTesting(2) + runner.equal(checks, 2) + runner.expect(controller.headerView.actionLayoutFitsForTesting, + "dashboard and More actions fit without an update") +} + +runner.test("ui: route controls show both recovery choices when route is uncertain") { + let controller = PopoverViewController() + _ = controller.view + controller.apply(makeSnapshot(health: currentHealth( + status: "native", routingKind: "native", routingInjected: false + ))) + runner.equal(controller.visibleRouteActionTitles, ["Use Commander"], + "confirmed native route shows its available alternative") + runner.equal(controller.primaryActionTitles, ["Stop Proxy…", "Restart…", "Use Commander"]) + + var unknown = makeSnapshot() + unknown.codexRoute = .confirmationUnavailable + controller.apply(unknown) + runner.equal(controller.visibleRouteActionTitles, + ["Restore Native Codex", "Route Codex Through Proxy"], + "unconfirmed route keeps both recovery choices") + runner.equal(controller.separateRouteRowVisible, true, + "uncertain routing gives both recovery actions their own row") + if let output = ProcessInfo.processInfo.environment["CCX_ACTION_LAYOUT_UNKNOWN_SCREENSHOT"] { + writePanelScreenshot(controller, to: output) + } + controller.apply(makeSnapshot()) + runner.equal(controller.visibleRouteActionTitles, ["Use Native"]) + runner.equal(controller.separateRouteRowVisible, false, + "route recovery row collapses after confirmation") } runner.test("ui: catalog update presents manual ChatGPT restart outside the proxy footer") { @@ -297,6 +427,9 @@ runner.test("ui: startup control exposes desktop, headless, off, and approval st controller.startupModeView.modeText, "Desktop · starts CodexCommander at login" ) + runner.equal(controller.startupModeView.modeDetailVisible, false) + runner.equal(controller.startupModeView.modeToolTip, + "Desktop · starts CodexCommander at login") runner.equal(controller.startupModeView.isLaunchAtLoginOn, true) controller.applyLaunchAtLogin( @@ -310,12 +443,18 @@ runner.test("ui: startup control exposes desktop, headless, off, and approval st controller.startupModeView.modeText, "Headless · proxy runs without the menu bar" ) + runner.equal(controller.startupModeView.modeDetailVisible, false) + runner.equal(controller.startupModeView.modeToolTip, + "Headless · proxy runs without the menu bar") controller.apply(makeSnapshot(health: currentHealth(status: "at-risk"))) runner.equal( controller.startupModeView.modeText, "Off · start CodexCommander manually" ) + runner.equal(controller.startupModeView.modeDetailVisible, false) + runner.equal(controller.startupModeView.modeToolTip, + "Off · start CodexCommander manually") controller.applyLaunchAtLogin( LaunchAtLoginPresentation( @@ -325,6 +464,8 @@ runner.test("ui: startup control exposes desktop, headless, off, and approval st ) ) runner.equal(controller.startupModeView.isLaunchAtLoginToggleEnabled, false) + runner.equal(controller.startupModeView.modeDetailVisible, true, + "approval guidance remains visible beside its recovery action") runner.expect( controller.startupModeView.showsRemediationButton, "approval state should expose Login Items settings" @@ -382,6 +523,8 @@ runner.test("ui: relocation guidance is neutral and opens Applications on explic ) ) let errorColor = controller.startupModeView.modeTextColor + runner.equal(controller.startupModeView.modeDetailVisible, true, + "startup errors stay visible without hovering") controller.applyLaunchAtLogin( LaunchAtLoginPresentation( @@ -396,6 +539,7 @@ runner.test("ui: relocation guidance is neutral and opens Applications on explic controller.startupModeView.modeText, "Move CodexCommander to Applications to launch at login." ) + runner.equal(controller.startupModeView.modeDetailVisible, true) runner.expect( controller.startupModeView.modeTextColor != errorColor, "relocation detail must use the neutral faint tone, not the error tone" @@ -657,20 +801,20 @@ runner.test("ui: overflowing provider content fits the scroll viewport") { ) } -runner.test("ui: OpenCode Go renders published caps and honest local observation semantics") { +runner.test("ui: OpenCode Go renders local observations without obsolete caps") { let quotas = decodeQuotas(""" [{"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1,"quota":{"updatedAt":1, "referenceWindows":[ {"id":"five_hour","label":"5-hour","windowSeconds":18000, - "publishedLimitUsd":12,"observedSpendUsd":0.3,"observedTokens":1000120, + "observedSpendUsd":0.3,"observedTokens":1000120, "observedRequests":3,"pricedRequests":3,"unpricedRequests":0, "unmeasuredRequests":0,"coverage":"complete"}, {"id":"weekly","label":"7-day","windowSeconds":604800, - "publishedLimitUsd":30,"observedSpendUsd":1.1,"observedTokens":2400000, + "observedSpendUsd":1.1,"observedTokens":2400000, "observedRequests":4,"pricedRequests":2,"unpricedRequests":1, "unmeasuredRequests":1,"coverage":"partial"}, {"id":"monthly","label":"30-day","windowSeconds":2592000, - "publishedLimitUsd":60,"observedTokens":0,"observedRequests":0, + "observedTokens":0,"observedRequests":0, "pricedRequests":0,"unpricedRequests":0,"unmeasuredRequests":0, "coverage":"none"}], "observedLimitEvent":{"limitName":"weekly","observedAt":1784915000000, @@ -678,9 +822,12 @@ runner.test("ui: OpenCode Go renders published caps and honest local observation """) let report = quotas[0] let references = report.referenceWindows - runner.equal(ReferenceQuotaPresentation.capText(references[0]), "5h · Published cap $12") - runner.equal(ReferenceQuotaPresentation.capText(references[1]), "7d · Published cap $30") - runner.equal(ReferenceQuotaPresentation.capText(references[2]), "30d · Published cap $60") + runner.equal(ReferenceQuotaPresentation.title(references[0]), "5h · Local usage") + runner.equal(ReferenceQuotaPresentation.title(references[1]), "7d · Local usage") + runner.equal(ReferenceQuotaPresentation.title(references[2]), "30d · Local usage") + runner.equal(ReferenceQuotaPresentation.compactObservationText(references[0]), "5h estimated $0.30") + runner.equal(ReferenceQuotaPresentation.compactObservationText(references[1]), "7d partial estimate $1.10") + runner.equal(ReferenceQuotaPresentation.compactObservationText(references[2]), "30d no local usage") runner.equal( ReferenceQuotaPresentation.observationText(references[0]), "Estimate $0.30 · 1,000,120 tokens · 3 requests" @@ -704,9 +851,10 @@ runner.test("ui: OpenCode Go renders published caps and honest local observation ) let rendered = references.flatMap { - [ReferenceQuotaPresentation.capText($0), ReferenceQuotaPresentation.observationText($0)] + [ReferenceQuotaPresentation.title($0), ReferenceQuotaPresentation.observationText($0)] }.joined(separator: " ") runner.expect(!rendered.contains("%"), "reference data must not manufacture a percentage") + runner.expect(!rendered.contains("cap"), "local observations must not claim a current cap") let accordion = ProviderQuotaAccordionView() accordion.apply(makeSnapshot(quotas: quotas)) @@ -714,6 +862,55 @@ runner.test("ui: OpenCode Go renders published caps and honest local observation runner.expect(accordion.expandedProviderIDs.contains("opencode-go"), "reference provider expands") } +runner.test("ui: OpenCode Go ignores obsolete caps in an older proxy payload") { + let quotas = decodeQuotas(""" + [{"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1, + "quota":{"updatedAt":1,"referenceWindows":[{ + "id":"five_hour","label":"5-hour","windowSeconds":18000, + "publishedLimitUsd":12,"observedSpendUsd":0.3,"observedTokens":100, + "observedRequests":1,"pricedRequests":1,"unpricedRequests":0, + "unmeasuredRequests":0,"coverage":"complete"}]}}] + """) + let window = quotas[0].referenceWindows[0] + runner.equal(window.publishedLimitUsd, 12) + let presentation = "\(ReferenceQuotaPresentation.title(window)) · \(ReferenceQuotaPresentation.observationText(window))" + runner.expect(!presentation.contains("$12"), "legacy caps must not appear as current limits") + runner.expect(presentation.contains("$0.30"), "local estimate remains visible") +} + +runner.test("ui: OpenCode Go shows the active model and completed per-model usage") { + let quotas = decodeQuotas(""" + [{"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1, + "quota":{"updatedAt":1,"fiveHourPercent":12,"weeklyPercent":40, + "monthlyPercent":65}}] + """) + let usage = try! JSONDecoder().decode(ProviderUsageEnvelope.self, from: Data(""" + {"generatedAt":1784915336899,"models":[ + {"provider":"opencode-go","model":"glm-5.2","requests":3,"measuredRequests":2, + "totalTokens":1200,"inputTokens":1000,"outputTokens":200,"estimatedCostUsd":0.4}, + {"provider":"opencode-go","model":"new-unpriced","requests":1,"measuredRequests":0, + "totalTokens":0,"inputTokens":0,"outputTokens":0}]} + """.utf8)) + let activity = activitySnapshot(activities: """ + {"id":"go","role":"primary","provider":"opencode-go","model":"glm-5.2", + "phase":"running","startedAt":1} + """, activeTurnCount: 1) + let accordion = ProviderQuotaAccordionView() + accordion.apply(makeSnapshot( + quotas: quotas, + activity: activity, + openCodeGoModelUsage: usage.models, + openCodeGoUsageLoaded: true + )) + let text = accordion.modelUsageTextForTesting("opencode-go") + runner.equal(quotas[0].normalizedWindows().count, 3, "live Go quota windows are present") + runner.expect(text.contains("In flight · glm-5.2"), "live model is separate from completed usage") + runner.expect(text.contains("glm-5.2 · 3 completed requests · 1,200 measured tokens (partial) · estimated $0.40"), + "partially measured totals") + runner.expect(text.contains("new-unpriced · 1 completed request · tokens unknown"), + "unmeasured and unpriced usage is not a false zero") +} + // MARK: - Deep links runner.test("ui: provider deep-link encoding preserves safe ids") { @@ -752,6 +949,7 @@ runner.test("ui: activity empty and unavailable states stay compact") { let unloaded = makeSnapshot(activityLoaded: false) controller.apply(unloaded) runner.equal(controller.activityView.headingText, "Live proxy requests", "request heading") + runner.equal(controller.activityView.countText, nil, "unknown count is not shown as zero") runner.equal(controller.activityView.emptyText, "Request activity unavailable", "unavailable copy") runner.expect(controller.activityView.accessibilityLabel()?.contains("unavailable") == true || controller.activityView.accessibilityLabel()?.contains("Activity") == true, @@ -759,23 +957,24 @@ runner.test("ui: activity empty and unavailable states stay compact") { let empty = activitySnapshot(activities: "", activeTurnCount: 0) controller.apply(makeSnapshot(activity: empty)) - runner.equal(controller.activityView.emptyText, "No requests in flight", "empty request copy") + runner.equal(controller.activityView.countText, "0 in flight", "idle count moves into the heading") + runner.equal(controller.activityView.emptyText, nil, "idle state needs no duplicate empty row") // Should not crash and should keep preferred width. runner.equal(controller.preferredContentSize.width, 387, "width stable") } -runner.test("ui: header separates proxy requests from the Codex route") { +runner.test("ui: compact status footer leaves the brand header and request heading uncluttered") { let controller = PopoverViewController() _ = controller.view let activity = activitySnapshot(activities: "", activeTurnCount: 2) controller.apply(makeSnapshot(activity: activity)) - runner.equal(controller.headerView.statusText, "Proxy running", "proxy status") - runner.equal(controller.headerView.requestCountText, "2 in flight", "request count") - runner.equal(controller.headerView.readinessText, "Readiness · Checking", "initial readiness") + runner.equal(controller.headerView.accessibilityLabel(), "CodexCommander", "brand-only header") + runner.equal(controller.activityView.countText, "2 in flight", "request count beside activity heading") + runner.equal(controller.statusFooterView.statusText, "Checking", "initial combined status") runner.equal( - controller.headerView.codexRouteText, - "Codex route · CodexCommander", + controller.statusFooterView.codexRouteText, + "Codex: CodexCommander", "managed Codex route" ) @@ -787,17 +986,17 @@ runner.test("ui: header separates proxy requests from the Codex route") { routingInjected: false ) )) - runner.equal(controller.headerView.codexRouteText, "Codex route · Native OpenAI", "native route") - runner.expect( - controller.headerView.accessibilityLabel()?.contains("2 requests in flight") == true, - "request count is explicit to assistive technology" - ) + runner.equal(controller.statusFooterView.codexRouteText, "Codex: Native OpenAI", "native route") + runner.expect(controller.activityView.accessibilityLabel()?.contains("2 in flight") == true, + "request count is explicit to assistive technology") + runner.expect(controller.statusFooterView.accessibilityLabel()?.contains("in flight") == false, + "status footer does not repeat the request count") controller.apply(makeSnapshot( activity: activity, health: currentHealth(diagnosticStale: true) )) - runner.equal(controller.headerView.codexRouteText, "Codex route · Unknown", "stale route fails closed") + runner.equal(controller.statusFooterView.codexRouteText, "Codex: Unconfirmed", "stale route fails closed") } runner.test("ui: fresh route truth overrides stale startup diagnostics immediately") { @@ -808,8 +1007,8 @@ runner.test("ui: fresh route truth overrides stale startup diagnostics immediate controller.apply(snapshot) runner.equal( - controller.headerView.codexRouteText, - "Codex route · Native OpenAI", + controller.statusFooterView.codexRouteText, + "Codex: Native OpenAI", "focused route observation" ) runner.equal(controller.footerEnabledStates[5], false, "native action follows fresh route") @@ -823,7 +1022,7 @@ runner.test("ui: unconfirmed route truth is explicit and leaves both choices ava snapshot.codexRoute = .confirmationUnavailable controller.apply(snapshot) - runner.equal(controller.headerView.codexRouteText, "Codex route · Unconfirmed") + runner.equal(controller.statusFooterView.codexRouteText, "Codex: Unconfirmed") runner.equal(controller.footerEnabledStates[5], true, "native recovery remains available") runner.equal(controller.footerEnabledStates[6], true, "proxy recovery remains available") } @@ -835,7 +1034,7 @@ runner.test("ui: backend-confirmed unknown stays distinct from unavailable confi snapshot.codexRoute = .confirmed(CodexRouteStatus(routingKind: .unknown)) controller.apply(snapshot) - runner.equal(controller.headerView.codexRouteText, "Codex route · Unknown") + runner.equal(controller.statusFooterView.codexRouteText, "Codex: Unknown") runner.equal(controller.footerEnabledStates[5], true) runner.equal(controller.footerEnabledStates[6], true) } @@ -1019,33 +1218,57 @@ runner.test("ui: current Codex route disables only the redundant route action") runner.equal(controller.footerEnabledStates[6], false, "proxy action disabled in flight") } -runner.test("ui: header keeps readiness separate from liveness and routing") { +runner.test("ui: footer combines liveness and readiness while preserving route context") { let controller = PopoverViewController() _ = controller.view let states: [(ProxyReadinessState, String)] = [ (.unknown, "Checking"), (.pending, "Starting"), (.ready, "Ready"), - (.failed, "Startup failed"), - (.unavailable, "Unavailable"), + (.failed, "Sync failed"), + (.unavailable, "Readiness unavailable"), ] for (state, label) in states { controller.apply(makeSnapshot(readiness: state)) - runner.equal(controller.headerView.statusText, "Proxy running", "liveness stays running for \(label)") - runner.equal(controller.headerView.readinessText, "Readiness · \(label)", "readiness \(label)") + runner.equal(controller.statusFooterView.statusText, label, "combined status \(label)") runner.equal( - controller.headerView.codexRouteText, - "Codex route · CodexCommander", + controller.statusFooterView.codexRouteText, + "Codex: CodexCommander", "route stays independent for \(label)" ) } runner.expect( - controller.headerView.accessibilityLabel()?.contains("Readiness · Unavailable") == true, - "readiness is explicit to assistive technology" + controller.statusFooterView.accessibilityLabel()?.contains("readiness unavailable") == true, + "unavailable readiness is explicit to assistive technology" ) } +runner.test("ui: status footer distinguishes stopped, unverified, and failed states") { + let controller = PopoverViewController() + _ = controller.view + let cases: [(ProxyState, ProxyReadinessState, String)] = [ + (.loading, .unknown, "Checking"), + (.unreachable, .unavailable, "Stopped"), + (.unauthorized, .unavailable, "Authentication needed"), + (.degraded("probe failed"), .unavailable, "Connection issue"), + (.running(currentHealth()), .failed, "Sync failed"), + (.running(currentHealth(status: "at-risk")), .ready, "Startup at risk"), + ] + for (state, readiness, label) in cases { + controller.apply(makeSnapshot(state: state, readiness: readiness)) + runner.equal(controller.statusFooterView.statusText, label, "status \(label)") + } + runner.equal(controller.statusFooterView.codexRouteText, "Codex: CodexCommander", + "a live proxy retains independently observed route context") + + controller.apply(makeSnapshot(state: .unreachable)) + runner.equal(controller.statusFooterView.codexRouteText, "Codex: Unconfirmed", + "a stopped proxy does not present the last route as current") + runner.expect(controller.statusFooterView.accessibilityLabel()?.contains("Proxy stopped") == true, + "combined footer status is accessible") +} + runner.test("ui: activity rows render once and elapsed timers clear the scrollbar") { let now = Int64(Date().timeIntervalSince1970 * 1_000) let activity = activitySnapshot( @@ -1088,12 +1311,13 @@ runner.test("ui: activity rows render once and elapsed timers clear the scrollba } } -runner.test("ui: accessibility labels exist on header and accordion") { +runner.test("ui: accessibility labels exist on header, status footer, and accordion") { let controller = PopoverViewController() _ = controller.view let quotas = decodeQuotas("[\(quotaJSON(provider: "openai", label: "ChatGPT", fiveHour: 12))]") controller.apply(makeSnapshot(quotas: quotas)) _ = runner.notNil(controller.headerView.accessibilityLabel(), "header a11y") + _ = runner.notNil(controller.statusFooterView.accessibilityLabel(), "status a11y") _ = runner.notNil(controller.quotaAccordion.accessibilityLabel(), "quota a11y") } @@ -1113,12 +1337,14 @@ runner.test("ui: running footer invokes proxy, Codex route, and exit actions ind controller.onRestoreNativeCodex = { calls.append("restore-native") } controller.onRouteCodexThroughProxy = { calls.append("restore-back") } controller.onStopAndQuit = { calls.append("stop-and-quit") } + controller.onCheckForUpdates = { calls.append("updates") } for index in 0..<8 { controller.activateFooterForTesting(index) } + controller.activateMoreActionForTesting(2) runner.equal( calls, [ "dashboard", "logs", "refresh", "stop", "restart", - "restore-native", "restore-back", "stop-and-quit", + "restore-native", "restore-back", "stop-and-quit", "updates", ] ) } @@ -1935,7 +2161,12 @@ MainActor.assumeIsolated { let controller = PopoverViewController() controller.applyUpdatePresentation(title: driver.updateAvailable ? "Update Available…" : "Check for Updates…", enabled: true, blocked: false, message: "") - runner.equal(controller.updateActionTitleForTesting, "Update Available…") + runner.equal(controller.headerUpdateTitleForTesting, "Update Available…") + runner.equal(controller.headerUpdateEnabledForTesting, true) + runner.equal(controller.moreActionTitles, ["Refresh Models", "Logs"], + "available update moves out of More actions") + runner.equal(controller.moreActionsAccessibilityLabel, + "More actions: Refresh Models and Logs") decide?(.install) runner.equal(disclosures, 1, "only explicit install opens the pause disclosure") runner.equal(preparations, 0, "Later never prepares or downloads") @@ -2210,35 +2441,17 @@ MainActor.assumeIsolated { controller.apply(ProxySnapshot(state: .unreachable, endpoint: .default)) runner.equal(controller.guidanceText, nil, "guarded recovery must not advise starting the proxy") runner.equal(controller.commandText, nil, "guarded recovery must not advertise ccx start") - runner.equal(controller.updateActionTitleForTesting, "Finish Update…") - runner.equal(controller.updateActionEnabledForTesting, true) + runner.equal(controller.headerUpdateTitleForTesting, "Finish Update…") + runner.equal(controller.headerUpdateEnabledForTesting, true) + runner.equal(controller.moreActionTitles, ["Refresh Models", "Logs"], + "pending update moves out of More actions") var clicked = false controller.onCheckForUpdates = { clicked = true } - controller.clickUpdateForTesting() + controller.clickHeaderUpdateForTesting() runner.equal(clicked, true) if let output = ProcessInfo.processInfo.environment["CCX_UPDATER_SCREENSHOT"] { controller.apply(ProxySnapshot(state: .unreachable, endpoint: .default)) - let window = NSWindow(contentRect: controller.view.bounds, styleMask: [.borderless], backing: .buffered, defer: false) - window.appearance = NSAppearance(named: .darkAqua) - window.contentView = controller.view - window.backgroundColor = NSColor(calibratedWhite: 0.12, alpha: 1) - controller.view.layoutSubtreeIfNeeded() - window.displayIfNeeded() - if let bitmap = controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds) { - controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) - let rendered = NSImage(size: controller.view.bounds.size) - rendered.lockFocus() - NSColor(calibratedWhite: 0.12, alpha: 1).setFill() - controller.view.bounds.fill() - let foreground = NSImage(size: controller.view.bounds.size) - foreground.addRepresentation(bitmap) - NSGraphicsContext.current?.imageInterpolation = .high - foreground.draw(in: controller.view.bounds, from: .zero, operation: .sourceOver, fraction: 1) - rendered.unlockFocus() - if let tiff = rendered.tiffRepresentation, let opaque = NSBitmapImageRep(data: tiff) { - try? opaque.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: output)) - } - } + writePanelScreenshot(controller, to: output) } } } diff --git a/docs-site/public/macos-menu-bar.png b/docs-site/public/macos-menu-bar.png index b0a9be9662e..0a0ccd23dab 100644 Binary files a/docs-site/public/macos-menu-bar.png and b/docs-site/public/macos-menu-bar.png differ diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index e22a98400db..db8e213bfa5 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -29,13 +29,12 @@ In-app updates are available starting with the numbered v0.1.6 build. If you ins v0.1.6 archive without a build number in its name, install the newer build or a later release manually once using the steps above. -![macOS menu with an update action](/macos-updates.png) - The app checks for compatible stable releases quietly in the background. Starting with v0.1.8, it checks -on launch and every six hours while running. When it finds an update, the menu shows -**Update Available…**. Closing the update window or choosing **Later** keeps that reminder available -so you can return to it. Select it to review the version and release notes. -You can also choose **Check for Updates…** to check manually. Checking, including a failed or +on launch and every six hours while running. When it finds an update, the panel header shows +**Update** in the upper right. Closing the update window or choosing **Later** keeps that button available +so you can return to it. Select it to review the version and release notes. If an installation needs +attention, the header button changes to **Finish Update**. +You can also choose **More actions → Check for Updates…** to check manually. Checking, including a failed or offline background check, leaves the proxy running and does not open an unsolicited update window. Downloads and installation require an explicit choice. When you choose to install, a confirmation @@ -94,7 +93,8 @@ the app and reopen it. The ad-hoc Gatekeeper steps above remain unchanged. ## Startup modes -The panel has one **Launch at Login** switch and reports the resulting mode: +The panel has one **Launch at Login** switch. Hover over its label or switch for the normal mode +summary; approval, errors, and recovery guidance remain visible in the row. The resulting modes are: - **Desktop** — the CodexCommander menu app launches when you sign in, performs an explicit Start, starts or attaches to exactly one proxy, and routes managed Codex through it when Codex @@ -143,14 +143,27 @@ override it. ## What the panel shows -- **Proxy status** — reports process liveness without treating a running server as proof that startup - synchronization finished or that Codex uses the proxy. -- **Readiness** — reports startup and catalog synchronization as **Checking**, **Starting**, **Ready**, - **Startup failed**, or **Unavailable**. This signal is independent of proxy liveness. -- **Codex route** — reports whether Codex currently routes through CodexCommander, native OpenAI, or +Proxy controls and the available Codex route switch share the first action row. Dashboard and More +actions are compact buttons in the header; Stop and Quit remains visible above the one-line +Launch at Login control. If the Codex route is uncertain, both recovery choices appear on a separate +row. More actions contains Refresh Models, Logs, and the manual update check. An +available update or installation needing attention appears in the upper-right header instead. The +scroll area contains provider quotas followed by live requests, with the in-flight count beside the +live-request heading. A one-line status footer +sits below the scroll area, at the bottom of the panel. When the current Codex route is confirmed, +the controls show the available route switch instead of a disabled action for the route already in +use. If the route cannot be confirmed, both recovery actions remain visible. + +- **Status footer** — combines proxy liveness and startup readiness into one label: **Checking**, + **Starting**, **Ready**, **Sync failed**, **Startup at risk**, **Readiness unavailable**, **Stopped**, + **Authentication needed**, or **Connection issue**. **Ready** requires both a running proxy and + completed startup synchronization. The footer reports status; actions remain in the controls or + guidance above it. +- **Codex route** — appears beside the footer status and reports whether Codex currently routes through CodexCommander, native OpenAI, or another custom route. A running proxy does not by itself mean that Codex is using it. After an explicit switch, the companion confirms this value from a fresh uncached read of the route Codex - will consume instead of waiting for cached startup diagnostics. + will consume instead of waiting for cached startup diagnostics. An unconfirmed or stale route is + labeled **Unconfirmed** rather than presented as current. - **Live proxy requests** — the current in-flight request count and live model/provider turn rows. A spawned-child request is nested only when CodexCommander can prove its in-flight parent from request metadata; otherwise it is shown as a standalone subagent turn. A row disappears when that model @@ -158,10 +171,16 @@ override it. persistent Codex agent-lifecycle view, and the companion never invents queued, reviewing, rate-limited, or completed history. - **Provider quotas** — provider-reported 5-hour, weekly, monthly, or provider-specific credit - windows and reset times when available. OpenCode Go instead shows its published caps and local - observations, never an invented live balance. Missing data is shown as unavailable, never as zero - usage or unlimited capacity. -- **Dashboard and Logs** — open the corresponding local dashboard view in your default browser with + windows and reset times when available. OpenCode Go shows its account-wide rolling, weekly, and + monthly used percentages from its Go usage endpoint. If that endpoint is unavailable, the menu + shows locally observed requests, tokens, and estimated cost without a limit or remaining balance. + Missing data is never shown as zero usage or unlimited capacity. +- **OpenCode Go model usage** — expand the provider to see each model's completed requests and + measured tokens over the last 30 days. An active model is marked **In flight** separately; its + final usage is recorded when the request completes. The dashboard's OpenCode Go Overview and Usage + tab show the same 30-day model totals. These local observations are not a live provider balance. +- **Dashboard and Logs** — Dashboard opens from the header; Logs is under **More actions**. + Both open the corresponding local dashboard view in your default browser with a one-time launch authorization for full dashboard changes, including catalog Apply. - **Startup options…** — opens the dashboard's Startup page when an optional startup upgrade or repair is available; the panel does not make a raw CLI command the primary action. @@ -244,6 +263,12 @@ configured in CodexCommander. If no Codex worker is running, the new roster is r task. If a long-lived worker loaded an older roster, CodexCommander stays running and the panel keeps the nonfatal **Restart ChatGPT to load models** card visible. +Choose **More actions → Refresh Models** in the panel to run the same non-disruptive catalog sync as `ccx sync`. +This checks the current native Codex account and enabled providers; the panel then checks whether +running Codex workers still hold an older catalog. The panel also reads catalog status when opened +and periodically while open, so a sync run from the CLI can show the restart card without relaunching +the menu app. A model added upstream is discovered on the next sync, not by the status check alone. + Choose **Show restart steps…**, quit ChatGPT completely, and reopen it. Return to the same task and check its model picker. This replaces the old worker; CodexCommander and the menu app remain running throughout. Some task-scoped collaboration setting changes still require a new task. @@ -323,8 +348,9 @@ bun run build:macos open dist/macos/CodexCommander.app ``` -Without a release public key, a development build shows **Updates unavailable in this build**; -normal Start still works. Its default integer build identifier is `1`. Keyed builds require an +Without a release public key, a development build disables **More actions → Check for Updates…**; +its tooltip explains that updates are unavailable in this build. Normal Start still works. Its +default integer build identifier is `1`. Keyed builds require an explicit positive-integer `MACOS_BUILD_NUMBER`; release packaging also requires the signing public key and a build number above the published inventory. These are maintainer release prerequisites, not required for ordinary source development. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 15f2c0430e9..ba0b757ad21 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -290,19 +290,31 @@ Volcengine Agent Plan uses its native Responses endpoint through `openai-respons OpenCode authentication store or Keychain for this key. The public model catalog is not evidence that a key works, so a saved key is **unverified** until the -first successful inference using that active key. The provider's published caps are reference caps — -**$12 / 5 hours**, **$30 / 7 days**, and **$60 / 30 days**. The dashboard's matching 5-hour, 7-day, -and 30-day observations are local usage estimates, not live remaining quota or billing. An -authoritative limit event is shown only after the upstream reports a concrete limit event (and, when -provided, its reset). - -The provider pins 28 verified Zen Go model ids, including `deepseek-v4.1-flash`, -`glm-5.3-flash`, and `qwen3.8-flash`, as -its static catalog. Live `/v1/models` discovery is authoritative on the canonical host; ids -outside the trusted set are quarantined rather than routed. Transports follow the official -endpoint table: Qwen and MiniMax models go over Anthropic Messages, `gpt-5.6-luna` and `grok-4.5` -over OpenAI Responses, and the -remaining models over OpenAI Chat Completions. These trust facts attach only to the canonical +first successful inference using that active key. With a configured Go key, CodexCommander reads +OpenCode's Go usage endpoint and shows its account-wide rolling, weekly, and monthly **used +percentages** and reset times in the dashboard and macOS menu. When the endpoint is unavailable, +the quota view shows only traffic observed through this proxy in the corresponding time windows. +Those local request, token, and estimated-cost totals are not the provider's current balance or +per-model allowance. A concrete upstream limit event can also be shown with its reported reset. + +OpenCode Go's **Overview** in the dashboard also shows completed requests and tokens for each model +over the last 30 days; the **Usage** tab retains the full model breakdown. Expanding OpenCode Go in +the macOS menu shows the same per-model completed usage beside the provider quota. While +a request is active, the menu identifies its model as **In flight**; its final token count appears +only after the request completes and the proxy records it. Unmeasured token totals are shown as +unknown rather than zero, and estimated cost appears only when pricing is available for that model. + +The Muse Spark 1.2 and 1.3 **Contributor** models may use submitted prompts and completions to train +future Meta models. See [Meta's Contributor tier terms](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier) +before sending data through either model. + +The provider keeps a reviewed fallback catalog of OpenCode Go models, including +`deepseek-v4.1-flash`, `gpt-6-luna`, and `grok-4.7`. With a Go key, live `/v1/models` +discovery determines which of those models the account can use. That endpoint returns model ids +without transport metadata, so a new id appears in Commander after its protocol is verified and +added to the trusted catalog; unverified ids are held back. The [OpenCode Go endpoint +table](https://opencode.ai/v2/docs/console/go#endpoints) identifies models using Anthropic Messages, +OpenAI Responses, or Chat Completions. These transport facts attach only to the canonical `https://opencode.ai/zen/go/v1` destination; a same-named custom provider keeps its own behavior. OpenCode Go requires a session identifier for each conversation. CodexCommander derives an opaque diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6c34d401c8c..b083cc7cc3c 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -91,9 +91,9 @@ on. **Logs** works the same way with `#logs` and `#logs/debug`. Cost values in **Dashboard**, **Logs**, and **Usage** are API list-price equivalents calculated from reported tokens. They are not billing receipts or evidence of an actual charge; subscription usage or provider credits may apply instead. The Dashboard's **Plan & quota** section shows -provider-reported limits (5-hour / weekly / monthly windows), the provider plan, and observed -reference spend versus published caps — always labeled as provider-reported estimates, never billed -spend. +provider-reported limits (5-hour / weekly / monthly windows) and the provider plan when available. +If OpenCode Go's live meter is unavailable, it shows only locally observed traffic and estimated +cost, without a claimed cap or remaining balance. Estimated cost is not billed spend. ### Request usage and dispatch evidence @@ -120,6 +120,16 @@ providers are capped or their saved values differ. Native OpenAI models always k Automatic upstream catalog refresh is configured per provider under **Providers → Settings**. The Models page shows that state and links directly to it; it does not keep a second discovery setting. +On **Models**, choose **Refresh models** to run the same explicit, non-disruptive sync as `ccx sync`. +It rechecks the native Codex account and enabled routed providers, then updates the published Codex +catalog. Provider models that Commander discovered and enabled but did not publish into Codex's +current picker catalog appear gray with a **Commander only** badge; use the badge count to filter +them. This comparison is unavailable when the published catalog cannot be read, and a provider's +model listing alone does not prove that the account can complete an inference with it. If the +catalog is current but running Codex workers hold an older version, Models shows a prompt to quit +and reopen ChatGPT. Opening Models checks the existing local catalog; upstream discovery occurs on +the explicit sync. + ## Catalog activation Saving model visibility, the featured roster, or collaboration mode is deliberately non-disruptive: diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index f5f6b9894a4..eda2e6eb3f3 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -263,6 +263,7 @@ export default function ProviderDetails({ apiBase={apiBase} connectionIdentity={connectionIdentity} usageTotals={usageTotals} + modelUsage={modelUsage} quotaReport={quotaReport} quotaUnavailableReason={quotaUnavailableReason} onRetryQuota={onRetryQuota} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index e0f38525dfa..4663c3572fc 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -10,7 +10,7 @@ import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/ import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, formatTokenCount } from "../../provider-workspace/usage"; import { accountQuotaFromReport, formatQuotaSourceLabel, type ProviderQuotaReportView } from "../../provider-workspace/report"; import { quotaUnavailableReasonKey } from "../../quota-unavailable"; -import type { ProviderUsageTotals } from "./types"; +import type { ProviderModelUsageRow, ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch } from "./types"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; @@ -31,7 +31,7 @@ type ConnectionTestState = { }; export default function ProviderOverview({ - item, usageTotals, quotaReport, quotaUnavailableReason, onRetryQuota, oauthEmail, oauth, + item, usageTotals, modelUsage, quotaReport, quotaUnavailableReason, onRetryQuota, oauthEmail, oauth, accountNeedsReauth = false, apiBase, connectionIdentity, onEditSettings, onViewUsage, onUpdateProvider, @@ -39,6 +39,7 @@ export default function ProviderOverview({ }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; + modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; /** Provider quota is unavailable (no report); reason drives the notice copy. */ quotaUnavailableReason?: string; @@ -86,6 +87,9 @@ export default function ProviderOverview({ : t("prov.disabledBadge"); const requests = usageTotals?.requests; const tokens = usageTotals?.totalTokens; + const openCodeGoModels = item.name === "opencode-go" + ? (modelUsage ?? []).toSorted((a, b) => b.totalTokens - a.totalTokens) + : []; const quota = quotaReport ? accountQuotaFromReport(quotaReport) : null; const connectionProbeKey = JSON.stringify([ apiBase ?? null, @@ -246,6 +250,67 @@ export default function ProviderOverview({ ) : null} + {item.name === "opencode-go" && ( +
+

{t("pws.usageLast30d")}

+ {typeof requests === "number" && ( +
+
+ {formatRequestCount(requests, locale)} + {t("pws.metricRequests")} +
+ {typeof tokens === "number" && ( +
+ + {requests > 0 && !(typeof usageTotals?.measuredRequests === "number" && usageTotals.measuredRequests > 0) + ? t("logs.tokens.unreported") + : formatTokenCount(tokens, locale)} + + + {t("pws.metricTokens")} + {requests > 0 && typeof usageTotals?.measuredRequests === "number" && usageTotals.measuredRequests > 0 && usageTotals.measuredRequests < requests + ? ` · ${t("pws.reference.partial")}` + : ""} + +
+ )} +
+ )} + {openCodeGoModels.length > 0 ? ( +
+ + + + + + + + + + + {openCodeGoModels.map(row => ( + + + + + + ))} + +
{t("pws.modelBreakdown")}
{t("pws.col.model")}{t("pws.col.requests")}{t("pws.col.tokens")}
{row.model}{formatRequestCount(row.requests, locale)} + {row.requests > 0 && !(typeof row.measuredRequests === "number" && row.measuredRequests > 0) + ? t("logs.tokens.unreported") + : formatTokenCount(row.totalTokens, locale)} + {row.requests > 0 && typeof row.measuredRequests === "number" && row.measuredRequests > 0 && row.measuredRequests < row.requests + ? · {t("pws.reference.partial")} + : null} +
+
+ ) : typeof requests !== "number" ? ( +

{t("pws.usageUnavailable")}

+ ) : null} +
+ )} +

{t("pws.authSummary")}

{showAuthWarning ? ( diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index ad95ac5df32..dddbd82d67d 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -27,6 +27,7 @@ import { ProviderIcon } from "./ProviderRail"; import { formatProviderDisplayName } from "../../provider-icons"; import QuotaBars from "../QuotaBars"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; +import { referenceCoverageLabel, referenceObservedLabels } from "../../provider-workspace/reference-observation"; export default function ProviderOverviewDashboard({ sections, @@ -181,15 +182,16 @@ export default function ProviderOverviewDashboard({