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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.46.1 — Unreleased

### Added
- z.ai: add 7-day and 30-day model-usage chart ranges with dataset-consistent legends, colors, and daily tooltips (#2524). Thanks @LeoLin990405!
- Refresh: add a default-off global Low Power Mode that limits automatic provider, local usage, and storage work to once every 30 minutes while keeping manual refresh immediate (#2518). Thanks @Carl723000!
- CLI: `codexbar hooks watch` continuously polls providers and fires hooks on real quota/status transitions for headless installs, with in-memory baselines, event rate limits, `--interval` (default 300s, minimum 60s), `--provider`, and JSON output (#2536). Thanks @OfficialAbhinavSingh!
- Claude: compact multi-account menu for claude-swap — with four or more accounts the active account keeps its full card while the others become one-line rows sorted by remaining headroom, constrained accounts surface in red/amber, the healthiest switch target gets a star, and the healthy tail folds behind a summary row. Click a row to expand its full card.
Expand Down
25 changes: 19 additions & 6 deletions Sources/CodexBar/StatusItemController+HostedSubmenus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,11 +348,19 @@ extension StatusItemController {
}

private func zaiHourlyUsageRenderSignature(for provider: UsageProvider) -> String {
guard let modelUsage = self.store.snapshot(for: provider)?.zaiUsage?.modelUsage else { return "none" }
return Self.zaiHourlyUsageRenderSignature(modelUsage: modelUsage, now: Date())
guard let zai = self.store.snapshot(for: provider)?.zaiUsage,
let modelUsage = zai.modelUsage else { return "none" }
return Self.zaiHourlyUsageRenderSignature(
modelUsage: modelUsage,
dailyModelUsage: zai.dailyModelUsage,
now: Date())
}

static func zaiHourlyUsageRenderSignature(modelUsage: ZaiModelUsageData, now: Date) -> String {
static func zaiHourlyUsageRenderSignature(
modelUsage: ZaiModelUsageData,
dailyModelUsage: ZaiModelUsageData? = nil,
now: Date) -> String
{
let models = modelUsage.modelDataList
.map { model in
let usage = model.tokensUsage
Expand All @@ -361,10 +369,11 @@ extension StatusItemController {
return "\(model.modelName ?? "")=\(usage)"
}
.joined(separator: ";")
let ranges: [ZaiHourlyRange] = [.today(referenceDate: now), .last24h]
let ranges: [ZaiHourlyRange] = [.today(referenceDate: now), .last24h, .last7d, .last30d]
let visibleBars = ranges
.map { range in
ZaiHourlyBars.from(modelData: modelUsage, range: range, now: now)
let data = range.isDaily ? (dailyModelUsage ?? modelUsage) : modelUsage
return ZaiHourlyBars.from(modelData: data, range: range, now: now)
.map { bar in
let segments = bar.segments
.map { "\($0.model)=\($0.tokens)" }
Expand All @@ -375,6 +384,7 @@ extension StatusItemController {
}
return [
modelUsage.xTime.joined(separator: ","),
dailyModelUsage?.xTime.joined(separator: ",") ?? "",
models,
visibleBars.joined(separator: "|"),
].joined(separator: "|")
Expand Down Expand Up @@ -634,7 +644,10 @@ extension StatusItemController {
return true
}

let chartView = ZaiHourlyUsageChartMenuView(modelUsage: modelUsage, width: width)
let chartView = ZaiHourlyUsageChartMenuView(
modelUsage: modelUsage,
dailyModelUsage: snapshot.zaiUsage?.dailyModelUsage,
width: width)
let hosting = MenuHostingView(rootView: chartView)
hosting.frame = NSRect(
origin: .zero,
Expand Down
141 changes: 95 additions & 46 deletions Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,56 @@ import SwiftUI
@MainActor
struct ZaiHourlyUsageChartMenuView: View {
private let modelUsage: ZaiModelUsageData
private let dailyModelUsage: ZaiModelUsageData?
private let width: CGFloat

@State private var selectedRange: RangeOption = .today
@State private var isExpanded = true
@State private var hoveredBarIndex: Int?

private enum RangeOption: Int, CaseIterable {
enum RangeOption: Int, CaseIterable {
case today = 0
case last24h = 1
case last7d = 2
case last30d = 3

func range(referenceDate: Date) -> ZaiHourlyRange {
switch self {
case .today: .today(referenceDate: referenceDate)
case .last24h: .last24h
case .last7d: .last7d
case .last30d: .last30d
}
}
}

struct RenderState {
let bars: [ZaiHourlyBar]
let modelNames: [String]
let isDaily: Bool

static func make(
modelUsage: ZaiModelUsageData,
dailyModelUsage: ZaiModelUsageData?,
selectedRange: RangeOption,
now: Date) -> RenderState
{
let range = selectedRange.range(referenceDate: now)
let selectedData = range.isDaily ? (dailyModelUsage ?? modelUsage) : modelUsage
return RenderState(
bars: ZaiHourlyBars.from(modelData: selectedData, range: range, now: now),
modelNames: selectedData.modelNames,
isDaily: range.isDaily)
}

func tooltipTitle(for bar: ZaiHourlyBar) -> String {
self.isDaily ? bar.label : "\(bar.label):00"
}

func colorIndex(for modelName: String, paletteCount: Int) -> Int {
guard paletteCount > 0 else { return 0 }
return (self.modelNames.firstIndex(of: modelName) ?? 0) % paletteCount
}
}

private let barHeight: CGFloat = 60
Expand All @@ -28,27 +69,24 @@ struct ZaiHourlyUsageChartMenuView: View {
Color(red: 255 / 255, green: 55 / 255, blue: 95 / 255),
]

init(modelUsage: ZaiModelUsageData, width: CGFloat) {
init(modelUsage: ZaiModelUsageData, dailyModelUsage: ZaiModelUsageData?, width: CGFloat) {
self.modelUsage = modelUsage
self.dailyModelUsage = dailyModelUsage
self.width = width
}

private var range: ZaiHourlyRange {
switch self.selectedRange {
case .today: .today(referenceDate: Date())
case .last24h: .last24h
}
}

private var bars: [ZaiHourlyBar] {
ZaiHourlyBars.from(modelData: self.modelUsage, range: self.range)
}

private var modelNames: [String] {
self.modelUsage.modelNames
private var renderState: RenderState {
RenderState.make(
modelUsage: self.modelUsage,
dailyModelUsage: self.dailyModelUsage,
selectedRange: self.selectedRange,
now: Date())
}

var body: some View {
let renderState = self.renderState
let bars = renderState.bars

VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 4) {
Button(
Expand All @@ -74,7 +112,7 @@ struct ZaiHourlyUsageChartMenuView: View {

if self.isExpanded {
VStack(alignment: .leading, spacing: 4) {
if self.bars.isEmpty {
if bars.isEmpty {
Text(L("No data"))
.font(.system(size: 10))
.foregroundColor(.secondary)
Expand All @@ -83,14 +121,18 @@ struct ZaiHourlyUsageChartMenuView: View {
} else {
GeometryReader { geometry in
let barWidth = max(
(geometry.size.width - self.barGap * CGFloat(max(self.bars.count - 1, 0)))
/ CGFloat(self.bars.count),
(geometry.size.width - self.barGap * CGFloat(max(bars.count - 1, 0)))
/ CGFloat(bars.count),
2)
HStack(alignment: .bottom, spacing: self.barGap) {
ForEach(Array(self.bars.enumerated()), id: \.offset) { index, bar in
ForEach(Array(bars.enumerated()), id: \.offset) { index, bar in
VStack(spacing: 0) {
Spacer(minLength: 0)
self.barStack(bar: bar, barWidth: barWidth, maxTotal: self.maxTotal)
self.barStack(
bar: bar,
barWidth: barWidth,
maxTotal: self.maxTotal(in: bars),
renderState: renderState)
}
.frame(width: barWidth, height: self.barHeight)
.contentShape(Rectangle())
Expand All @@ -99,7 +141,7 @@ struct ZaiHourlyUsageChartMenuView: View {
}
.overlay(alignment: .bottom) {
if self.hoveredBarIndex == index {
self.tooltipOverlay(bar: bar)
self.tooltipOverlay(bar: bar, renderState: renderState)
}
}
}
Expand All @@ -108,8 +150,8 @@ struct ZaiHourlyUsageChartMenuView: View {
}
.frame(height: self.barHeight)

self.legend
self.xAxisLabels
self.legend(renderState: renderState)
self.xAxisLabels(bars: bars)
}
}
.padding(.top, 6)
Expand All @@ -122,8 +164,8 @@ struct ZaiHourlyUsageChartMenuView: View {
.animation(.easeInOut(duration: 0.2), value: self.isExpanded)
}

private var maxTotal: Int {
self.bars.map(\.totalTokens).max() ?? 1
private func maxTotal(in bars: [ZaiHourlyBar]) -> Int {
bars.map(\.totalTokens).max() ?? 1
}

private var rangeToggle: some View {
Expand All @@ -133,6 +175,8 @@ struct ZaiHourlyUsageChartMenuView: View {
{
Text(L("Today")).tag(RangeOption.today.rawValue)
Text("24h").tag(RangeOption.last24h.rawValue)
Text("7d").tag(RangeOption.last7d.rawValue)
Text("30d").tag(RangeOption.last30d.rawValue)
}
.pickerStyle(.segmented)
.frame(width: 100)
Expand All @@ -141,7 +185,12 @@ struct ZaiHourlyUsageChartMenuView: View {
}

@ViewBuilder
private func barStack(bar: ZaiHourlyBar, barWidth: CGFloat, maxTotal: Int) -> some View {
private func barStack(
bar: ZaiHourlyBar,
barWidth: CGFloat,
maxTotal: Int,
renderState: RenderState) -> some View
{
let scaleFactor = CGFloat(bar.totalTokens) / CGFloat(max(maxTotal, 1))

VStack(spacing: 0) {
Expand All @@ -151,22 +200,22 @@ struct ZaiHourlyUsageChartMenuView: View {
self.barHeight * scaleFactor * segFraction,
segment.tokens > 0 ? 1 : 0)
RoundedRectangle(cornerRadius: segIndex == bar.segments.count - 1 ? 2 : 0)
.fill(self.colorForModel(segment.model))
.fill(self.colorForModel(segment.model, renderState: renderState))
.frame(height: segHeight)
}
}
.clipShape(RoundedRectangle(cornerRadius: 2))
}

private func tooltipOverlay(bar: ZaiHourlyBar) -> some View {
private func tooltipOverlay(bar: ZaiHourlyBar, renderState: RenderState) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(bar.label + ":00")
Text(renderState.tooltipTitle(for: bar))
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.primary)
ForEach(Array(bar.segments.enumerated()), id: \.offset) { _, segment in
HStack(spacing: 3) {
Circle()
.fill(self.colorForModel(segment.model))
.fill(self.colorForModel(segment.model, renderState: renderState))
.frame(width: 5, height: 5)
Text(segment.model)
.font(.system(size: 9))
Expand All @@ -193,13 +242,13 @@ struct ZaiHourlyUsageChartMenuView: View {
.offset(y: -self.barHeight - 8)
}

private var legend: some View {
private func legend(renderState: RenderState) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(self.modelNames, id: \.self) { name in
ForEach(renderState.modelNames, id: \.self) { name in
HStack(spacing: 2) {
Circle()
.fill(self.colorForModel(name))
.fill(self.colorForModel(name, renderState: renderState))
.frame(width: 6, height: 6)
Text(name)
.font(.system(size: 9))
Expand All @@ -211,11 +260,11 @@ struct ZaiHourlyUsageChartMenuView: View {
}
}

private var xAxisLabels: some View {
private func xAxisLabels(bars: [ZaiHourlyBar]) -> some View {
HStack(spacing: 0) {
ForEach(Array(self.labelIndices.enumerated()), id: \.offset) { _, index in
if index < self.bars.count {
Text(self.bars[index].label)
ForEach(Array(self.labelIndices(for: bars).enumerated()), id: \.offset) { _, index in
if index < bars.count {
Text(bars[index].label)
.font(.system(size: 9))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
Expand All @@ -224,19 +273,19 @@ struct ZaiHourlyUsageChartMenuView: View {
}
}

private var labelIndices: [Int] {
guard self.bars.count > self.maxLabelCount else { return Array(0..<self.bars.count) }
let step = max(1, self.bars.count / (self.maxLabelCount - 1))
var indices = stride(from: 0, to: self.bars.count, by: step).map(\.self)
if indices.last != self.bars.count - 1 {
indices.append(self.bars.count - 1)
private func labelIndices(for bars: [ZaiHourlyBar]) -> [Int] {
guard bars.count > self.maxLabelCount else { return Array(0..<bars.count) }
let step = max(1, bars.count / (self.maxLabelCount - 1))
var indices = stride(from: 0, to: bars.count, by: step).map(\.self)
if indices.last != bars.count - 1 {
indices.append(bars.count - 1)
}
return indices
}

private func colorForModel(_ name: String) -> Color {
let index = self.modelNames.firstIndex(of: name) ?? 0
return self.colorPalette[index % self.colorPalette.count]
private func colorForModel(_ name: String, renderState: RenderState) -> Color {
let index = renderState.colorIndex(for: name, paletteCount: self.colorPalette.count)
return self.colorPalette[index]
}

private func formatTokenCount(_ count: Int) -> String {
Expand Down
Loading