mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 12:20:43 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
* Add IBM Bob provider * Add workspace extraction for Cline-family providers Extract project name from workspace directory in api_conversation_history.json so sessions show actual folder names instead of the provider display name. Thread projectPath through ParsedProviderCall to avoid unsanitizePath mangling hyphenated folder names. --------- Co-authored-by: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Co-authored-by: iamtoruk <hello@agentseal.org>
360 lines
13 KiB
Swift
360 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 .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/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)
|
|
}
|
|
}
|
|
}
|