codeburn/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
Resham Joshi 4c29f6b880
Add Crush provider plus per-provider icon column in README (#286)
Closes #278.

Adds Charmbracelet Crush as a lazy-loaded provider:
- src/providers/crush.ts: walks ~/.local/share/crush/projects.json
  (XDG_DATA_HOME and CRUSH_GLOBAL_DATA aware), opens each project's
  crush.db read-only, queries root sessions where parent_session_id
  IS NULL. Emits one ParsedProviderCall per session with real
  prompt_tokens, completion_tokens, cost (dollars), and the
  dominant model resolved from messages.model.
- src/providers/index.ts: register crush alongside cursor, goose,
  opencode, antigravity, cursor-agent in the lazy import path.
- tests/providers/crush.test.ts: 10 fixture-based tests covering
  discovery, parsing, missing-registry, malformed JSON, missing db,
  child session exclusion, dominant model selection, dedup, and
  array-shaped legacy registry.

Schema source: charmbracelet/crush@v0.66.1
internal/db/migrations/20250424200609_initial.sql, verified by
spawning a research agent against upstream. The schema *comments*
in that migration claim millisecond timestamps but every actual
INSERT/UPDATE uses strftime('%s', 'now') which returns Unix
seconds; the parser treats values as seconds. Tokscale's
parser (junhoyeo/tokscale#346) gets this wrong and is off by
1000x, plus its parser misses the prompt_tokens/completion_tokens
columns that exist in Crush's schema. Our integration uses both,
so Crush sessions get real per-model attribution.

Menubar:
- mac/Sources/CodeBurnMenubar/AppStore.swift: add .crush case to
  ProviderFilter and its cliArg switch.
- mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift: add
  Crush color to the per-tab color extension. The visibleFilters
  computed property already filters by detected providers, so the
  Crush tab appears automatically when a user has Crush data.

README:
- Replace the provider table with an icon-led layout. Icons live
  under assets/providers/<name>.<ext>. 14 icons sourced from
  junhoyeo/tokscale (MIT) under nominative fair use, 4 sourced
  separately: codex (OpenAI org avatar), cursor-agent (reuses the
  Cursor icon), kiro (kiro.dev favicon, ico->png via sips), omp
  (can1357/oh-my-pi icon.svg, MIT). Attribution line added.
- Add Crush row.

Docs:
- docs/providers/crush.md: full per-provider doc with verified
  schema excerpt, the seconds-vs-milliseconds quirk, and a
  "when fixing a bug here" checklist.
- docs/architecture.md: provider count 17 -> 18, test count
  41 -> 42, and crush in the lazy list.
- docs/providers/README.md: add Crush row to the lazy index.
- CONTRIBUTING.md: bump test count to 568 (was 558).

All 568 tests pass locally; swift build clean.
2026-05-09 20:47:56 -07:00

359 lines
13 KiB
Swift

import SwiftUI
struct AgentTabStrip: View {
@Environment(AppStore.self) private var store
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(visibleFilters) { filter in
AgentTab(
filter: filter,
cost: cost(for: filter),
isActive: store.selectedProvider == filter,
quota: store.quotaSummary(for: filter)
) {
store.switchTo(provider: filter)
}
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
}
}
private var todayAll: MenubarPayload {
store.todayPayload ?? store.payload
}
private var periodAll: MenubarPayload {
store.periodAllPayload ?? store.payload
}
private var visibleFilters: [ProviderFilter] {
let detectedKeys = Set(
todayAll.current.providers.keys.map { $0.lowercased() }
)
return ProviderFilter.allCases.filter { filter in
if filter == .all { return true }
return filter.providerKeys.contains(where: detectedKeys.contains)
}
}
private func cost(for filter: ProviderFilter) -> Double? {
let data = periodAll
if filter == .all { return data.current.cost }
if filter == store.selectedProvider, store.hasCachedData {
return store.payload.current.cost
}
let providers = Dictionary(
data.current.providers.map { ($0.key.lowercased(), $0.value) },
uniquingKeysWith: +
)
return filter.providerKeys.reduce(0.0) { sum, key in
sum + (providers[key] ?? 0)
}
}
}
private struct AgentTab: View {
let filter: ProviderFilter
let cost: Double?
let isActive: Bool
let quota: QuotaSummary?
let onTap: () -> Void
@State private var hoverPopoverShown = false
@State private var hoverEnterTask: DispatchWorkItem?
@State private var hoverExitTask: DispatchWorkItem?
@State private var clickDismissed = false
/// Providers whose AgentTab chip reserves a 3pt bar slot underneath the
/// label, even when not yet connected. Driven by which providers we
/// actually implement live-quota fetching for in AppStore.quotaSummary.
static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool {
switch filter {
case .claude, .codex: return true
default: return false
}
}
var body: some View {
VStack(spacing: 3) {
HStack(spacing: 5) {
Text(filter.rawValue)
.font(.system(size: 11.5, weight: .medium))
.tracking(-0.05)
if let cost, cost > 0 {
Text(cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
.tracking(-0.2)
}
}
if quota != nil {
AgentTabQuotaBar(quota: quota, isActive: isActive)
.frame(height: 3)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08)))
)
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.contentShape(Rectangle())
.onTapGesture {
hoverPopoverShown = false
hoverEnterTask?.cancel()
clickDismissed = true
onTap()
}
.onHover { hovering in
hoverEnterTask?.cancel()
hoverExitTask?.cancel()
if !hovering {
clickDismissed = false
let task = DispatchWorkItem { hoverPopoverShown = false }
hoverExitTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task)
} else if !clickDismissed, quota != nil {
let task = DispatchWorkItem { hoverPopoverShown = true }
hoverEnterTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task)
}
}
.popover(isPresented: $hoverPopoverShown) {
if let quota {
QuotaDetailPopover(quota: quota)
}
}
}
}
/// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota
/// source. Width matches the chip; color shifts green amber red at 70% / 90%.
private struct AgentTabQuotaBar: View {
let quota: QuotaSummary?
let isActive: Bool
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(trackColor)
if let percent = filledFraction {
Capsule()
.fill(barColor)
.frame(width: max(2, geo.size.width * CGFloat(percent)))
.animation(.easeOut(duration: 0.25), value: percent)
}
if case .terminalFailure = quota?.connection {
// Hatched/red strip to telegraph "broken; reconnect needed".
Capsule()
.fill(Color.red.opacity(0.7))
}
}
}
}
private var filledFraction: Double? {
guard let pct = quota?.primary?.percent else { return nil }
return min(max(pct, 0), 1)
}
private var barColor: Color {
guard let pct = quota?.primary?.percent else { return .clear }
switch QuotaSummary.severity(for: pct) {
case .normal: return isActive ? Color.white : Color.green.opacity(0.85)
case .warning: return Color.yellow
case .critical: return Color.orange
case .danger: return Color.red
}
}
private var trackColor: Color {
isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18)
}
}
private struct QuotaDetailPopover: View {
let quota: QuotaSummary
var body: some View {
VStack(alignment: .leading, spacing: 8) {
switch quota.connection {
case .terminalFailure(let reason):
terminalFailureCard(reason: reason)
case .disconnected:
Text(disconnectedMessage)
.font(.system(size: 11))
.foregroundStyle(.secondary)
case .loading where quota.details.isEmpty:
Text("Loading…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
default:
rowsCard
}
}
.padding(12)
.frame(width: 260)
}
private var disconnectedMessage: String {
switch quota.providerFilter {
case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota."
case .claude: return "Sign in to Claude Code to track quota."
default: return "Sign in to track quota."
}
}
private var rowsCard: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text("\(quota.providerFilter.rawValue) usage")
.font(.system(size: 11, weight: .semibold))
if case .stale = quota.connection {
Text("stale")
.font(.system(size: 9.5))
.foregroundStyle(.secondary)
} else if case .transientFailure = quota.connection {
Text("retrying")
.font(.system(size: 9.5))
.foregroundStyle(.orange)
}
Spacer()
if let plan = quota.planLabel, !plan.isEmpty {
Text(plan)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.12))
)
// Size to content. Plan names are bounded short strings
// ("Max 20x", "Pro Lite", "Free Workspace"); a forced
// maxWidth was making short labels look stretched.
.fixedSize(horizontal: true, vertical: false)
}
}
ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in
QuotaDetailRow(window: w)
}
if !quota.footerLines.isEmpty {
Divider()
.padding(.top, 2)
ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
}
}
private func terminalFailureCard(reason: String?) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(reconnectTitle)
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.red)
Text(reason ?? defaultReconnectReason)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
Text(reconnectInstruction)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
private var reconnectTitle: String {
switch quota.providerFilter {
case .codex: return "Reconnect Codex"
default: return "Reconnect Claude"
}
}
private var defaultReconnectReason: String {
switch quota.providerFilter {
case .codex: return "Refresh token rejected by OpenAI."
default: return "Refresh token rejected by Anthropic."
}
}
private var reconnectInstruction: String {
switch quota.providerFilter {
case .codex: return "Run `codex login` in your terminal, then click Reconnect."
default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
}
}
}
private struct QuotaDetailRow: View {
let window: QuotaSummary.Window
var body: some View {
HStack(spacing: 8) {
Text(window.label)
.font(.system(size: 10.5))
.frame(width: 92, alignment: .leading)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule()
.fill(barColor)
.frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1))))
}
}
.frame(height: 4)
Text(window.percentLabel)
.font(.codeMono(size: 10.5, weight: .medium))
.frame(width: 36, alignment: .trailing)
if !window.resetsInLabel.isEmpty {
Text(window.resetsInLabel)
.font(.codeMono(size: 10))
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .trailing)
}
}
}
private var barColor: Color {
switch QuotaSummary.severity(for: window.percent) {
case .normal: return Color.green.opacity(0.85)
case .warning: return Color.yellow
case .critical: return Color.orange
case .danger: return Color.red
}
}
}
extension ProviderFilter {
@MainActor var color: Color {
switch self {
case .all: return Theme.brandAccent
case .claude: return Theme.categoricalClaude
case .codex: return Theme.categoricalCodex
case .cursor: return Theme.categoricalCursor
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0)
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/255.0)
case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0)
case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
case .crush: return Color(red: 0xE0/255.0, green: 0x6C/255.0, blue: 0x9F/255.0)
}
}
}