Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions Sources/CodexBar/IconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -938,30 +938,30 @@ enum IconRenderer {

switch indicator {
case .minor, .maintenance:
let size: CGFloat = 4
let size: CGFloat = 2
let rect = Self.snapRect(
x: Self.baseSize.width - size - 2,
y: 2,
x: Self.baseSize.width - size - 1,
y: 7,
width: size,
height: size)
let path = NSBezierPath(ovalIn: rect)
color.setFill()
path.fill()
case .major, .critical, .unknown:
let lineRect = Self.snapRect(
x: Self.baseSize.width - 6,
y: 4,
width: 2.0,
height: 6)
let linePath = NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1)
x: Self.baseSize.width - 2.5,
y: 7.4,
width: 1.5,
height: 1.8)
let linePath = NSBezierPath(roundedRect: lineRect, xRadius: 0.75, yRadius: 0.75)
color.setFill()
linePath.fill()

let dotRect = Self.snapRect(
x: Self.baseSize.width - 6,
y: 2,
width: 2.0,
height: 2.0)
x: Self.baseSize.width - 2.45,
y: 6.6,
width: 1.4,
height: 1.4)
NSBezierPath(ovalIn: dotRect).fill()
case .none:
break
Expand Down
116 changes: 116 additions & 0 deletions Sources/CodexBar/InlineUsageDashboardContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,24 @@ extension UsageMenuCardView.Model {
return nil
}

static func inlineUsageDashboardLoading(
input: Input,
tokenUsageSnapshot: CostUsageTokenSnapshot?,
inlineUsageDashboard: InlineUsageDashboardModel?)
-> Bool
{
guard inlineUsageDashboard == nil else { return false }
guard input.tokenCostUsageEnabled else { return false }
guard tokenUsageSnapshot == nil else { return false }
guard input.tokenError?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true else { return false }
guard input.lastError?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true else { return false }
if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider) {
return input.isRefreshing || input.tokenRefreshInFlight || input.tokenRefreshQueued
}
guard [.codex, .claude, .vertexai, .bedrock].contains(input.provider) else { return false }
return input.tokenRefreshInFlight || input.tokenRefreshQueued
}

static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool {
provider == .openai || provider == .mistral
}
Expand Down Expand Up @@ -659,3 +677,101 @@ struct InlineUsageDashboardContent: View {
}
}
}

struct InlineUsageDashboardLoadingContent: View {
private let barHeights: [CGFloat] = [
26,
19,
10,
48,
12,
4,
4,
4,
4,
8,
13,
12,
15,
9,
36,
33,
39,
7,
22,
16,
24,
26,
27,
18,
13,
]
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
VStack(alignment: .leading, spacing: 10) {
LazyVGrid(
columns: [
GridItem(.flexible(minimum: 118), alignment: .leading),
GridItem(.flexible(minimum: 100), alignment: .leading),
],
alignment: .leading,
spacing: 6)
{
LoadingKPI(title: L("Today"), width: 68)
LoadingKPI(title: L("30d cost"), width: 78)
LoadingKPI(title: L("30d tokens"), width: 58)
LoadingKPI(title: L("Latest tokens"), width: 62)
}
HStack(alignment: .bottom, spacing: 2) {
ForEach(Array(self.barHeights.enumerated()), id: \.offset) { _, height in
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(self.skeletonFill.opacity(0.85))
.frame(maxWidth: .infinity)
.frame(height: height)
}
}
.frame(maxWidth: .infinity)
.frame(height: 58, alignment: .bottom)
.overlay(alignment: .bottomLeading) {
Rectangle()
.fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.18))
.frame(height: 1)
}
VStack(alignment: .leading, spacing: 5) {
self.skeletonLine(width: 138)
self.skeletonLine(width: 228)
}
}
.accessibilityLabel("Loading cost history")
}

private var skeletonFill: Color {
MenuHighlightStyle.secondary(self.isHighlighted).opacity(self.isHighlighted ? 0.34 : 0.2)
}

private func skeletonLine(width: CGFloat, height: CGFloat = 10) -> some View {
RoundedRectangle(cornerRadius: height / 2, style: .continuous)
.fill(self.skeletonFill)
.frame(width: width, height: height)
}

private struct LoadingKPI: View {
let title: String
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.caption2)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(self.isHighlighted ? 0.34 : 0.2))
.frame(width: self.width, height: 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
66 changes: 66 additions & 0 deletions Sources/CodexBar/MainRunLoopStallMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import CodexBarCore
import Foundation

@MainActor
final class MainRunLoopStallMonitor {
private let logger: CodexBarLogger
private let interval: TimeInterval
private let threshold: TimeInterval
private let metadataProvider: @MainActor () -> [String: String]
private var timer: DispatchSourceTimer?
private var lastTick: TimeInterval = ProcessInfo.processInfo.systemUptime

init(
logger: CodexBarLogger,
interval: TimeInterval = 0.1,
threshold: TimeInterval = 0.35,
metadataProvider: @escaping @MainActor () -> [String: String])
{
self.logger = logger
self.interval = interval
self.threshold = threshold
self.metadataProvider = metadataProvider
}

func start() {
guard self.timer == nil else { return }

self.lastTick = ProcessInfo.processInfo.systemUptime
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(
deadline: .now() + self.interval,
repeating: self.interval,
leeway: .milliseconds(20))
timer.setEventHandler { [weak self] in
Task { @MainActor [weak self] in
self?.tick()
}
}
self.timer = timer
timer.resume()
}

func stop() {
self.timer?.cancel()
self.timer = nil
}

private func tick() {
let now = ProcessInfo.processInfo.systemUptime
let elapsed = now - self.lastTick
self.lastTick = now

let gap = max(0, elapsed - self.interval)
guard gap >= self.threshold else { return }

var metadata = self.metadataProvider()
metadata["elapsedMs"] = String(format: "%.1f", elapsed * 1000)
metadata["gapMs"] = String(format: "%.1f", gap * 1000)
metadata["thresholdMs"] = String(format: "%.1f", self.threshold * 1000)
self.logger.warning("main runloop stall detected", metadata: metadata)
}

deinit {
self.timer?.cancel()
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ extension UsageMenuCardView.Model {
self.usageNotes.isEmpty &&
self.openAIAPIUsage == nil &&
self.inlineUsageDashboard == nil &&
!self.inlineUsageDashboardLoading &&
self.creditsRemaining == nil &&
self.providerCost == nil &&
self.tokenUsage == nil &&
Expand All @@ -26,6 +27,7 @@ extension UsageMenuCardView.Model {
!self.usageNotes.isEmpty ||
self.openAIAPIUsage != nil ||
self.inlineUsageDashboard != nil ||
self.inlineUsageDashboardLoading ||
self.placeholder != nil
}

Expand Down
22 changes: 21 additions & 1 deletion Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ struct UsageMenuCardView: View {
let creditsHintCopyText: String?
let providerCost: ProviderCostSection?
let tokenUsage: TokenUsageSection?
let inlineUsageDashboardLoading: Bool
let placeholder: String?
let progressColor: Color
}
Expand All @@ -137,6 +138,8 @@ struct UsageMenuCardView: View {
if self.model.metrics.isEmpty {
if let dashboard = self.model.inlineUsageDashboard {
InlineUsageDashboardContent(model: dashboard)
} else if self.model.inlineUsageDashboardLoading {
InlineUsageDashboardLoadingContent()
} else if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder {
Expand All @@ -161,6 +164,8 @@ struct UsageMenuCardView: View {
}
if let dashboard = self.model.inlineUsageDashboard {
InlineUsageDashboardContent(model: dashboard)
} else if self.model.inlineUsageDashboardLoading {
InlineUsageDashboardLoadingContent()
} else if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
}
Expand Down Expand Up @@ -484,6 +489,8 @@ struct UsageMenuCardUsageSectionView: View {
if self.model.metrics.isEmpty {
if let dashboard = self.model.inlineUsageDashboard {
InlineUsageDashboardContent(model: dashboard)
} else if self.model.inlineUsageDashboardLoading {
InlineUsageDashboardLoadingContent()
} else if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder {
Expand All @@ -500,6 +507,8 @@ struct UsageMenuCardUsageSectionView: View {
}
if let dashboard = self.model.inlineUsageDashboard {
InlineUsageDashboardContent(model: dashboard)
} else if self.model.inlineUsageDashboardLoading {
InlineUsageDashboardLoadingContent()
} else if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
}
Expand Down Expand Up @@ -685,6 +694,8 @@ extension UsageMenuCardView.Model {
let dashboardError: String?
let tokenSnapshot: CostUsageTokenSnapshot?
let tokenError: String?
let tokenRefreshInFlight: Bool
let tokenRefreshQueued: Bool
let account: AccountInfo
let isRefreshing: Bool
let lastError: String?
Expand All @@ -711,6 +722,8 @@ extension UsageMenuCardView.Model {
dashboardError: String?,
tokenSnapshot: CostUsageTokenSnapshot?,
tokenError: String?,
tokenRefreshInFlight: Bool = false,
tokenRefreshQueued: Bool = false,
account: AccountInfo,
isRefreshing: Bool,
lastError: String?,
Expand All @@ -736,6 +749,8 @@ extension UsageMenuCardView.Model {
self.dashboardError = dashboardError
self.tokenSnapshot = tokenSnapshot
self.tokenError = tokenError
self.tokenRefreshInFlight = tokenRefreshInFlight
self.tokenRefreshQueued = tokenRefreshQueued
self.account = account
self.isRefreshing = isRefreshing
self.lastError = lastError
Expand All @@ -761,7 +776,12 @@ extension UsageMenuCardView.Model {
metadata: input.metadata)
let metrics = Self.metrics(input: input)
let openAIAPIUsage = input.snapshot?.openAIAPIUsage
let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input)
let inlineUsageDashboard = Self.inlineUsageDashboard(input: input)
let inlineUsageDashboardLoading = Self.inlineUsageDashboardLoading(
input: input,
tokenUsageSnapshot: tokenUsageSnapshot,
inlineUsageDashboard: inlineUsageDashboard)
let usageNotes = Self.usageNotes(input: input)
let creditsText: String? = if input.provider == .openrouter {
nil
Expand All @@ -783,7 +803,6 @@ extension UsageMenuCardView.Model {
} else {
Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost)
}
let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input)
let tokenUsage = Self.tokenUsageSection(
provider: input.provider,
enabled: input.tokenCostUsageEnabled,
Expand Down Expand Up @@ -814,6 +833,7 @@ extension UsageMenuCardView.Model {
creditsHintCopyText: redacted.creditsHintCopyText,
providerCost: providerCost,
tokenUsage: tokenUsage,
inlineUsageDashboardLoading: inlineUsageDashboardLoading,
placeholder: placeholder,
progressColor: Self.progressColor(for: input.provider))
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ struct CodexUIErrorMapper {
return trimmed
}

if self.looksCancellation(lower: lower) {
return nil
}

if let cachedMessage = self.cachedMessage(raw: trimmed, lower: lower) {
return cachedMessage
}
Expand Down Expand Up @@ -125,6 +129,14 @@ struct CodexUIErrorMapper {
|| lower.contains("returned invalid data")
}

private static func looksCancellation(lower: String) -> Bool {
lower == "cancelled"
|| lower.contains("network error: cancelled")
|| lower.contains("nsurlerrordomain code=-999")
|| lower.contains("nsurlerrordomain error -999")
|| lower.contains("cancellationerror")
}

private static func looksOpenAIWebTimeout(lower: String) -> Bool {
lower.contains("nsurlerrordomain")
&& (lower.contains("timed out") || lower.contains("error -1001"))
Expand Down
Loading