From b7deb532ad20b54e5bc3afde1bc70f6ed9f0db2c Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:56:20 +0800 Subject: [PATCH] feat: add codex usage support and open sidebar tab in new window --- Packages/Package.swift | 5 +- .../Sources/RxCodeChatKit/ChatBridge.swift | 3 + .../RateLimitAdvicePopover.swift | 73 + .../Resources/Localizable.xcstrings | 26 +- .../RxCodeChatKit/StatusLineView.swift | 50 +- .../RxCodeCore/Models/RateLimitAdvisor.swift | 276 ++ .../RxCodeCore/Models/RateLimitHistory.swift | 424 +++ .../RxCodeCore/Models/RateLimitUsage.swift | 10 + .../Resources/Localizable.xcstrings | 456 ++++ .../RateLimitHistoryTests.swift | 232 ++ RxCode.xcodeproj/project.pbxproj | 8 +- RxCode/App/AppState+CrossProject.swift | 22 + RxCode/App/AppState+Lifecycle.swift | 5 + RxCode/App/AppState+PullRequest.swift | 29 +- RxCode/App/AppState+RateLimitHistory.swift | 224 ++ RxCode/App/AppState+RateLimitUsage.swift | 1 + RxCode/App/AppState.swift | 10 + RxCode/App/MenuBarViews.swift | 14 +- RxCode/App/RxCodeApp.swift | 91 +- RxCode/Resources/Localizable.xcstrings | 2336 +++++++++++++---- RxCode/Services/CodexAppServer+Parsing.swift | 38 + RxCode/Services/CodexAppServer+Turn.swift | 16 + RxCode/Services/ThreadStore+Container.swift | 5 +- .../ThreadStore+RateLimitHistory.swift | 63 + RxCode/Views/GeneralRouteWindowView.swift | 187 ++ .../Sidebar/BriefingRateLimitStatsView.swift | 252 ++ RxCode/Views/Sidebar/BriefingStatTile.swift | 200 ++ .../Sidebar/BriefingUsageStatsView.swift | 168 +- RxCode/Views/Sidebar/ProjectTreeView.swift | 13 + .../Sidebar/RateLimitStatsPopovers.swift | 874 ++++++ .../Views/Sidebar/UsageBreakdownPopover.swift | 4 +- RxCodeTests/CodexTokenUsageTests.swift | 67 + 32 files changed, 5536 insertions(+), 646 deletions(-) create mode 100644 Packages/Sources/RxCodeChatKit/RateLimitAdvicePopover.swift create mode 100644 Packages/Sources/RxCodeCore/Models/RateLimitAdvisor.swift create mode 100644 Packages/Sources/RxCodeCore/Models/RateLimitHistory.swift create mode 100644 Packages/Sources/RxCodeCore/Resources/Localizable.xcstrings create mode 100644 Packages/Tests/RxCodeCoreTests/RateLimitHistoryTests.swift create mode 100644 RxCode/App/AppState+RateLimitHistory.swift create mode 100644 RxCode/Services/ThreadStore+RateLimitHistory.swift create mode 100644 RxCode/Views/GeneralRouteWindowView.swift create mode 100644 RxCode/Views/Sidebar/BriefingRateLimitStatsView.swift create mode 100644 RxCode/Views/Sidebar/BriefingStatTile.swift create mode 100644 RxCode/Views/Sidebar/RateLimitStatsPopovers.swift create mode 100644 RxCodeTests/CodexTokenUsageTests.swift diff --git a/Packages/Package.swift b/Packages/Package.swift index 41837c94..bbfcac0f 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -23,7 +23,10 @@ let package = Package( dependencies: [ .product(name: "libwebp", package: "libwebp-Xcode"), ], - path: "Sources/RxCodeCore" + path: "Sources/RxCodeCore", + resources: [ + .process("Resources"), + ] ), .target( name: "RxCodeChatKit", diff --git a/Packages/Sources/RxCodeChatKit/ChatBridge.swift b/Packages/Sources/RxCodeChatKit/ChatBridge.swift index 82949113..90692ede 100644 --- a/Packages/Sources/RxCodeChatKit/ChatBridge.swift +++ b/Packages/Sources/RxCodeChatKit/ChatBridge.swift @@ -30,6 +30,9 @@ public final class ChatBridge { /// interrupt it. public var canSteer: Bool = false public var modelDisplayName: String = "" + /// Usage-limit advice for the selected provider and model, e.g. "only ~2 + /// more tasks fit in the 5-hour limit"; nil when limits need no attention. + public var rateLimitAdvice: RateLimitAdvice? public var sessionStats: ChatSessionStats = ChatSessionStats() public var autoPreviewSettings: AttachmentAutoPreviewSettings = AttachmentAutoPreviewSettings() public var appVersion: String = "" diff --git a/Packages/Sources/RxCodeChatKit/RateLimitAdvicePopover.swift b/Packages/Sources/RxCodeChatKit/RateLimitAdvicePopover.swift new file mode 100644 index 00000000..8ed2bc0f --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/RateLimitAdvicePopover.swift @@ -0,0 +1,73 @@ +import SwiftUI +import RxCodeCore + +/// Popover behind the status-line usage-limit warning: why the selected model +/// needs attention and what to switch to. +struct RateLimitAdvicePopover: View { + let advice: RateLimitAdvice + /// Switches the session to another provider (raw `AgentProvider` value). + let onSwitchProvider: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: advice.severity == .warning ? "exclamationmark.triangle.fill" : "lightbulb") + .foregroundStyle(advice.severity == .warning ? ClaudeTheme.statusWarning : ClaudeTheme.accent) + Group { + if advice.severity == .warning { + Text("Usage limit running low", bundle: .module) + } else { + Text("Usage limit tip", bundle: .module) + } + } + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + } + + VStack(alignment: .leading, spacing: 6) { + ForEach(advice.reasons, id: \.self) { reason in + Text(verbatim: reason) + .font(.system(size: 12)) + .foregroundStyle(ClaudeTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + if !advice.suggestions.isEmpty { + Divider() + Text("Suggestions", bundle: .module) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + ForEach(advice.suggestions) { suggestion in + suggestionRow(suggestion) + } + } + } + .padding(14) + .frame(width: 320, alignment: .leading) + } + + private func suggestionRow(_ suggestion: RateLimitAdvice.Suggestion) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: suggestion.title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(verbatim: suggestion.detail) + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + if case .switchProvider(let providerRaw) = suggestion.action { + Button { + onSwitchProvider(providerRaw) + } label: { + Text("Switch", bundle: .module) + } + .controlSize(.small) + } + } + } +} diff --git a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings index a6bc7bc4..038300fa 100644 --- a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings +++ b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings @@ -1228,8 +1228,6 @@ } }, "Combine every queued message and send it as a single turn" : { - - "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1740,8 +1738,6 @@ } }, "Couldn't reach the current response — still queued, sends when it ends." : { - - "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3152,8 +3148,6 @@ } }, "Interrupt and send all now" : { - - "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3164,8 +3158,6 @@ } }, "Interrupt and send now" : { - - "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4978,8 +4970,6 @@ } }, "Send before the current response ends — steer it in, or interrupt" : { - - "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5516,8 +5506,6 @@ } }, "Steer all into current response" : { - - "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5528,8 +5516,6 @@ } }, "Steer into current response" : { - - "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5598,6 +5584,12 @@ } } } + }, + "Suggestions" : { + + }, + "Switch" : { + }, "Switch to horizontal scroll" : { "localizations" : { @@ -6080,6 +6072,12 @@ } } } + }, + "Usage limit running low" : { + + }, + "Usage limit tip" : { + }, "Usage:" : { "localizations" : { diff --git a/Packages/Sources/RxCodeChatKit/StatusLineView.swift b/Packages/Sources/RxCodeChatKit/StatusLineView.swift index a2a003e1..4b35bec2 100644 --- a/Packages/Sources/RxCodeChatKit/StatusLineView.swift +++ b/Packages/Sources/RxCodeChatKit/StatusLineView.swift @@ -7,6 +7,7 @@ struct StatusLineView: View { @State private var showFiveHourPopover = false @State private var showSevenDayPopover = false @State private var showContextPopover = false + @State private var showAdvicePopover = false private var totalResponseDuration: Double { chatBridge.messages @@ -28,6 +29,8 @@ struct StatusLineView: View { usageSegments() + rateLimitAdviceSegment() + Divider().frame(height: 12) contextSegment() @@ -160,15 +163,19 @@ struct StatusLineView: View { body: "Tracks usage against Anthropic's rolling 7-day limit. Resets gradually as older requests age out." ) case .codex: - rateLimitSegment( - label: String(localized: "5h", bundle: .module), - icon: "clock", - percent: rateLimit?.fiveHourPercent, - resetsAt: rateLimit?.fiveHourResetsAt, - isPresented: $showFiveHourPopover, - title: "5-hour rate limit", - body: "Tracks usage against Codex's rolling 5-hour limit. Resets gradually as older requests age out." - ) + // Plans without a separate 5-hour limit report it as a copy of the + // 7-day window; only show the real one. + if rateLimit?.hasFiveHourLimit ?? true { + rateLimitSegment( + label: String(localized: "5h", bundle: .module), + icon: "clock", + percent: rateLimit?.fiveHourPercent, + resetsAt: rateLimit?.fiveHourResetsAt, + isPresented: $showFiveHourPopover, + title: "5-hour rate limit", + body: "Tracks usage against Codex's rolling 5-hour limit. Resets gradually as older requests age out." + ) + } rateLimitSegment( label: String(localized: "7d", bundle: .module), icon: "calendar", @@ -183,6 +190,31 @@ struct StatusLineView: View { } } + // MARK: - Rate Limit Advice + + /// Warning shown when the selected model is close to (or burning through) + /// its usage limits, with cheaper models and providers to switch to. + @ViewBuilder + private func rateLimitAdviceSegment() -> some View { + if let advice = chatBridge.rateLimitAdvice { + Image(systemName: advice.severity == .warning ? "exclamationmark.triangle.fill" : "lightbulb") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(advice.severity == .warning ? ClaudeTheme.statusWarning : ClaudeTheme.textTertiary) + .contentShape(Rectangle()) + .help(advice.tooltip) + .onTapGesture { showAdvicePopover.toggle() } + .popover(isPresented: $showAdvicePopover, arrowEdge: .top) { + RateLimitAdvicePopover(advice: advice) { providerRaw in + showAdvicePopover = false + if let provider = AgentProvider(rawValue: providerRaw) { + chatBridge.setSessionProvider(provider) + } + } + } + .accessibilityLabel(Text(advice.headline)) + } + } + // MARK: - Rate Limit Segment @ViewBuilder diff --git a/Packages/Sources/RxCodeCore/Models/RateLimitAdvisor.swift b/Packages/Sources/RxCodeCore/Models/RateLimitAdvisor.swift new file mode 100644 index 00000000..73d6c272 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/RateLimitAdvisor.swift @@ -0,0 +1,276 @@ +import Foundation + +/// Advice for the model a user is about to run: why its usage limits need +/// attention and which models or providers would stretch them further. +public struct RateLimitAdvice: Sendable, Equatable { + public enum Severity: Int, Sendable, Comparable { + /// Worth knowing, e.g. a premium model used for light tasks. + case info + /// The limit is close or on pace to run out before it resets. + case warning + + public static func < (lhs: Severity, rhs: Severity) -> Bool { lhs.rawValue < rhs.rawValue } + } + + public struct Suggestion: Sendable, Equatable, Identifiable { + public enum Action: Sendable, Equatable { + case switchModel(String) + case switchProvider(String) + case waitForReset(Date) + } + + public var title: String + public var detail: String + public var action: Action + + public var id: String { title } + } + + public var severity: Severity + public var reasons: [String] + public var suggestions: [Suggestion] + + public var headline: String { reasons.first ?? "" } + + /// Plain-text summary for hover help. + public var tooltip: String { + var lines = reasons + if !suggestions.isEmpty { + lines.append("") + lines.append(contentsOf: suggestions.map { "• \($0.title) — \($0.detail)" }) + } + return lines.joined(separator: "\n") + } +} + +/// Turns measured task costs and current limits into `RateLimitAdvice`. +public enum RateLimitAdvisor { + /// Limit state and task costs for one provider. + public struct ProviderContext: Sendable { + public var providerRaw: String + public var displayName: String + public var usage: RateLimitUsage? + public var summary: RateLimitTaskCostSummary + + public init(providerRaw: String, displayName: String, usage: RateLimitUsage?, summary: RateLimitTaskCostSummary) { + self.providerRaw = providerRaw + self.displayName = displayName + self.usage = usage + self.summary = summary + } + } + + /// Warn once this few tasks (or fewer) fit in a window. + public static let nearLimitTasks = 3 + /// Warn at this share used even before task costs are known. + public static let nearLimitPercent: Double = 90 + /// A model counts as cheaper when it uses at most this share of the + /// current model's limit per task (or per token). + static let cheaperRatio = 0.7 + /// Other providers are suggested while their 5-hour usage is below this. + static let alternativeFiveHourCeiling: Double = 70 + static let alternativeSevenDayCeiling: Double = 90 + + static let fiveHourWindow: TimeInterval = 5 * 3600 + static let sevenDayWindow: TimeInterval = 7 * 24 * 3600 + + public static func advise( + context: ProviderContext, + model: String?, + alternatives: [ProviderContext] = [], + modelDisplayName: (String) -> String = { $0 }, + now: Date = .now + ) -> RateLimitAdvice? { + guard let usage = context.usage else { return nil } + let summary = context.summary + let current = summary.stats(forModel: model) + let modelLabel = current.map { modelDisplayName($0.model) } + ?? model.map(modelDisplayName) + ?? context.displayName + + let hasFiveHour = usage.hasFiveHourLimit + var warnings: [String] = [] + var infos: [String] = [] + + // 1. Few tasks left before a limit is reached. + let fiveHourEstimate = current?.remainingFiveHour ?? summary.remainingFiveHour + let sevenDayEstimate = current?.remainingSevenDay ?? summary.remainingSevenDay + if hasFiveHour, let reason = nearLimitReason(.fiveHour, used: usage.fiveHourPercent, estimate: fiveHourEstimate, model: modelLabel) { + warnings.append(reason) + } + if let reason = nearLimitReason(.sevenDay, used: usage.sevenDayPercent, estimate: sevenDayEstimate, model: modelLabel) { + warnings.append(reason) + } + + // 2. Burning the limit faster than the window allows. + if hasFiveHour, let early = exhaustionLead(used: usage.fiveHourPercent, resetsAt: usage.fiveHourResetsAt, window: fiveHourWindow, now: now) { + warnings.append(String(localized: "At this pace the 5-hour limit runs out about \(formatInterval(early)) before it resets.", bundle: .module)) + } + if let early = exhaustionLead(used: usage.sevenDayPercent, resetsAt: usage.sevenDayResetsAt, window: sevenDayWindow, now: now) { + warnings.append(String(localized: "At this pace the 7-day limit runs out about \(formatInterval(early)) before it resets.", bundle: .module)) + } + + // 3. A premium model spent on light tasks. + let cheaper = cheaperModels(than: current, in: summary) + if let current, current.sampleCount >= RateLimitTaskCostSummary.minimumIsolatedSamples, !cheaper.isEmpty, + let overallMedian = RateLimitTaskCostSummary.median(summary.tasks.map { Double($0.tokens) }.filter { $0 > 0 }), + current.medianTokens > 0, current.medianTokens <= overallMedian { + infos.append(String( + localized: "Recent \(modelLabel) tasks are light (median \(formatTokens(current.medianTokens)) tokens). A smaller model could handle them for less of the limit.", + bundle: .module + )) + } + + let reasons = warnings + infos + guard !reasons.isEmpty else { return nil } + + var suggestions: [RateLimitAdvice.Suggestion] = cheaper.prefix(2).map { stats in + let average = hasFiveHour + ? stats.averageFiveHour.map { String(localized: "\(formatPoints($0)) of 5h per task", bundle: .module) } + : stats.averageSevenDay.map { String(localized: "\(formatPoints($0)) of 7d per task", bundle: .module) } + var detail = average ?? String(localized: "Lower limit cost per token", bundle: .module) + if case .tasks(let count) = hasFiveHour ? stats.remainingFiveHour : stats.remainingSevenDay { + detail = String(localized: "\(detail) · ~\(count) tasks left", bundle: .module) + } + return .init( + title: String(localized: "Switch to \(modelDisplayName(stats.model))", bundle: .module), + detail: detail, + action: .switchModel(stats.model) + ) + } + for alternative in alternatives where alternative.providerRaw != context.providerRaw { + guard let altUsage = alternative.usage, + !altUsage.hasFiveHourLimit || altUsage.fiveHourPercent < alternativeFiveHourCeiling, + altUsage.sevenDayPercent < alternativeSevenDayCeiling + else { continue } + let altHasFiveHour = altUsage.hasFiveHourLimit + var detail = altHasFiveHour + ? String( + localized: "5-hour at \(formatPercent(altUsage.fiveHourPercent)), 7-day at \(formatPercent(altUsage.sevenDayPercent))", + bundle: .module + ) + : String(localized: "7-day at \(formatPercent(altUsage.sevenDayPercent))", bundle: .module) + let altEstimate = altHasFiveHour ? alternative.summary.remainingFiveHour : alternative.summary.remainingSevenDay + if case .tasks(let count) = altEstimate { + detail = String(localized: "\(detail) · ~\(count) tasks left", bundle: .module) + } + suggestions.append(.init( + title: String(localized: "Switch to \(alternative.displayName)", bundle: .module), + detail: detail, + action: .switchProvider(alternative.providerRaw) + )) + } + if hasFiveHour, !warnings.isEmpty, let reset = usage.fiveHourResetsAt, reset > now, usage.fiveHourPercent >= usage.sevenDayPercent { + suggestions.append(.init( + title: String(localized: "Wait for the 5-hour reset", bundle: .module), + detail: String(localized: "Resets in \(formatInterval(reset.timeIntervalSince(now)))", bundle: .module), + action: .waitForReset(reset) + )) + } + + return RateLimitAdvice( + severity: warnings.isEmpty ? .info : .warning, + reasons: reasons, + suggestions: suggestions + ) + } + + // MARK: - Rules + + enum LimitWindow { + case fiveHour + case sevenDay + } + + /// Each window's sentences are spelled out in full so they translate as + /// whole sentences rather than with the window name spliced in. + static func nearLimitReason( + _ window: LimitWindow, + used: Double, + estimate: RateLimitTaskCostSummary.Estimate, + model: String + ) -> String? { + if case .tasks(let count) = estimate, count <= nearLimitTasks { + switch (window, count) { + case (.fiveHour, 0): + return String(localized: "The 5-hour limit is nearly used up — another \(model) task will likely hit it.", bundle: .module) + case (.sevenDay, 0): + return String(localized: "The 7-day limit is nearly used up — another \(model) task will likely hit it.", bundle: .module) + case (.fiveHour, 1): + return String(localized: "Only about 1 more \(model) task fits in the 5-hour limit.", bundle: .module) + case (.sevenDay, 1): + return String(localized: "Only about 1 more \(model) task fits in the 7-day limit.", bundle: .module) + case (.fiveHour, _): + return String(localized: "Only about \(count) more \(model) tasks fit in the 5-hour limit.", bundle: .module) + case (.sevenDay, _): + return String(localized: "Only about \(count) more \(model) tasks fit in the 7-day limit.", bundle: .module) + } + } + if used >= nearLimitPercent { + switch window { + case .fiveHour: + return String(localized: "The 5-hour limit is \(formatPercent(used)) used.", bundle: .module) + case .sevenDay: + return String(localized: "The 7-day limit is \(formatPercent(used)) used.", bundle: .module) + } + } + return nil + } + + /// How long before `resetsAt` the limit runs out at the average rate so + /// far in this window, or nil when it lasts (or there's too little data). + static func exhaustionLead(used: Double, resetsAt: Date?, window: TimeInterval, now: Date) -> TimeInterval? { + guard let resetsAt, resetsAt > now, used >= 20, used < 100 else { return nil } + let elapsed = window - resetsAt.timeIntervalSince(now) + // Too early in the window for the pace to mean much. + guard elapsed >= window * 0.1 else { return nil } + let ratePerSecond = used / elapsed + let timeToFull = (100 - used) / ratePerSecond + let lead = resetsAt.timeIntervalSince(now) - timeToFull + // Ignore near-misses so the warning isn't noisy. + return lead > window * 0.05 ? lead : nil + } + + /// Models on the same provider that cost clearly less per task (or per + /// token when per-task cost is unknown), cheapest first. + static func cheaperModels( + than current: RateLimitTaskCostSummary.ModelStats?, + in summary: RateLimitTaskCostSummary + ) -> [RateLimitTaskCostSummary.ModelStats] { + guard let current else { return [] } + return summary.models + .filter { $0.model != current.model && $0.sampleCount >= 2 } + .filter { candidate in + if let mine = current.averageFiveHour, mine > 0, let theirs = candidate.averageFiveHour { + return theirs <= mine * cheaperRatio + } + if let mine = current.fiveHourPerMillionTokens, mine > 0, let theirs = candidate.fiveHourPerMillionTokens { + return theirs <= mine * cheaperRatio + } + return false + } + .sorted { ($0.averageFiveHour ?? .infinity) < ($1.averageFiveHour ?? .infinity) } + } + + // MARK: - Formatting + + static func formatPercent(_ value: Double) -> String { + (value / 100).formatted(.percent.precision(.fractionLength(0))) + } + + static func formatPoints(_ value: Double) -> String { + (value / 100).formatted(.percent.precision(.fractionLength(0...2))) + } + + static func formatTokens(_ value: Double) -> String { + Int(value).formatted(.number.notation(.compactName).precision(.fractionLength(0...1))) + } + + static func formatInterval(_ seconds: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 2 + formatter.allowedUnits = [.day, .hour, .minute] + return formatter.string(from: max(60, seconds)) ?? "" + } +} diff --git a/Packages/Sources/RxCodeCore/Models/RateLimitHistory.swift b/Packages/Sources/RxCodeCore/Models/RateLimitHistory.swift new file mode 100644 index 00000000..ded4e616 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/RateLimitHistory.swift @@ -0,0 +1,424 @@ +import Foundation +import SwiftData + +/// One point-in-time reading of a provider's 5-hour / 7-day usage limits. +/// Written whenever a fresh reading arrives (throttled by +/// `RateLimitSample.shouldRecord`) and plotted as the usage-limit history +/// line chart on the briefing page. +@Model +public final class RateLimitSample { + @Attribute(.unique) public var id: UUID + public var providerRaw: String + public var capturedAt: Date + public var fiveHourPercent: Double + public var sevenDayPercent: Double + public var fiveHourResetsAt: Date? + public var sevenDayResetsAt: Date? + + public init(providerRaw: String, usage: RateLimitUsage, capturedAt: Date = .now) { + self.id = UUID() + self.providerRaw = providerRaw + self.capturedAt = capturedAt + self.fiveHourPercent = usage.fiveHourPercent + self.sevenDayPercent = usage.sevenDayPercent + self.fiveHourResetsAt = usage.fiveHourResetsAt + self.sevenDayResetsAt = usage.sevenDayResetsAt + } + + /// Readings closer together than this are only stored when a value changed. + public static let minimumUnchangedInterval: TimeInterval = 10 * 60 + + /// Whether `usage` read at `date` adds information over the `previous` + /// stored sample. Changed values are always kept so the chart shows every + /// step; unchanged ones only every `minimumUnchangedInterval`. + public static func shouldRecord(previous: RateLimitSamplePoint?, usage: RateLimitUsage, at date: Date) -> Bool { + guard let previous else { return true } + if previous.fiveHourPercent != usage.fiveHourPercent + || previous.sevenDayPercent != usage.sevenDayPercent { + return true + } + return date.timeIntervalSince(previous.capturedAt) >= minimumUnchangedInterval + } + + public func toPoint() -> RateLimitSamplePoint { + RateLimitSamplePoint( + providerRaw: providerRaw, + capturedAt: capturedAt, + fiveHourPercent: fiveHourPercent, + sevenDayPercent: sevenDayPercent + ) + } +} + +/// Value copy of a `RateLimitSample` row. +public struct RateLimitSamplePoint: Sendable, Equatable { + public var providerRaw: String + public var capturedAt: Date + public var fiveHourPercent: Double + public var sevenDayPercent: Double + + public init(providerRaw: String, capturedAt: Date, fiveHourPercent: Double, sevenDayPercent: Double) { + self.providerRaw = providerRaw + self.capturedAt = capturedAt + self.fiveHourPercent = fiveHourPercent + self.sevenDayPercent = sevenDayPercent + } +} + +/// How much of a provider's usage limits one agent task (a prompt through its +/// final `result`) consumed, measured as the change in the limit percentages +/// read before and after the task. +@Model +public final class RateLimitTaskCost { + @Attribute(.unique) public var id: UUID + public var providerRaw: String + /// Model id as reported by the backend; empty string when unknown. + public var model: String + public var projectId: UUID? + public var threadId: String + public var threadTitle: String + public var startedAt: Date + public var endedAt: Date + /// Percentage points of the 5-hour limit used; nil when the window reset + /// mid-task, so the change can't be attributed. + public var fiveHourDelta: Double? + /// Percentage points of the 7-day limit used; nil when unattributable. + public var sevenDayDelta: Double? + /// Other tasks on the same provider running at the same time. Their usage + /// lands in the same limit, so overlapping measurements are less precise. + public var concurrentRuns: Int = 0 + /// Uncached tokens the task processed (input + output + cache writes). + /// Cache reads are left out since they barely count toward limits. + public var tokens: Int = 0 + + public init( + providerRaw: String, + model: String?, + projectId: UUID?, + threadId: String, + threadTitle: String, + startedAt: Date, + endedAt: Date, + fiveHourDelta: Double?, + sevenDayDelta: Double?, + concurrentRuns: Int, + tokens: Int = 0 + ) { + self.id = UUID() + self.providerRaw = providerRaw + self.model = model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.projectId = projectId + self.threadId = threadId + self.threadTitle = threadTitle + self.startedAt = startedAt + self.endedAt = endedAt + self.fiveHourDelta = fiveHourDelta + self.sevenDayDelta = sevenDayDelta + self.concurrentRuns = concurrentRuns + self.tokens = tokens + } + + public func toSnapshot() -> RateLimitTaskCostSnapshot { + RateLimitTaskCostSnapshot( + id: id, + providerRaw: providerRaw, + model: model, + projectId: projectId, + threadId: threadId, + threadTitle: threadTitle, + startedAt: startedAt, + endedAt: endedAt, + fiveHourDelta: fiveHourDelta, + sevenDayDelta: sevenDayDelta, + concurrentRuns: concurrentRuns, + tokens: tokens + ) + } +} + +/// Value copy of a `RateLimitTaskCost` row. +public struct RateLimitTaskCostSnapshot: Sendable, Equatable, Identifiable { + public var id: UUID + public var providerRaw: String + public var model: String + public var projectId: UUID? + public var threadId: String + public var threadTitle: String + public var startedAt: Date + public var endedAt: Date + public var fiveHourDelta: Double? + public var sevenDayDelta: Double? + public var concurrentRuns: Int + public var tokens: Int + + public init( + id: UUID = UUID(), + providerRaw: String, + model: String = "", + projectId: UUID? = nil, + threadId: String = "", + threadTitle: String = "", + startedAt: Date, + endedAt: Date, + fiveHourDelta: Double?, + sevenDayDelta: Double?, + concurrentRuns: Int = 0, + tokens: Int = 0 + ) { + self.id = id + self.providerRaw = providerRaw + self.model = model + self.projectId = projectId + self.threadId = threadId + self.threadTitle = threadTitle + self.startedAt = startedAt + self.endedAt = endedAt + self.fiveHourDelta = fiveHourDelta + self.sevenDayDelta = sevenDayDelta + self.concurrentRuns = concurrentRuns + self.tokens = tokens + } + + public var isIsolated: Bool { concurrentRuns == 0 } +} + +/// Change in usage-limit percentages between two readings. +public enum RateLimitDelta { + /// Reset times further apart than this mean the window rolled over. + static let resetTolerance: TimeInterval = 5 * 60 + + /// Percentage points used between `before` and `after`, per window. A + /// window yields nil when it reset in between (its reset time moved or its + /// percentage dropped), since the usage can't be attributed. + public static func between( + _ before: RateLimitUsage, + _ after: RateLimitUsage + ) -> (fiveHour: Double?, sevenDay: Double?) { + ( + delta(before.fiveHourPercent, after.fiveHourPercent, before.fiveHourResetsAt, after.fiveHourResetsAt), + delta(before.sevenDayPercent, after.sevenDayPercent, before.sevenDayResetsAt, after.sevenDayResetsAt) + ) + } + + private static func delta(_ before: Double, _ after: Double, _ beforeReset: Date?, _ afterReset: Date?) -> Double? { + if let beforeReset, let afterReset, + abs(afterReset.timeIntervalSince(beforeReset)) > resetTolerance { + return nil + } + guard after >= before else { return nil } + return after - before + } +} + +/// Per-task limit cost statistics for one provider, plus how many more tasks +/// of average cost fit in what's left of each window. +public struct RateLimitTaskCostSummary: Sendable, Equatable { + public enum Estimate: Sendable, Equatable { + /// No measured tasks for this window yet. + case insufficientData + /// Tasks measured so far didn't move the limit at all. + case unbounded + case tasks(Int) + } + + /// Isolated samples are preferred once there are at least this many. + public static let minimumIsolatedSamples = 3 + + /// Every measured task in the range, newest first. + public var tasks: [RateLimitTaskCostSnapshot] = [] + /// Tasks the averages and estimates are based on. + public var sampleCount: Int = 0 + /// True when concurrent runs were excluded from the averages. + public var usesIsolatedSamplesOnly = false + public var averageFiveHour: Double? + public var averageSevenDay: Double? + /// Highest 5-hour cost (7-day breaks ties). + public var mostExpensive: RateLimitTaskCostSnapshot? + /// Lowest 5-hour cost (7-day breaks ties). + public var leastExpensive: RateLimitTaskCostSnapshot? + public var remainingFiveHour: Estimate = .insufficientData + public var remainingSevenDay: Estimate = .insufficientData + /// Per-model costs and estimates, most measured tasks first. + public var models: [ModelStats] = [] + + /// Limit cost and remaining-task estimates for one model. + public struct ModelStats: Sendable, Equatable, Identifiable { + public var model: String + public var sampleCount: Int + public var averageFiveHour: Double? + public var averageSevenDay: Double? + /// Mean uncached tokens per task. + public var averageTokens: Double + /// Median uncached tokens per task — how heavy this model's tasks are. + public var medianTokens: Double + /// 5-hour percentage points per million uncached tokens. Larger, more + /// capable models draw down limits faster for the same tokens, so this + /// ranks the provider's models by tier. + public var fiveHourPerMillionTokens: Double? + /// 7-day percentage points per million uncached tokens. + public var sevenDayPerMillionTokens: Double? = nil + public var remainingFiveHour: Estimate + public var remainingSevenDay: Estimate + + public var id: String { model } + } + + public init() {} + + /// Stats for `model`, matching aliases such as `opus` against full ids + /// such as `claude-opus-5-5`. + public func stats(forModel model: String?) -> ModelStats? { + guard let needle = model?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !needle.isEmpty + else { return nil } + if let exact = models.first(where: { $0.model.lowercased() == needle }) { return exact } + return models.first { candidate in + let id = candidate.model.lowercased() + return !id.isEmpty && (id.contains(needle) || needle.contains(id)) + } + } + + public var isEmpty: Bool { tasks.isEmpty } + + public static func aggregate( + _ costs: [RateLimitTaskCostSnapshot], + current: RateLimitUsage? + ) -> RateLimitTaskCostSummary { + var summary = RateLimitTaskCostSummary() + summary.tasks = costs.sorted { $0.endedAt > $1.endedAt } + + let measured = summary.tasks.filter { $0.fiveHourDelta != nil || $0.sevenDayDelta != nil } + let isolated = measured.filter(\.isIsolated) + let basis: [RateLimitTaskCostSnapshot] + if isolated.count >= minimumIsolatedSamples, isolated.count < measured.count { + basis = isolated + summary.usesIsolatedSamplesOnly = true + } else { + basis = measured + } + summary.sampleCount = basis.count + summary.averageFiveHour = average(basis.compactMap(\.fiveHourDelta)) + summary.averageSevenDay = average(basis.compactMap(\.sevenDayDelta)) + + let ranked = basis.sorted(by: costsMore) + summary.mostExpensive = ranked.first + summary.leastExpensive = ranked.count > 1 ? ranked.last : nil + + if let current { + summary.remainingFiveHour = estimate(used: current.fiveHourPercent, average: summary.averageFiveHour) + summary.remainingSevenDay = estimate(used: current.sevenDayPercent, average: summary.averageSevenDay) + } + summary.models = Dictionary(grouping: basis, by: \.model) + .map { model, tasks in modelStats(model: model, tasks: tasks, current: current) } + .sorted { + if $0.sampleCount != $1.sampleCount { return $0.sampleCount > $1.sampleCount } + return $0.model < $1.model + } + return summary + } + + private static func modelStats( + model: String, + tasks: [RateLimitTaskCostSnapshot], + current: RateLimitUsage? + ) -> ModelStats { + let fiveHour = average(tasks.compactMap(\.fiveHourDelta)) + let sevenDay = average(tasks.compactMap(\.sevenDayDelta)) + let tokens = tasks.map { Double($0.tokens) } + let perMillion = perMillionTokens(tasks, delta: \.fiveHourDelta) + let sevenDayPerMillion = perMillionTokens(tasks, delta: \.sevenDayDelta) + return ModelStats( + model: model, + sampleCount: tasks.count, + averageFiveHour: fiveHour, + averageSevenDay: sevenDay, + averageTokens: average(tokens) ?? 0, + medianTokens: median(tokens) ?? 0, + fiveHourPerMillionTokens: perMillion, + sevenDayPerMillionTokens: sevenDayPerMillion, + remainingFiveHour: current.map { estimate(used: $0.fiveHourPercent, average: fiveHour) } ?? .insufficientData, + remainingSevenDay: current.map { estimate(used: $0.sevenDayPercent, average: sevenDay) } ?? .insufficientData + ) + } + + /// Percentage points of a limit per million tokens, over the tasks whose + /// cost in that limit could be measured. + static func perMillionTokens( + _ tasks: [RateLimitTaskCostSnapshot], + delta: KeyPath + ) -> Double? { + let measured = tasks.filter { $0[keyPath: delta] != nil } + let tokens = measured.reduce(0) { $0 + $1.tokens } + guard tokens > 0 else { return nil } + return measured.reduce(0) { $0 + ($1[keyPath: delta] ?? 0) } / Double(tokens) * 1_000_000 + } + + static func median(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + let sorted = values.sorted() + let mid = sorted.count / 2 + return sorted.count.isMultiple(of: 2) ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] + } + + /// Whole tasks of `average` cost that fit in the unused part of a window. + public static func estimate(used: Double, average: Double?) -> Estimate { + guard let average else { return .insufficientData } + guard average > 0 else { return .unbounded } + let left = max(0, 100 - used) + return .tasks(Int((left / average).rounded(.down))) + } + + private static func average(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + return values.reduce(0, +) / Double(values.count) + } + + private static func costsMore(_ lhs: RateLimitTaskCostSnapshot, _ rhs: RateLimitTaskCostSnapshot) -> Bool { + let lhsFive = lhs.fiveHourDelta ?? -1 + let rhsFive = rhs.fiveHourDelta ?? -1 + if lhsFive != rhsFive { return lhsFive > rhsFive } + let lhsSeven = lhs.sevenDayDelta ?? -1 + let rhsSeven = rhs.sevenDayDelta ?? -1 + if lhsSeven != rhsSeven { return lhsSeven > rhsSeven } + return lhs.endedAt > rhs.endedAt + } +} + +/// Limit used per million tokens for one calendar week, to show whether a +/// provider's limits are getting tighter or looser over time. +public struct RateLimitWeeklyCost: Sendable, Equatable, Identifiable { + public var weekStart: Date + /// Tasks the week's figures are based on. + public var taskCount: Int + /// Uncached tokens across those tasks (input + output + cache writes). + public var tokens: Int + public var fiveHourPerMillionTokens: Double? + public var sevenDayPerMillionTokens: Double? + + public var id: Date { weekStart } + + /// Groups measured tasks by the calendar week they finished in, oldest + /// week first. Tasks that ran alone are preferred when a week has any, + /// since concurrent tasks share one limit reading. + public static func weekly( + _ costs: [RateLimitTaskCostSnapshot], + calendar: Calendar = .current + ) -> [RateLimitWeeklyCost] { + let measured = costs.filter { ($0.fiveHourDelta != nil || $0.sevenDayDelta != nil) && $0.tokens > 0 } + let byWeek = Dictionary(grouping: measured) { cost in + calendar.dateInterval(of: .weekOfYear, for: cost.endedAt)?.start ?? cost.endedAt + } + return byWeek.map { weekStart, tasks in + let isolated = tasks.filter(\.isIsolated) + let basis = isolated.isEmpty ? tasks : isolated + return RateLimitWeeklyCost( + weekStart: weekStart, + taskCount: basis.count, + tokens: basis.reduce(0) { $0 + $1.tokens }, + fiveHourPerMillionTokens: RateLimitTaskCostSummary.perMillionTokens(basis, delta: \.fiveHourDelta), + sevenDayPerMillionTokens: RateLimitTaskCostSummary.perMillionTokens(basis, delta: \.sevenDayDelta) + ) + } + .sorted { $0.weekStart < $1.weekStart } + } +} diff --git a/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift b/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift index 4c8ff8f1..4fb45d40 100644 --- a/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift +++ b/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift @@ -26,4 +26,14 @@ public struct RateLimitUsage: Sendable, Codable, Equatable { self.sevenDayResetsAt = sevenDayResetsAt self.twentyFourHourResetsAt = twentyFourHourResetsAt } + + /// False when the plan has no separate 5-hour limit. Such plans report a + /// single window, which shows up as identical 5-hour and 7-day readings + /// that reset at the same time. + public var hasFiveHourLimit: Bool { + guard fiveHourPercent == sevenDayPercent, + let fiveHourResetsAt, let sevenDayResetsAt + else { return true } + return abs(fiveHourResetsAt.timeIntervalSince(sevenDayResetsAt)) > 60 + } } diff --git a/Packages/Sources/RxCodeCore/Resources/Localizable.xcstrings b/Packages/Sources/RxCodeCore/Resources/Localizable.xcstrings new file mode 100644 index 00000000..a225da96 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Resources/Localizable.xcstrings @@ -0,0 +1,456 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "·" : { + + }, + "%@ · ~%lld tasks left" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · 约剩 %2$lld 个任务" + } + } + } + }, + "%@ of 5h per task" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每个任务消耗 5 小时限额的 %@" + } + } + } + }, + "%@ of 7d per task" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每个任务消耗 7 天限额的 %@" + } + } + } + }, + "│" : { + + }, + "5-hour at %@, 7-day at %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时已用 %1$@,7 天已用 %2$@" + } + } + } + }, + "7-day at %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天已用 %@" + } + } + } + }, + "Accept Edits" : { + + }, + "ACP" : { + + }, + "Amber (Yellow)" : { + + }, + "Ask" : { + + }, + "At this pace the 5-hour limit runs out about %@ before it resets." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按当前速度,5 小时限额会在重置前约 %@ 用完。" + } + } + } + }, + "At this pace the 7-day limit runs out about %@ before it resets." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按当前速度,7 天限额会在重置前约 %@ 用完。" + } + } + } + }, + "Auto" : { + + }, + "Available in all projects" : { + + }, + "Backlog" : { + + }, + "Board" : { + + }, + "Branch" : { + + }, + "Briefing" : { + + }, + "Bug" : { + + }, + "Bypass" : { + + }, + "Calling…" : { + + }, + "Changes" : { + + }, + "Chat" : { + + }, + "Chore" : { + + }, + "Claude Code" : { + + }, + "Codex" : { + + }, + "Creating pull request…" : { + + }, + "Done" : { + + }, + "Enter a cron expression." : { + + }, + "Every 15 minutes" : { + + }, + "Every day at 9:00" : { + + }, + "Every hour" : { + + }, + "Every Monday at 9:00" : { + + }, + "Expected 5 fields (minute hour day month weekday), found %lld." : { + + }, + "Feature" : { + + }, + "First day of the month at 9:00" : { + + }, + "Forest (Green)" : { + + }, + "High" : { + + }, + "HTTP" : { + + }, + "Ideas and work not planned yet" : { + + }, + "In Progress" : { + + }, + "Inherit" : { + + }, + "Inspector" : { + + }, + "Invalid %@ field \"%@\"." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Invalid %1$@ field \"%2$@\"." + } + } + } + }, + "Lavender (Purple)" : { + + }, + "Local" : { + + }, + "Low" : { + + }, + "Lower limit cost per token" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每个 token 的限额消耗更低" + } + } + } + }, + "Medium" : { + + }, + "Memo" : { + + }, + "Midnight (Indigo)" : { + + }, + "Ocean (Blue)" : { + + }, + "Off" : { + + }, + "On" : { + + }, + "On review fail" : { + + }, + "On review pass" : { + + }, + "On review start" : { + + }, + "On session stop" : { + + }, + "Only about %lld more %@ tasks fit in the 5-hour limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时限额内仅能再运行约 %1$lld 个 %2$@ 任务。" + } + } + } + }, + "Only about %lld more %@ tasks fit in the 7-day limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天限额内仅能再运行约 %1$lld 个 %2$@ 任务。" + } + } + } + }, + "Only about 1 more %@ task fits in the 5-hour limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时限额内仅能再运行约 1 个 %@ 任务。" + } + } + } + }, + "Only about 1 more %@ task fits in the 7-day limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天限额内仅能再运行约 1 个 %@ 任务。" + } + } + } + }, + "Pending" : { + + }, + "Pending Review" : { + + }, + "Plan" : { + + }, + "Project" : { + + }, + "Projects" : { + + }, + "Recent %@ tasks are light (median %@ tokens). A smaller model could handle them for less of the limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最近的 %1$@ 任务都较轻量(中位数 %2$@ 个 token)。换用更小的模型即可胜任,并能节省限额。" + } + } + } + }, + "Resets in %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@后重置" + } + } + } + }, + "Response in progress in the background" : { + + }, + "Review" : { + + }, + "Run" : { + + }, + "Scheduled" : { + + }, + "Scheduled task finished" : { + + }, + "Send message" : { + + }, + "Shared via .mcp.json" : { + + }, + "SSE" : { + + }, + "stdio" : { + + }, + "Stop generating" : { + + }, + "Switch to %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换到 %@" + } + } + } + }, + "Table" : { + + }, + "Terminal" : { + + }, + "Terracotta (Default)" : { + + }, + "The 5-hour limit is %@ used." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时限额已用 %@。" + } + } + } + }, + "The 5-hour limit is nearly used up — another %@ task will likely hit it." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时限额即将用完——再运行一个 %@ 任务很可能就会触顶。" + } + } + } + }, + "The 7-day limit is %@ used." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天限额已用 %@。" + } + } + } + }, + "The 7-day limit is nearly used up — another %@ task will likely hit it." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天限额即将用完——再运行一个 %@ 任务很可能就会触顶。" + } + } + } + }, + "The run finished without a final message." : { + + }, + "This has been completed" : { + + }, + "This is actively being worked on" : { + + }, + "This item hasn't been started" : { + + }, + "This item is in review" : { + + }, + "This project, this machine only" : { + + }, + "This thread" : { + + }, + "Unsupported macro %@." : { + + }, + "Urgent" : { + + }, + "User" : { + + }, + "Wait for the 5-hour reset" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待 5 小时限额重置" + } + } + } + }, + "Weekdays at 9:00" : { + + }, + "Working…" : { + + } + }, + "version" : "1.1" +} diff --git a/Packages/Tests/RxCodeCoreTests/RateLimitHistoryTests.swift b/Packages/Tests/RxCodeCoreTests/RateLimitHistoryTests.swift new file mode 100644 index 00000000..416c8b05 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/RateLimitHistoryTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing +@testable import RxCodeCore + +@Suite("Usage-limit history") +struct RateLimitHistoryTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func usage( + _ fiveHour: Double, + _ sevenDay: Double, + fiveHourResetsAt: Date? = nil, + sevenDayResetsAt: Date? = nil + ) -> RateLimitUsage { + RateLimitUsage( + fiveHourPercent: fiveHour, + sevenDayPercent: sevenDay, + fiveHourResetsAt: fiveHourResetsAt, + sevenDayResetsAt: sevenDayResetsAt + ) + } + + private func cost( + model: String = "claude-opus-5-5", + fiveHour: Double?, + sevenDay: Double? = nil, + tokens: Int = 0, + concurrent: Int = 0, + minutesAgo: Double = 0 + ) -> RateLimitTaskCostSnapshot { + let end = now.addingTimeInterval(-minutesAgo * 60) + return RateLimitTaskCostSnapshot( + providerRaw: "claudeCode", + model: model, + startedAt: end.addingTimeInterval(-60), + endedAt: end, + fiveHourDelta: fiveHour, + sevenDayDelta: sevenDay, + concurrentRuns: concurrent, + tokens: tokens + ) + } + + // MARK: - Sampling + + @Test func recordsFirstChangedAndStaleSamples() { + let previous = RateLimitSamplePoint(providerRaw: "claudeCode", capturedAt: now, fiveHourPercent: 10, sevenDayPercent: 5) + #expect(RateLimitSample.shouldRecord(previous: nil, usage: usage(10, 5), at: now)) + #expect(!RateLimitSample.shouldRecord(previous: previous, usage: usage(10, 5), at: now.addingTimeInterval(60))) + #expect(RateLimitSample.shouldRecord(previous: previous, usage: usage(11, 5), at: now.addingTimeInterval(60))) + #expect(RateLimitSample.shouldRecord(previous: previous, usage: usage(10, 5), at: now.addingTimeInterval(11 * 60))) + } + + // MARK: - Deltas + + @Test func deltaIsDifferenceWithinSameWindow() { + let reset = now.addingTimeInterval(3600) + let delta = RateLimitDelta.between( + usage(10, 4, fiveHourResetsAt: reset, sevenDayResetsAt: reset), + usage(13.5, 5, fiveHourResetsAt: reset, sevenDayResetsAt: reset) + ) + #expect(delta.fiveHour == 3.5) + #expect(delta.sevenDay == 1) + } + + @Test func deltaIsNilWhenWindowResets() { + let delta = RateLimitDelta.between( + usage(80, 40, fiveHourResetsAt: now, sevenDayResetsAt: now.addingTimeInterval(86_400)), + usage(2, 41, fiveHourResetsAt: now.addingTimeInterval(5 * 3600), sevenDayResetsAt: now.addingTimeInterval(86_400)) + ) + #expect(delta.fiveHour == nil) + #expect(delta.sevenDay == 1) + } + + // MARK: - Summary + + @Test func summaryRanksMostAndLeastAndEstimatesRemaining() { + let summary = RateLimitTaskCostSummary.aggregate( + [cost(fiveHour: 2, sevenDay: 0.5), cost(fiveHour: 6, sevenDay: 1), cost(fiveHour: 4, sevenDay: 0.3)], + current: usage(40, 70) + ) + #expect(summary.sampleCount == 3) + #expect(summary.averageFiveHour == 4) + #expect(summary.mostExpensive?.fiveHourDelta == 6) + #expect(summary.leastExpensive?.fiveHourDelta == 2) + #expect(summary.remainingFiveHour == .tasks(15)) + #expect(summary.remainingSevenDay == .tasks(50)) + } + + @Test func summaryPrefersIsolatedTasksOnceEnoughExist() { + let summary = RateLimitTaskCostSummary.aggregate( + [cost(fiveHour: 1), cost(fiveHour: 1), cost(fiveHour: 1), cost(fiveHour: 10, concurrent: 2)], + current: nil + ) + #expect(summary.usesIsolatedSamplesOnly) + #expect(summary.averageFiveHour == 1) + #expect(summary.tasks.count == 4) + } + + @Test func estimateHandlesMissingAndZeroCost() { + #expect(RateLimitTaskCostSummary.estimate(used: 50, average: nil) == .insufficientData) + #expect(RateLimitTaskCostSummary.estimate(used: 50, average: 0) == .unbounded) + #expect(RateLimitTaskCostSummary.estimate(used: 120, average: 5) == .tasks(0)) + } + + @Test func perModelStatsAndAliasLookup() { + let summary = RateLimitTaskCostSummary.aggregate( + [ + cost(model: "claude-opus-5-5", fiveHour: 4, tokens: 100_000), + cost(model: "claude-opus-5-5", fiveHour: 6, tokens: 100_000), + cost(model: "claude-sonnet-5", fiveHour: 1, tokens: 100_000), + ], + current: usage(50, 10) + ) + let opus = summary.stats(forModel: "opus") + #expect(opus?.model == "claude-opus-5-5") + #expect(opus?.averageFiveHour == 5) + #expect(opus?.remainingFiveHour == .tasks(10)) + #expect(opus?.fiveHourPerMillionTokens == 50) + #expect(summary.stats(forModel: "claude-sonnet-5")?.remainingFiveHour == .tasks(50)) + } + + // MARK: - Advice + + private func context(_ summary: RateLimitTaskCostSummary, usage: RateLimitUsage?, provider: String = "claudeCode") -> RateLimitAdvisor.ProviderContext { + RateLimitAdvisor.ProviderContext(providerRaw: provider, displayName: provider, usage: usage, summary: summary) + } + + @Test func warnsNearLimitAndSuggestsCheaperModelAndProvider() { + let current = usage(90, 30) + let summary = RateLimitTaskCostSummary.aggregate( + [ + cost(model: "opus", fiveHour: 4, tokens: 50_000), + cost(model: "opus", fiveHour: 4, tokens: 50_000), + cost(model: "sonnet", fiveHour: 1, tokens: 50_000), + cost(model: "sonnet", fiveHour: 1, tokens: 50_000), + ], + current: current + ) + let codex = context(RateLimitTaskCostSummary(), usage: usage(10, 5), provider: "codex") + let advice = RateLimitAdvisor.advise( + context: context(summary, usage: current), + model: "opus", + alternatives: [codex], + now: now + ) + #expect(advice?.severity == .warning) + #expect(advice?.reasons.first?.contains("2 more opus tasks") == true) + #expect(advice?.suggestions.contains { $0.action == .switchModel("sonnet") } == true) + #expect(advice?.suggestions.contains { $0.action == .switchProvider("codex") } == true) + } + + @Test func noAdviceWhenLimitsAreComfortable() { + let current = usage(10, 5) + let summary = RateLimitTaskCostSummary.aggregate([cost(fiveHour: 1), cost(fiveHour: 1)], current: current) + #expect(RateLimitAdvisor.advise(context: context(summary, usage: current), model: nil, now: now) == nil) + } + + @Test func warnsWhenPaceExhaustsLimitBeforeReset() { + // 1 hour into the 5-hour window with 60% used: runs out ~40 min in the future, + // well before the reset 4 hours away. + let current = usage(60, 5, fiveHourResetsAt: now.addingTimeInterval(4 * 3600)) + let advice = RateLimitAdvisor.advise( + context: context(RateLimitTaskCostSummary(), usage: current), + model: nil, + now: now + ) + #expect(advice?.severity == .warning) + #expect(advice?.reasons.contains { $0.contains("At this pace the 5-hour limit") } == true) + } + + @Test func tipsSmallerModelForLightTasksOnPremiumModel() { + let current = usage(30, 10) + let summary = RateLimitTaskCostSummary.aggregate( + [ + cost(model: "opus", fiveHour: 3, tokens: 10_000), + cost(model: "opus", fiveHour: 3, tokens: 10_000), + cost(model: "opus", fiveHour: 3, tokens: 10_000), + cost(model: "sonnet", fiveHour: 1, tokens: 200_000), + cost(model: "sonnet", fiveHour: 1, tokens: 200_000), + ], + current: current + ) + let advice = RateLimitAdvisor.advise(context: context(summary, usage: current), model: "opus", now: now) + #expect(advice?.severity == .info) + #expect(advice?.suggestions.first?.action == .switchModel("sonnet")) + } + + @Test func detectsPlansWithoutFiveHourLimit() { + let reset = now.addingTimeInterval(6 * 24 * 3600) + #expect(!usage(20, 20, fiveHourResetsAt: reset, sevenDayResetsAt: reset).hasFiveHourLimit) + #expect(usage(20, 20, fiveHourResetsAt: now.addingTimeInterval(3600), sevenDayResetsAt: reset).hasFiveHourLimit) + #expect(usage(30, 20, fiveHourResetsAt: reset, sevenDayResetsAt: reset).hasFiveHourLimit) + #expect(usage(20, 20).hasFiveHourLimit) + } + + @Test func skipsFiveHourAdviceWithoutFiveHourLimit() { + // 90% of a single weekly window, mirrored into both readings. + let reset = now.addingTimeInterval(24 * 3600) + let current = usage(90, 90, fiveHourResetsAt: reset, sevenDayResetsAt: reset) + let advice = RateLimitAdvisor.advise( + context: context(RateLimitTaskCostSummary(), usage: current), + model: nil, + now: now + ) + #expect(advice?.reasons.contains { $0.contains("5-hour") } == false) + #expect(advice?.reasons.contains { $0.contains("7-day") } == true) + #expect(advice?.suggestions.contains { if case .waitForReset = $0.action { true } else { false } } == false) + } + + @Test func weeklyCostGroupsByWeekAndPrefersSoloTasks() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let costs = [ + // This week: a solo task and a concurrent one that is ignored. + cost(fiveHour: 2, sevenDay: 1, tokens: 1_000_000), + cost(fiveHour: 9, sevenDay: 9, tokens: 1_000_000, concurrent: 1), + // Two weeks ago: only concurrent tasks, so they are used. + cost(fiveHour: 1, sevenDay: nil, tokens: 500_000, concurrent: 1, minutesAgo: 14 * 24 * 60), + // Unmeasured and token-less tasks are skipped. + cost(fiveHour: nil, sevenDay: nil, tokens: 1_000_000), + cost(fiveHour: 5, tokens: 0), + ] + let weeks = RateLimitWeeklyCost.weekly(costs, calendar: calendar) + #expect(weeks.count == 2) + #expect(weeks.first?.fiveHourPerMillionTokens == 2) + #expect(weeks.first?.sevenDayPerMillionTokens == nil) + #expect(weeks.last?.taskCount == 1) + #expect(weeks.last?.fiveHourPerMillionTokens == 2) + #expect(weeks.last?.sevenDayPerMillionTokens == 1) + } +} diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index f8459b84..9c47707d 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 101010102FCB100000000002 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010102FCB100000000001 /* AppStateTests.swift */; }; 101010112FCB100000000002 /* BranchBriefingPromptContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010112FCB100000000001 /* BranchBriefingPromptContextTests.swift */; }; 33993F0F87CF4DB09F2813A8 /* AppStateProjectSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */; }; + 36596E0C1F1F03A76E8EAC44 /* TaskRunQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82AB0A9F0F420072B6D65917 /* TaskRunQueueTests.swift */; }; 5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */; }; 5C3333332FCB400000000002 /* ThreadStoreThreadSummaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */; }; 5C3333332FCB400000000004 /* ThreadStoreHookCardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3333332FCB400000000003 /* ThreadStoreHookCardTests.swift */; }; @@ -36,6 +37,7 @@ AC1900000000000000000300 /* RxAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DF462DCA2FC73FA8002D9562 /* RxAuthSwiftUI */; }; BA0E00012FCB400000000002 /* BriefingNotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA0E00012FCB400000000001 /* BriefingNotificationStateTests.swift */; }; BBD2456729416901FAFBD00D /* GlobalSessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308E9D66FBEB0549929D9E3A /* GlobalSessionPersistenceTests.swift */; }; + C0DE70C00000000000000002 /* CodexTokenUsageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE70C00000000000000001 /* CodexTokenUsageTests.swift */; }; C71A00000000000000000001 /* ChatHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A00000000000000000002 /* ChatHistoryTests.swift */; }; CA5E00012FCB400000000002 /* CacheStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5E00012FCB400000000001 /* CacheStorageTests.swift */; }; DCA8CF4A05C959C4A6EB391F /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = CCDF9594876099576D4FD46E /* RxCodeCore */; }; @@ -95,7 +97,6 @@ FB0000080000000000000001 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000060000000000000001 /* FirebaseCrashlytics */; }; FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */; }; FE0A11BB22CC33DD44EE5601 /* TaskBoardHookTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5602 /* TaskBoardHookTests.swift */; }; - 36596E0C1F1F03A76E8EAC44 /* TaskRunQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82AB0A9F0F420072B6D65917 /* TaskRunQueueTests.swift */; }; FE0A11BB22CC33DD44EE5701 /* MarkdownDescriptionEditorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5702 /* MarkdownDescriptionEditorTests.swift */; }; FE0A11BB22CC33DD44EE5901 /* TaskCreationModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5902 /* TaskCreationModeTests.swift */; }; /* End PBXBuildFile section */ @@ -177,6 +178,7 @@ 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7A5C0001000000000000A001 /* MockAgentBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockAgentBackend.swift; sourceTree = ""; }; 7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrossProjectSendConcurrencyTests.swift; sourceTree = ""; }; + 82AB0A9F0F420072B6D65917 /* TaskRunQueueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskRunQueueTests.swift; sourceTree = ""; }; 85641A3F2099521070F2BAB6 /* MockAppStatePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAppStatePersistence.swift; sourceTree = ""; }; A5E1C0DE0000000000000001 /* MockAppStatePersistence.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockAppStatePersistence.swift; sourceTree = ""; }; A5E1C0DE0000000000000011 /* ClaudeLoginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClaudeLoginTests.swift; sourceTree = ""; }; @@ -189,6 +191,7 @@ AC1900000000000000000002 /* RxCode/Services/AutopilotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RxCode/Services/AutopilotService.swift; sourceTree = SOURCE_ROOT; }; AC1900000000000000000003 /* RxCode/Utilities/KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RxCode/Utilities/KeychainHelper.swift; sourceTree = SOURCE_ROOT; }; BA0E00012FCB400000000001 /* BriefingNotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BriefingNotificationStateTests.swift; sourceTree = ""; }; + C0DE70C00000000000000001 /* CodexTokenUsageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CodexTokenUsageTests.swift; sourceTree = ""; }; C71A00000000000000000002 /* ChatHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHistoryTests.swift; sourceTree = ""; }; C9B0483FE5385C1F0E1CFE15 /* ClaudeLoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeLoginTests.swift; sourceTree = ""; }; CA5E00012FCB400000000001 /* CacheStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheStorageTests.swift; sourceTree = ""; }; @@ -217,7 +220,6 @@ FBE6CEA01FE935F6710EB5B0 /* ScheduledTaskStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTaskStateTests.swift; sourceTree = ""; }; FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanModeHookSuppressionTests.swift; sourceTree = ""; }; FE0A11BB22CC33DD44EE5602 /* TaskBoardHookTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskBoardHookTests.swift; sourceTree = ""; }; - 82AB0A9F0F420072B6D65917 /* TaskRunQueueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskRunQueueTests.swift; sourceTree = ""; }; FE0A11BB22CC33DD44EE5702 /* MarkdownDescriptionEditorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownDescriptionEditorTests.swift; sourceTree = ""; }; FE0A11BB22CC33DD44EE5902 /* TaskCreationModeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskCreationModeTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -436,6 +438,7 @@ DFA9E5040000000000000001 /* AgentEventBridgeTests.swift */, DFA9E5060000000000000001 /* AppStateSteeringTests.swift */, DFA9E5080000000000000001 /* ReasoningLevelTests.swift */, + C0DE70C00000000000000001 /* CodexTokenUsageTests.swift */, DFA9E50A0000000000000001 /* EffortSanitizationTests.swift */, 5C3333332FCB400000000007 /* ThreadStoreReaderTests.swift */, 5C4444442FCE100000000001 /* GeneratedTextSanitizerTests.swift */, @@ -936,6 +939,7 @@ DFA9E5050000000000000001 /* AgentEventBridgeTests.swift in Sources */, DFA9E5070000000000000001 /* AppStateSteeringTests.swift in Sources */, DFA9E5090000000000000001 /* ReasoningLevelTests.swift in Sources */, + C0DE70C00000000000000002 /* CodexTokenUsageTests.swift in Sources */, DFA9E50B0000000000000001 /* EffortSanitizationTests.swift in Sources */, 5C3333332FCB400000000008 /* ThreadStoreReaderTests.swift in Sources */, 5C4444442FCE100000000002 /* GeneratedTextSanitizerTests.swift in Sources */, diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 49d56c4c..23b786b4 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -34,6 +34,10 @@ extension AppState { } var sessionKey = internalSessionKey + // Measures how much of the provider's usage limits each finished + // span on this stream consumed. + var rateLimitMeasurement = beginRateLimitMeasurement(for: agentProvider) + defer { endRateLimitMeasurement(rateLimitMeasurement) } // Resolve per-backend send-request fields (MCP injection, ACP client // spec, model split, background context) concurrently before dispatching @@ -150,6 +154,15 @@ extension AppState { model: usageModel(sessionKey), projectId: projectId ) + recordRateLimitTaskCost( + &rateLimitMeasurement, + model: usageModel(sessionKey), + projectId: projectId, + threadId: sessionKey, + prompt: prompt, + tokens: Self.rateLimitTaskTokens(resultEvent.usage), + startedAt: usageSpanStart + ) usageSpanStart = Date() await finalizeAgentStream(agentProvider: agentProvider, streamId: streamId) if sessionKey != resultEvent.sessionId, @@ -501,6 +514,15 @@ extension AppState { model: usageModel(sessionKey), projectId: projectId ) + recordRateLimitTaskCost( + &rateLimitMeasurement, + model: usageModel(sessionKey), + projectId: projectId, + threadId: sessionKey, + prompt: prompt, + tokens: Self.rateLimitTaskTokens(resultEvent.usage), + startedAt: usageSpanStart + ) usageSpanStart = Date() // Recent Claude Code runs long tasks (background shells, subagents) as diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index 89c4f12d..ae2325b0 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -313,6 +313,7 @@ extension AppState { await self?.refreshRateLimitUsage() await self?.refreshCodexRateLimitUsage() } + startRateLimitSampling() // Recurring probe so disconnected MCP servers surface promptly even // when the user isn't actively interacting with the Settings tab. @@ -673,6 +674,10 @@ extension AppState { bridge.agentProvider = provider bridge.canSteer = self.canSteer(in: window) bridge.modelDisplayName = modelDisplayName(for: currentModel, provider: provider, in: window) + let advice = rateLimitAdvice(for: provider, model: currentModel) + if bridge.rateLimitAdvice != advice { + bridge.rateLimitAdvice = advice + } bridge.sessionStats = ChatSessionStats( costUsd: state.costUsd, inputTokens: state.inputTokens, diff --git a/RxCode/App/AppState+PullRequest.swift b/RxCode/App/AppState+PullRequest.swift index 4907b84e..a8bb5061 100644 --- a/RxCode/App/AppState+PullRequest.swift +++ b/RxCode/App/AppState+PullRequest.swift @@ -59,7 +59,8 @@ extension AppState { let (title, body) = await generateValidatedPullRequestContent( briefing: briefing, branch: branch, - model: model ?? rememberedPullRequestModel + model: model ?? rememberedPullRequestModel, + cwd: project.path ) // 3. Open the PR via autopilot. @@ -100,10 +101,10 @@ extension AppState { // MARK: - PR model selection - /// Model sections offered by the "Create with Model" menu. ACP clients are - /// left out because they have no one-shot generation path. + /// Model sections offered by the "Create with Model" menu: Claude Code, + /// Codex, and every enabled ACP client. func pullRequestModelSections() -> [(id: String, title: String, provider: AgentProvider, iconURL: String?, models: [AgentModel])] { - availableAgentModelSections().filter { $0.provider != .acp } + availableAgentModelSections() } /// The model last picked for PR generation, or `nil` when the user hasn't @@ -126,8 +127,8 @@ extension AppState { /// Generate raw PR text (title on the first line, blank line, then a markdown /// body) from a branch briefing. Uses `model` when given; otherwise routes /// through the configured `summarizationProvider`, mirroring - /// `generateCommitMessage`. - func generatePullRequestContent(briefing: String, branch: String, model: AgentModel? = nil) async -> String? { + /// `generateCommitMessage`. `cwd` is the working directory for ACP clients. + func generatePullRequestContent(briefing: String, branch: String, model: AgentModel? = nil, cwd: String? = nil) async -> String? { if let model { switch model.provider { case .claudeCode: @@ -136,7 +137,18 @@ extension AppState { let prompt = OpenAISummarizationService.pullRequestPrompt(briefing: briefing, branch: branch) return await codex.generateCodexPlainSummary(prompt: prompt, model: model.id) case .acp: - break // ACP has no one-shot; fall through to the summarization settings. + if let parts = acpSelectionParts(for: model.id), + let spec = acpClients.first(where: { $0.id == parts.clientId && $0.enabled }) { + let prompt = OpenAISummarizationService.pullRequestPrompt(briefing: briefing, branch: branch) + return await acp.generatePlainResponse( + prompt: prompt, + model: parts.model.isEmpty ? nil : parts.model, + spec: spec, + cwd: cwd ?? FileManager.default.homeDirectoryForCurrentUser.path + ) + } + // Client disabled or removed; fall through to the summarization settings. + logger.warning("ACP client for PR generation is unavailable; using summarization settings") } } switch summarizationProvider { @@ -182,11 +194,12 @@ extension AppState { briefing: String, branch: String, model: AgentModel? = nil, + cwd: String? = nil, maxAttempts: Int = 3 ) async -> (title: String, body: String) { var lastBody = "" for attempt in 1...maxAttempts { - let raw = await generatePullRequestContent(briefing: briefing, branch: branch, model: model) + let raw = await generatePullRequestContent(briefing: briefing, branch: branch, model: model, cwd: cwd) let (title, body) = Self.parsePullRequestContent(raw, branch: branch) if Self.isConventionalCommitTitle(title) { return (title, body) diff --git a/RxCode/App/AppState+RateLimitHistory.swift b/RxCode/App/AppState+RateLimitHistory.swift new file mode 100644 index 00000000..cc8b1084 --- /dev/null +++ b/RxCode/App/AppState+RateLimitHistory.swift @@ -0,0 +1,224 @@ +import Foundation +import RxCodeCore + +/// Usage-limit history and per-task limit cost tracking for Claude Code and +/// Codex, read by the briefing page's usage-limit statistics. +extension AppState { + /// How often limits are polled in the background so the history chart has + /// points even while the user isn't looking at a usage surface. + static let rateLimitSamplingInterval: UInt64 = 10 * 60 * 1_000_000_000 + /// Wait after a task finishes before reading limits again, so the + /// provider's usage counters have caught up with the task's last requests. + static let rateLimitSettleDelay: UInt64 = 5 * 1_000_000_000 + + /// Providers that report 5-hour / 7-day usage limits. + static let rateLimitedProviders: [AgentProvider] = [.claudeCode, .codex] + + /// In-flight cost measurement for one agent stream. + struct RateLimitMeasurement { + let provider: AgentProvider + /// Limit reading taken before the span being measured. + var baseline: Task + /// Most other runs on the same provider seen during the span. + var concurrentRuns: Int + } + + /// Persists a fresh limit reading into the history chart's samples. + func recordRateLimitSample(_ usage: RateLimitUsage, for provider: AgentProvider) { + guard Self.rateLimitedProviders.contains(provider) else { return } + if threadStore.recordRateLimitSample(usage, providerRaw: provider.rawValue) { + rateLimitHistoryRevision += 1 + } + refreshRateLimitProviderStats() + } + + /// Starts the background poll that keeps the usage-limit history filled. + func startRateLimitSampling() { + guard rateLimitSamplingTask == nil else { return } + refreshRateLimitProviderStats() + rateLimitSamplingTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: AppState.rateLimitSamplingInterval) + guard !Task.isCancelled else { return } + await self?.refreshRateLimitUsage(forceRefresh: true) + await self?.refreshCodexRateLimitUsage(forceRefresh: true) + } + } + } + + // MARK: - Per-task cost + + /// Takes the "before" limit reading for a stream. Returns nil for + /// providers without usage limits. Pair with `endRateLimitMeasurement`. + func beginRateLimitMeasurement(for provider: AgentProvider) -> RateLimitMeasurement? { + guard !AppSupport.isTestProcess, Self.rateLimitedProviders.contains(provider) else { return nil } + if provider == .codex, !codexInstalled { return nil } + let active = (activeRateLimitRuns[provider] ?? 0) + 1 + activeRateLimitRuns[provider] = active + let baseline = Task { [weak self] in + await self?.rateLimitUsage(for: provider, forceRefresh: true) + } + return RateLimitMeasurement(provider: provider, baseline: baseline, concurrentRuns: active - 1) + } + + func endRateLimitMeasurement(_ measurement: RateLimitMeasurement?) { + guard let provider = measurement?.provider else { return } + activeRateLimitRuns[provider] = max(0, (activeRateLimitRuns[provider] ?? 0) - 1) + } + + /// Records the limit cost of the span that just ended with a `result`, and + /// makes the "after" reading the baseline for the next span on the stream. + func recordRateLimitTaskCost( + _ measurement: inout RateLimitMeasurement?, + model: String?, + projectId: UUID, + threadId: String, + prompt: String, + tokens: Int, + startedAt: Date, + endedAt: Date = .now + ) { + guard var current = measurement else { return } + let provider = current.provider + let othersNow = max(0, (activeRateLimitRuns[provider] ?? 1) - 1) + let concurrentRuns = max(current.concurrentRuns, othersNow) + let baseline = current.baseline + let fallbackTitle = Self.rateLimitTaskTitle(fromPrompt: prompt) + + let after = Task { [weak self] in + let before = await baseline.value + try? await Task.sleep(nanoseconds: AppState.rateLimitSettleDelay) + guard let self else { return nil } + let after = await self.rateLimitUsage(for: provider, forceRefresh: true) + guard let before, let after else { return after } + let delta = RateLimitDelta.between(before, after) + let storedTitle = self.threadStore.fetch(id: threadId)?.title + .trimmingCharacters(in: .whitespacesAndNewlines) + let title = storedTitle.flatMap { $0.isEmpty ? nil : $0 } ?? fallbackTitle + self.threadStore.recordRateLimitTaskCost(RateLimitTaskCost( + providerRaw: provider.rawValue, + model: model, + projectId: projectId, + threadId: threadId, + threadTitle: title, + startedAt: startedAt, + endedAt: endedAt, + fiveHourDelta: delta.fiveHour, + sevenDayDelta: delta.sevenDay, + concurrentRuns: concurrentRuns, + tokens: tokens + )) + self.rateLimitHistoryRevision += 1 + self.refreshRateLimitProviderStats() + return after + } + + current.baseline = after + current.concurrentRuns = othersNow + measurement = current + } + + /// Uncached tokens a `result` accounts for; cache reads barely count + /// toward usage limits, so they're left out. + static func rateLimitTaskTokens(_ usage: UsageInfo?) -> Int { + guard let usage else { return 0 } + return usage.inputTokens + usage.outputTokens + usage.cacheCreationInputTokens + } + + /// First line of the prompt, shortened, for tasks whose thread has no title. + static func rateLimitTaskTitle(fromPrompt prompt: String) -> String { + let line = prompt + .split(whereSeparator: \.isNewline) + .first + .map { $0.trimmingCharacters(in: .whitespaces) } ?? "" + guard !line.isEmpty else { return "Untitled task" } + return line.count > 80 ? String(line.prefix(79)) + "…" : line + } + + // MARK: - Per-model estimates and advice + + /// Window of task costs behind the per-model estimates and advice. + static let rateLimitAdviceRange: UsageStatsRange = .week + + /// Recomputes `rateLimitProviderStats` off the main actor from the stored + /// task costs and the latest limits. Called whenever either changes. + func refreshRateLimitProviderStats() { + let since = Self.rateLimitAdviceRange.cutoff(now: .now) + let inputs = Self.rateLimitedProviders.map { provider in + ( + provider: provider, + costs: threadStore.rateLimitTaskCosts(providerRaw: provider.rawValue, since: since), + usage: cachedRateLimitUsage(for: provider) + ) + } + rateLimitProviderStatsTask?.cancel() + rateLimitProviderStatsTask = Task { [weak self] in + let stats = await Task.detached(priority: .utility) { + var result: [AgentProvider: RateLimitTaskCostSummary] = [:] + for input in inputs { + result[input.provider] = RateLimitTaskCostSummary.aggregate(input.costs, current: input.usage) + } + return result + }.value + guard !Task.isCancelled, let self else { return } + if self.rateLimitProviderStats != stats { + self.rateLimitProviderStats = stats + } + } + } + + /// Advice for running `model` on `provider` next, from the background + /// stats; nil when its limits need no attention. + func rateLimitAdvice(for provider: AgentProvider, model: String?) -> RateLimitAdvice? { + guard Self.rateLimitedProviders.contains(provider) else { return nil } + let contexts = Self.rateLimitedProviders + .filter { $0 != .codex || codexInstalled } + .map { candidate in + RateLimitAdvisor.ProviderContext( + providerRaw: candidate.rawValue, + displayName: candidate.displayNameText, + usage: cachedRateLimitUsage(for: candidate), + summary: rateLimitProviderStats[candidate] ?? RateLimitTaskCostSummary() + ) + } + guard let context = contexts.first(where: { $0.providerRaw == provider.rawValue }) else { return nil } + return RateLimitAdvisor.advise( + context: context, + model: model, + alternatives: contexts, + modelDisplayName: { [weak self] id in + self?.usageModelDisplayName(id, provider: provider) ?? id + } + ) + } + + // MARK: - Reads + + func rateLimitSamples(for provider: AgentProvider, range: UsageStatsRange, now: Date = .now) -> [RateLimitSamplePoint] { + threadStore.rateLimitSamples(providerRaw: provider.rawValue, since: range.cutoff(now: now)) + } + + /// Limit used per million tokens for each of the last `weeks` calendar + /// weeks, oldest first. Independent of the briefing window so the trend + /// always spans several weeks. + func rateLimitWeeklyCosts(for provider: AgentProvider, weeks: Int = 12, now: Date = .now) -> [RateLimitWeeklyCost] { + let calendar = Calendar.current + let thisWeek = calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now + let since = calendar.date(byAdding: .weekOfYear, value: -(weeks - 1), to: thisWeek) ?? thisWeek + return RateLimitWeeklyCost.weekly( + threadStore.rateLimitTaskCosts(providerRaw: provider.rawValue, since: since), + calendar: calendar + ) + } + + func rateLimitTaskCostSummary( + for provider: AgentProvider, + range: UsageStatsRange, + now: Date = .now + ) -> RateLimitTaskCostSummary { + RateLimitTaskCostSummary.aggregate( + threadStore.rateLimitTaskCosts(providerRaw: provider.rawValue, since: range.cutoff(now: now)), + current: cachedRateLimitUsage(for: provider) + ) + } +} diff --git a/RxCode/App/AppState+RateLimitUsage.swift b/RxCode/App/AppState+RateLimitUsage.swift index 9b7c100b..ec2b50e4 100644 --- a/RxCode/App/AppState+RateLimitUsage.swift +++ b/RxCode/App/AppState+RateLimitUsage.swift @@ -58,6 +58,7 @@ extension AppState { case .acp: break } + recordRateLimitSample(usage, for: provider) // Refresh the mobile home-screen widget's usage figures. MobileSyncService.shared.pushWidgetUpdate() } diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 0b5ea799..e560be5e 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -428,6 +428,9 @@ final class AppState { /// Bumped whenever a finished turn is folded into the persisted usage /// buckets, so the briefing statistics panel re-reads its summary. var usageStatsRevision = 0 + /// Bumped when a usage-limit sample or task cost is persisted, so the + /// briefing usage-limit panel re-reads its history. + var rateLimitHistoryRevision = 0 /// Agent-written document briefings from `briefingStore`, newest first. /// Loaded lazily by the briefing tab via `reloadBriefingDocuments()`. var briefingDocuments: [BriefingDocument] = [] @@ -646,6 +649,13 @@ final class AppState { var latestRateLimitUsage: RateLimitUsage? var latestCodexRateLimitUsage: RateLimitUsage? @ObservationIgnored var rateLimitUsageRefreshTasks: [AgentProvider: Task] = [:] + @ObservationIgnored var rateLimitSamplingTask: Task? + /// Agent streams currently measuring their usage-limit cost, per provider. + @ObservationIgnored var activeRateLimitRuns: [AgentProvider: Int] = [:] + /// Per-provider task costs and per-model estimates over the last week, + /// computed in the background. Drives rate-limit advice in the status line. + var rateLimitProviderStats: [AgentProvider: RateLimitTaskCostSummary] = [:] + @ObservationIgnored var rateLimitProviderStatsTask: Task? /// Sessions currently streaming, anywhere across all windows. /// Reads `sessionActivity` (not `sessionStates`) so the menu bar label doesn't diff --git a/RxCode/App/MenuBarViews.swift b/RxCode/App/MenuBarViews.swift index 83e6c54a..edeecf22 100644 --- a/RxCode/App/MenuBarViews.swift +++ b/RxCode/App/MenuBarViews.swift @@ -162,12 +162,14 @@ struct MenuBarContentView: View { agentPicker VStack(alignment: .leading, spacing: 12) { - MenuBarUsageBar( - label: "5-hour limit", - percent: selectedUsage?.fiveHourPercent, - resetsAt: selectedUsage?.fiveHourResetsAt, - emptyText: emptyUsageText - ) + if selectedUsage?.hasFiveHourLimit ?? true { + MenuBarUsageBar( + label: "5-hour limit", + percent: selectedUsage?.fiveHourPercent, + resetsAt: selectedUsage?.fiveHourResetsAt, + emptyText: emptyUsageText + ) + } MenuBarUsageBar( label: secondaryLimitLabel, diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index fab33057..9b72ee9e 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -54,6 +54,17 @@ struct ChatWindowValue: Codable, Hashable { var workspaceID: String? } +// MARK: - GeneralRouteWindowValue + +/// Identifies a detached General-route window (Projects, Briefing, Scheduled) +/// opened from the sidebar row's context menu. `instanceId` lets the user open +/// several independent windows for the same route. +struct GeneralRouteWindowValue: Codable, Hashable { + let route: GeneralRoute + let instanceId: UUID + var workspaceID: String? +} + // MARK: - TerminalWindowValue struct TerminalWindowValue: Codable, Hashable { @@ -152,6 +163,21 @@ struct RxCodeApp: App { } .defaultSize(width: 800, height: 700) + // Detached General-route window — opened from the sidebar Projects, + // Briefing, and Scheduled rows' context menus. + WindowGroup(id: "route-window", for: GeneralRouteWindowValue.self) { $value in + if let value { + GeneralRouteWindowRoot( + workspaceManager: workspaceManager, + workspaceID: value.workspaceID ?? workspaceManager.frontmostWorkspaceID, + route: value.route + ) + .focusable(false) + .modifier(CacheStoragePresenter(workspaceManager: workspaceManager)) + } + } + .defaultSize(width: 1000, height: 700) + // Detached terminal window — opened from the toolbar. WindowGroup(id: "terminal-window", for: TerminalWindowValue.self) { $value in TerminalWindowRoot(path: value?.path ?? "") @@ -215,25 +241,7 @@ struct MainWindowRoot: View { .environment(workspaceManager) .environment(windowState) .environment(chatBridge) - .environment(\.openURL, OpenURLAction { url in - if let docs = DocsDeepLink.parse(url), docs.action == .setup { - appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) - return .handled - } - if let release = ReleaseDeepLink.parse(url), release.action == .setup { - appState.releaseSetupRequest = ReleaseSetupRequest(repoFullName: release.repoFullName) - return .handled - } - if let request = SecretsDeepLink.parse(url) { - appState.secretsSetupRequest = request - return .handled - } - if let request = CIUpdateDeepLink.parse(url) { - appState.ciSetupRequest = request - return .handled - } - return openMarkdownLink(url, in: windowState) - }) + .environment(\.openURL, workspaceOpenURLAction(appState: appState, windowState: windowState)) .transition(.opacity) } else { LoadingView() @@ -270,6 +278,31 @@ struct MainWindowRoot: View { } } +/// `openURL` handler shared by full workspace windows: routes RxCode setup +/// deep links to the matching sheet and everything else to `openMarkdownLink`. +@MainActor +func workspaceOpenURLAction(appState: AppState, windowState: WindowState) -> OpenURLAction { + OpenURLAction { url in + if let docs = DocsDeepLink.parse(url), docs.action == .setup { + appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) + return .handled + } + if let release = ReleaseDeepLink.parse(url), release.action == .setup { + appState.releaseSetupRequest = ReleaseSetupRequest(repoFullName: release.repoFullName) + return .handled + } + if let request = SecretsDeepLink.parse(url) { + appState.secretsSetupRequest = request + return .handled + } + if let request = CIUpdateDeepLink.parse(url) { + appState.ciSetupRequest = request + return .handled + } + return openMarkdownLink(url, in: windowState) + } +} + @MainActor private func openMarkdownLink(_ url: URL, in windowState: WindowState) -> OpenURLAction.Result { if let fileLink = LocalFileLink.parse(url) { @@ -378,25 +411,7 @@ struct ProjectWindowRoot: View { .environment(workspaceManager) .environment(windowState) .environment(chatBridge) - .environment(\.openURL, OpenURLAction { url in - if let docs = DocsDeepLink.parse(url), docs.action == .setup { - appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) - return .handled - } - if let release = ReleaseDeepLink.parse(url), release.action == .setup { - appState.releaseSetupRequest = ReleaseSetupRequest(repoFullName: release.repoFullName) - return .handled - } - if let request = SecretsDeepLink.parse(url) { - appState.secretsSetupRequest = request - return .handled - } - if let request = CIUpdateDeepLink.parse(url) { - appState.ciSetupRequest = request - return .handled - } - return openMarkdownLink(url, in: windowState) - }) + .environment(\.openURL, workspaceOpenURLAction(appState: appState, windowState: windowState)) .transition(.opacity) } else { LoadingView() diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 2ba696a0..ec481813 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -17,6 +17,17 @@ } } }, + " :" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : ":" + } + } + } + }, " (copy)" : { "extractionState" : "stale", "localizations" : { @@ -66,15 +77,8 @@ } } }, - " :" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : ":" - } - } - } + ":" : { + }, ".env" : { "localizations" : { @@ -288,6 +292,76 @@ } } }, + "%@ · %@ of agent work" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · 智能体工作 %2$@" + } + } + } + }, + "%@ · %@ of turns" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · 占 %2$@ 轮次" + } + } + } + }, + "%@ · %@ tokens" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@ 个 token" + } + } + } + }, + "%@ · change in each limit between a task's start and finish." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · 任务开始到结束期间各限额的变化。" + } + } + } + }, + "%@ · share of %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · 占 %2$@ 的比例" + } + } + } + }, + "%@ · share of each limit used. Solid lines are 5-hour limits, dashed lines 7-day limits." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · 各限额已用比例。实线为 5 小时限额,虚线为 7 天限额。" + } + } + } + }, + "%@ · tasks of each model's average cost that fit in what's left of each limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · 按各模型平均消耗,剩余限额还能容纳的任务数。" + } + } + } + }, "%@ (%@)" : { "extractionState" : "stale", "localizations" : { @@ -315,6 +389,23 @@ } } }, + "%@ %@ · %@" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@ · %3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ · %3$@" + } + } + } + }, "%@ actions" : { "localizations" : { "zh-Hans" : { @@ -325,6 +416,16 @@ } } }, + "%@ cache" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缓存 %@" + } + } + } + }, "%@ copy" : { "localizations" : { "zh-Hans" : { @@ -335,6 +436,16 @@ } } }, + "%@ in · %@ out" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入 %1$@ · 输出 %2$@" + } + } + } + }, "%@ installed%@" : { "extractionState" : "stale", "localizations" : { @@ -407,6 +518,17 @@ } } }, + "%@ of 5h per 1M tokens" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每 100 万 token 消耗 5 小时限额的 %@" + } + } + } + }, "%@ Usage" : { "localizations" : { "ko" : { @@ -812,6 +934,16 @@ } } }, + "%lld more" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "另外 %lld 项" + } + } + } + }, "%lld of %lld" : { "localizations" : { "en" : { @@ -866,6 +998,16 @@ } } }, + "%lld of %lld repos" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld / %lld 个仓库" + } + } + } + }, "%lld of %lld running" : { "localizations" : { "en" : { @@ -956,6 +1098,16 @@ } } }, + "%lld repos" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个仓库" + } + } + } + }, "%lld tasks" : { "localizations" : { "zh-Hans" : { @@ -966,6 +1118,26 @@ } } }, + "%lld tasks measured" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已测量 %lld 个任务" + } + } + } + }, + "%lld turns" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 轮" + } + } + } + }, "%lld workflows · %lld actions · %lld outdated" : { "localizations" : { "en" : { @@ -1058,6 +1230,22 @@ } } }, + "• %@ — %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "• %1$@ — %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "• %1$@ — %2$@" + } + } + } + }, "• last seen %@" : { "localizations" : { "ko" : { @@ -1186,6 +1374,26 @@ } } }, + "~%lld tasks" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "约 %lld 个任务" + } + } + } + }, + "~1 task" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "约 1 个任务" + } + } + } + }, "−%lld" : { "localizations" : { "ko" : { @@ -1232,140 +1440,361 @@ } } }, - "5h" : { + "1 project" : { "localizations" : { - "ko" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "5시간" + "value" : "1 个项目" } - }, + } + } + }, + "1 task" : { + "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "5 小时" + "value" : "1 个任务" } } } }, - "A column that triggers a chat needs an “On session stop” column that doesn't trigger a chat itself." : { + "1 task measured" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "触发对话的列需要对应一个“会话停止时”列,且该列本身不能触发对话。" + "value" : "已测量 1 个任务" } } } }, - "A workflow failed" : { - "comment" : "CI failure notification body when the workflow name is unknown.", + "1 turn" : { "localizations" : { - "ko" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "워크플로 실패" + "value" : "1 轮" } - }, + } + } + }, + "5-hour · 7-day used" : { + "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "工作流失败" + "value" : "5 小时 · 7 天已用" } } } }, - "Absolute or project-relative path. Leave empty to use the project root." : { + "5-hour limit" : { "localizations" : { - "ko" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "절대 경로 또는 프로젝트 기준 상대 경로. 비워 두면 프로젝트 루트를 사용합니다." + "value" : "5 小时限额" } - }, + } + } + }, + "5-hour resets %@" : { + "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "绝对路径或项目相对路径。留空则使用项目根目录。" + "value" : "5 小时限额%@重置" } } } }, - "Accept" : { + "5h" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "수락" + "value" : "5시간" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "接受" + "value" : "5 小时" } } } }, - "Account" : { + "5h · 7d" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "账户" + "value" : "5 小时 · 7 天" } } } }, - "Account email" : { + "5h / task" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "账户邮箱" + "value" : "5 小时 / 任务" } } } }, - "ACP Clients" : { + "5h %@ · 7d %@" : { "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "ACP 클라이언트" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "ACP 客户端" + "value" : "5 小时 %1$@ · 7 天 %2$@" } } } }, - "ACP error" : { - "extractionState" : "stale", + "5h most %@" : { "localizations" : { - "ko" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "ACP 오류" + "value" : "5 小时最高 %@" } - }, + } + } + }, + "5h most %@ · least %@" : { + "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "ACP 错误" + "value" : "5 小时最高 %1$@ · 最低 %2$@" } } } }, - "ACP page" : { + "5h used" : { "localizations" : { - "ko" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "ACP 페이지" + "value" : "5 小时已用" + } + } + } + }, + "7-day limit" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天限额" + } + } + } + }, + "7-day only" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅 7 天限额" + } + } + } + }, + "7-day resets %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天限额%@重置" + } + } + } + }, + "7d" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天" + } + } + } + }, + "7d / task" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天 / 任务" + } + } + } + }, + "7d %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天 %@" + } + } + } + }, + "7d most %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天最高 %@" + } + } + } + }, + "7d most %@ · least %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天最高 %1$@ · 最低 %2$@" + } + } + } + }, + "7d used" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天已用" + } + } + } + }, + "A column that triggers a chat needs an “On session stop” column that doesn't trigger a chat itself." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "触发对话的列需要对应一个“会话停止时”列,且该列本身不能触发对话。" + } + } + } + }, + "A workflow failed" : { + "comment" : "CI failure notification body when the workflow name is unknown.", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크플로 실패" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作流失败" + } + } + } + }, + "Absolute or project-relative path. Leave empty to use the project root." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "절대 경로 또는 프로젝트 기준 상대 경로. 비워 두면 프로젝트 루트를 사용합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "绝对路径或项目相对路径。留空则使用项目根目录。" + } + } + } + }, + "Accept" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수락" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "接受" + } + } + } + }, + "Account" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "账户" + } + } + } + }, + "Account email" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "账户邮箱" + } + } + } + }, + "ACP Clients" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 클라이언트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 客户端" + } + } + } + }, + "ACP error" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 오류" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 错误" + } + } + } + }, + "ACP page" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 페이지" } }, "zh-Hans" : { @@ -1446,6 +1875,16 @@ } } }, + "Add a branch name after the prefix." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请在此前缀后添加分支名称。" + } + } + } + }, "Add a Code Review hook and a second agent reviews every change before you ship it." : { "localizations" : { "ko" : { @@ -2506,6 +2945,16 @@ } } }, + "All" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部" + } + } + } + }, "All archived chats in the current project will be deleted. This action cannot be undone." : { "localizations" : { "ko" : { @@ -2554,6 +3003,16 @@ } } }, + "All briefings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有简报" + } + } + } + }, "All Chats" : { "localizations" : { "ko" : { @@ -3630,60 +4089,113 @@ } } }, - "Awaiting check" : { + "Average of 5-hour · 7-day per task" : { "localizations" : { - "ko" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "확인 대기 중" + "value" : "每个任务平均消耗 5 小时 · 7 天限额" } - }, + } + } + }, + "Average share of the 5-hour limit one task of this model uses. Plans without a 5-hour limit show 7d / task instead." : { + "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "等待检查" + "value" : "该模型单个任务平均占用 5 小时限额的比例。没有 5 小时限额的套餐改为显示“7 天 / 任务”。" } } } }, - "Back" : { + "Average share of the 5-hour limit one task uses." : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Back" - } - }, - "ko" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "뒤로" + "value" : "单个任务平均占用 5 小时限额的比例。" } - }, + } + } + }, + "Average share of the 7-day limit one task uses." : { + "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "返回" + "value" : "单个任务平均占用 7 天限额的比例。" } } } }, - "Back to Draft" : { + "Avg %@ per turn" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "返回草稿" + "value" : "平均每轮 %@" } } } }, - "Back up this project's secrets to Autopilot" : { + "Awaiting check" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 프로젝트의 시크릿을 Autopilot에 백업" + "value" : "확인 대기 중" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待检查" + } + } + } + }, + "Back" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Back" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "뒤로" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "返回" + } + } + } + }, + "Back to %@" : { + + }, + "Back to Draft" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "返回草稿" + } + } + } + }, + "Back up this project's secrets to Autopilot" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로젝트의 시크릿을 Autopilot에 백업" } }, "zh-Hans" : { @@ -3694,6 +4206,46 @@ } } }, + "Based on %lld tasks" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基于 %lld 个任务" + } + } + } + }, + "Based on %lld tasks run alone" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基于 %lld 个单独运行的任务" + } + } + } + }, + "Based on 1 task" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基于 1 个任务" + } + } + } + }, + "Based on 1 task run alone" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基于 1 个单独运行的任务" + } + } + } + }, "Bash" : { "localizations" : { "ko" : { @@ -3835,6 +4387,16 @@ } } }, + "Branch + checkout" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建并切换分支" + } + } + } + }, "Branch name" : { "localizations" : { "ko" : { @@ -3851,6 +4413,26 @@ } } }, + "Branch name cannot end with “/”." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分支名称不能以“/”结尾。" + } + } + } + }, + "Branch name is required." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "必须填写分支名称。" + } + } + } + }, "Branch or Ref" : { "localizations" : { "ko" : { @@ -4081,6 +4663,56 @@ } } }, + "By model" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按模型" + } + } + } + }, + "By provider" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按提供方" + } + } + } + }, + "By type" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按类型" + } + } + } + }, + "Cache read" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缓存读取" + } + } + } + }, + "Cache write" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缓存写入" + } + } + } + }, "Call an external API — define the method, URL, headers, and body once, with {{placeholders}} for project and branch context." : { "localizations" : { "ko" : { @@ -4424,6 +5056,7 @@ } }, "Choose the time window for usage statistics." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4459,6 +5092,16 @@ } } }, + "Choose which providers and limits to show." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择要显示的提供方和限额。" + } + } + } + }, "Choose which RxCode tools are available to other local MCP clients." : { "localizations" : { "zh-Hans" : { @@ -5639,7 +6282,7 @@ } } }, - "Connect GitHub to\nimport repos instantly": { + "Connect GitHub to\nimport repos instantly" : { "extractionState" : "stale", "localizations" : { "en" : { @@ -6186,6 +6829,16 @@ } } }, + "Counts input and output tokens, including cache writes. Cache reads are left out because they barely count toward limits." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计输入和输出 token(含缓存写入)。缓存读取几乎不占用限额,因此不计入。" + } + } + } + }, "Create" : { "localizations" : { "ko" : { @@ -6324,6 +6977,9 @@ } } } + }, + "Create Now (%@)" : { + }, "Create PR" : { "localizations" : { @@ -6521,6 +7177,9 @@ } } } + }, + "Create with Model" : { + }, "Create Workspace" : { "localizations" : { @@ -6612,6 +7271,26 @@ } } }, + "Creates an isolated worktree so this chat works without touching the main checkout." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建独立的工作树,让此聊天可以运行而不影响主工作树。" + } + } + } + }, + "Creates the branch and checks it out in the project root, changing the main checkout." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在项目根目录创建分支并切换到该分支,这会更改主工作树。" + } + } + } + }, "Creating Pull Request…" : { "extractionState" : "stale", "localizations" : { @@ -8043,6 +8722,16 @@ } } }, + "Docs" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档" + } + } + } + }, "Document" : { "localizations" : { "ko" : { @@ -8108,6 +8797,16 @@ } } }, + "Documents" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档" + } + } + } + }, "Documents (%lld)" : { "localizations" : { "ko" : { @@ -9201,6 +9900,26 @@ } } }, + "Estimates appear after tasks finish" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "任务完成后显示估算" + } + } + } + }, + "Estimates by model" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按模型估算" + } + } + } + }, "Every" : { "localizations" : { "zh-Hans" : { @@ -9618,6 +10337,16 @@ } } }, + "Failing" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "失败" + } + } + } + }, "Fetch" : { "localizations" : { "ko" : { @@ -9767,6 +10496,16 @@ } } }, + "Filter" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "筛选" + } + } + } + }, "Filter by keyword" : { "localizations" : { "zh-Hans" : { @@ -10139,24 +10878,44 @@ } } }, - "Form" : { + "Forced off for this project" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "表单" + "value" : "此项目强制关闭" } } } }, - "Format the JSON body." : { + "Forced on for this project" : { "localizations" : { - "ko" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "JSON 본문의 형식을 지정합니다." + "value" : "此项目强制开启" } - }, + } + } + }, + "Form" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表单" + } + } + } + }, + "Format the JSON body." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 본문의 형식을 지정합니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10523,6 +11282,26 @@ } } }, + "Global default · Off" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全局默认 · 关闭" + } + } + } + }, + "Global default · On" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全局默认 · 开启" + } + } + } + }, "Global default plus per-project override" : { "localizations" : { "ko" : { @@ -10765,6 +11544,26 @@ } } }, + "High" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高" + } + } + } + }, + "Highest tier" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最高档" + } + } + } + }, "History" : { "localizations" : { "en" : { @@ -10963,6 +11762,16 @@ } } }, + "Hover" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "悬停" + } + } + } + }, "Hover to highlight this story's tasks" : { "localizations" : { "zh-Hans" : { @@ -10973,6 +11782,16 @@ } } }, + "How many more tasks of that average cost fit before each limit is reached, based on what's used now." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按当前已用量,在达到各限额前还能运行多少个平均消耗的任务。" + } + } + } + }, "How often autopilot scans this repo's workflows and opens a PR when actions are outdated." : { "localizations" : { "ko" : { @@ -10989,6 +11808,16 @@ } } }, + "How to read this" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "如何解读" + } + } + } + }, "https://example.com/mcp" : { "localizations" : { "ko" : { @@ -11156,6 +11985,26 @@ } } }, + "In this thread" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此线程中" + } + } + } + }, + "In this thread · %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此线程中 · %@" + } + } + } + }, "Inactive chats are moved to the archive automatically. Pinned chats are never auto-archived." : { "localizations" : { "ko" : { @@ -11246,6 +12095,26 @@ } } }, + "Inherits global default (Off)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继承全局默认值(关闭)" + } + } + } + }, + "Inherits global default (On)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继承全局默认值(开启)" + } + } + } + }, "Initialize Git" : { "localizations" : { "ko" : { @@ -11881,6 +12750,56 @@ } } }, + "Last 7 days" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "过去 7 天" + } + } + } + }, + "Last 12 weeks, regardless of the window picked above. A rising line means the same tokens cost more of the limit than before. Solid lines are 5-hour limits, dashed lines 7-day limits." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最近 12 周,不受上方所选时间范围影响。折线上升表示同样的 token 比以前占用更多限额。实线为 5 小时限额,虚线为 7 天限额。" + } + } + } + }, + "Last 24 hours" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "过去 24 小时" + } + } + } + }, + "Last 30 days" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "过去 30 天" + } + } + } + }, + "Last 365 days" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "过去 365 天" + } + } + } + }, "Last run %@" : { "localizations" : { "ko" : { @@ -11979,6 +12898,46 @@ } } }, + "Least" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最低" + } + } + } + }, + "Left in 5h" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时剩余" + } + } + } + }, + "Left in 5h · 7d" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时 · 7 天剩余" + } + } + } + }, + "Left in 7d" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天剩余" + } + } + } + }, "Let the agent decide" : { "localizations" : { "zh-Hans" : { @@ -12021,6 +12980,77 @@ } } }, + "Limit" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "限额" + } + } + } + }, + "Limit cost per million tokens ranks models by tier: larger models use more of the limit for the same tokens. For light tasks, a model with a lower cost usually does the job and leaves more room in the limit." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每百万 token 的限额消耗反映了模型档位:同样的 token 数,更大的模型会占用更多限额。对于轻量任务,消耗更低的模型通常就能胜任,并为限额留出更多余量。" + } + } + } + }, + "Limit cost per task" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每个任务的限额消耗" + } + } + } + }, + "Limit used" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已用限额" + } + } + } + }, + "Limit used per 1M tokens" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每 100 万 token 消耗的限额" + } + } + } + }, + "Limit used per 1M tokens by week" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每周每 100 万 token 消耗的限额" + } + } + } + }, + "Line" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "折线" + } + } + } + }, "Link" : { "localizations" : { "ko" : { @@ -12432,9 +13462,19 @@ } } }, - "main" : { + "Low" : { "localizations" : { - "ko" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "低" + } + } + } + }, + "main" : { + "localizations" : { + "ko" : { "stringUnit" : { "state" : "translated", "value" : "main" @@ -12893,6 +13933,16 @@ } } }, + "Many" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "很多" + } + } + } + }, "Markdown content" : { "localizations" : { "ko" : { @@ -12935,6 +13985,16 @@ } } }, + "Max" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最高" + } + } + } + }, "Maximum concurrent tasks" : { "localizations" : { "zh-Hans" : { @@ -13030,6 +14090,26 @@ } } }, + "median %@ tokens" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "中位数 %@ 个 token" + } + } + } + }, + "Medium" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "中" + } + } + } + }, "Memo" : { "localizations" : { "en" : { @@ -13292,6 +14372,16 @@ } } }, + "Model usage" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模型使用情况" + } + } + } + }, "Model: %@ · %@" : { "localizations" : { "en" : { @@ -13356,6 +14446,36 @@ } } }, + "Most" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最高" + } + } + } + }, + "Most · Least" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最高 · 最低" + } + } + } + }, + "Most used model" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最常用模型" + } + } + } + }, "Move tasks to" : { "localizations" : { "zh-Hans" : { @@ -13997,6 +15117,16 @@ } } }, + "New worktree" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建工作树" + } + } + } + }, "Next" : { "localizations" : { "ko" : { @@ -14077,6 +15207,16 @@ } } }, + "No agent turns yet" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无智能体轮次" + } + } + } + }, "No agents match “%@”." : { "localizations" : { "ko" : { @@ -14527,6 +15667,26 @@ } } }, + "No measured tasks in this window yet." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此时间范围内暂无已测量的任务。" + } + } + } + }, + "No measured tasks yet" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无已测量的任务" + } + } + } + }, "No memories" : { "localizations" : { "ko" : { @@ -14989,6 +16149,16 @@ } } }, + "No status" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无状态" + } + } + } + }, "No stories in the default view." : { "localizations" : { "ko" : { @@ -15251,6 +16421,16 @@ } } }, + "No usage-limit history in this window yet." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此时间范围内暂无用量限额历史。" + } + } + } + }, "No versions on this project yet." : { "localizations" : { "zh-Hans" : { @@ -15374,6 +16554,16 @@ } } }, + "Not enough measured tasks to estimate." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已测量的任务不足,无法估算。" + } + } + } + }, "not found" : { "localizations" : { "ko" : { @@ -15558,6 +16748,33 @@ } } }, + "now %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前 %@" + } + } + } + }, + "now %@ · %@" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "now %1$@ · %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前 %1$@ · %2$@" + } + } + } + }, "npx" : { "localizations" : { "ko" : { @@ -15638,6 +16855,26 @@ } } }, + "One finished task: its chat, model, finish time and how long it ran." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "一个已完成的任务:所属对话、模型、完成时间和运行时长。" + } + } + } + }, + "One provider's limit. Its height is the share of that limit used at that moment; it drops when the limit resets." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "某个提供方的一项限额。高度表示当时该限额的已用比例;限额重置时会回落。" + } + } + } + }, "Online" : { "localizations" : { "ko" : { @@ -16227,6 +17464,16 @@ } } }, + "Output" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输出" + } + } + } + }, "output added to context" : { "extractionState" : "stale", "localizations" : { @@ -16463,6 +17710,16 @@ } } }, + "Passing" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过" + } + } + } + }, "Pause" : { "localizations" : { "ko" : { @@ -16521,6 +17778,16 @@ } } }, + "Per 1M tokens" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每 100 万 token" + } + } + } + }, "Per-project secrets, end-to-end encrypted and easily shared with your team." : { "localizations" : { "ko" : { @@ -16537,6 +17804,26 @@ } } }, + "Percentage points of the 5-hour and 7-day limits the task used, measured from its start to its finish." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "任务从开始到结束所占用的 5 小时和 7 天限额的百分点。" + } + } + } + }, + "Percentage points of this limit the task used." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该任务占用此限额的百分点。" + } + } + } + }, "Permanently delete archived chats after the retention window. Pinned chats are never auto-deleted. This cannot be undone." : { "localizations" : { "ko" : { @@ -16785,6 +18072,16 @@ } } }, + "Plans without a separate 5-hour limit show only their 7-day line." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有单独 5 小时限额的套餐只显示 7 天折线。" + } + } + } + }, "Platform: %@ • %@" : { "localizations" : { "en" : { @@ -16807,6 +18104,16 @@ } } }, + "Point at the chart to read every provider's usage at that time." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将指针移到图表上,可查看该时刻各提供方的用量。" + } + } + } + }, "Port" : { "localizations" : { "zh-Hans" : { @@ -17157,6 +18464,16 @@ } } }, + "Project summaries" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "项目摘要" + } + } + } + }, "Project Unavailable" : { "localizations" : { "zh-Hans" : { @@ -17305,6 +18622,16 @@ } } }, + "Providers" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提供方" + } + } + } + }, "Public" : { "localizations" : { "ko" : { @@ -17483,9 +18810,39 @@ } } }, - "Re-run %@" : { + "ran with %lld other tasks" : { "localizations" : { - "ko" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与其他 %lld 个任务同时运行" + } + } + } + }, + "ran with 1 other task" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与其他 1 个任务同时运行" + } + } + } + }, + "Ran with others" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "同时运行" + } + } + } + }, + "Re-run %@" : { + "localizations" : { + "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@ 다시 실행" @@ -18786,6 +20143,16 @@ } } }, + "Row" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "行" + } + } + } + }, "Run" : { "localizations" : { "zh-Hans" : { @@ -20453,6 +21820,16 @@ } } }, + "Series" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系列" + } + } + } + }, "Server stopped" : { "localizations" : { "zh-Hans" : { @@ -20501,6 +21878,16 @@ } } }, + "Session time" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话时长" + } + } + } + }, "Set as Default View" : { "localizations" : { "ko" : { @@ -20713,6 +22100,9 @@ } } } + }, + "Settings Default (%@)" : { + }, "Setup templates" : { "localizations" : { @@ -20747,6 +22137,16 @@ } } }, + "Share of each limit a million tokens uses. Tokens are input and output tokens, including cache writes; cache reads aren't counted. Larger models cost more for the same tokens; for light tasks a cheaper model usually does the job and leaves more room. 0% means the limit didn't move, which is common for the 7-day limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每 100 万 token 占用各限额的比例。这里的 token 指输入和输出 token(含缓存写入),不计缓存读取。同样的 token 数,更大的模型消耗更多;轻量任务通常用更便宜的模型即可胜任,并留出更多余量。0% 表示限额没有变化,这在 7 天限额中很常见。" + } + } + } + }, "Shared With" : { "localizations" : { "zh-Hans" : { @@ -21153,6 +22553,16 @@ } } }, + "Show session time by provider and model." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按提供方和模型显示会话时长。" + } + } + } + }, "Show stories" : { "localizations" : { "ko" : { @@ -21211,6 +22621,46 @@ } } }, + "Show the model and provider breakdown." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示模型和提供方的明细。" + } + } + } + }, + "Show the token breakdown." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示 token 明细。" + } + } + } + }, + "Show the usage-limit cost of each task." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示每个任务的限额消耗。" + } + } + } + }, + "Show the usage-limit history chart." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示用量限额历史图表。" + } + } + } + }, "Show this item" : { "localizations" : { "ko" : { @@ -21432,7 +22882,7 @@ } } }, - "Sign in to rxlab to\nimport GitHub repos": { + "Sign in to rxlab to\nimport GitHub repos" : { "localizations" : { "ko" : { "stringUnit" : { @@ -21448,7 +22898,7 @@ } } }, - "Sign in to rxlab to\nimport your GitHub repos": { + "Sign in to rxlab to\nimport your GitHub repos" : { "localizations" : { "ko" : { "stringUnit" : { @@ -21748,6 +23198,16 @@ } } }, + "Solid 5-hour · dashed 7-day" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实线 5 小时 · 虚线 7 天" + } + } + } + }, "Source" : { "localizations" : { "ko" : { @@ -22582,6 +24042,16 @@ } } }, + "Tasks left in 5-hour · 7-day limits" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时 · 7 天限额内剩余任务数" + } + } + } + }, "Tasks must carry every selected tag." : { "localizations" : { "zh-Hans" : { @@ -22592,6 +24062,66 @@ } } }, + "Tasks of average cost that fit in what's left of each limit. Click for estimates by model." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按平均消耗计算,各剩余限额还能容纳的任务数。点击查看按模型的估算。" + } + } + } + }, + "Tasks of average cost that still fit in the 5-hour limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 小时限额内还能容纳的平均消耗任务数。" + } + } + } + }, + "Tasks of average cost that still fit in the 7-day limit." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 天限额内还能容纳的平均消耗任务数。" + } + } + } + }, + "Tasks remaining" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余任务" + } + } + } + }, + "Tasks running at the same time share one limit, so their costs are approximate and are left out of the averages once enough solo tasks exist." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "同时运行的任务共享同一限额,因此其消耗为近似值;当单独运行的任务足够多时,不计入平均值。" + } + } + } + }, + "Tasks so far barely moved the limit, so there's no practical cap yet." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目前的任务几乎没有消耗限额,暂无实际上限。" + } + } + } + }, "Tasks targeting any selected version." : { "localizations" : { "zh-Hans" : { @@ -22602,6 +24132,17 @@ } } }, + "Tasks that ran alongside other tasks on the same provider share its limit, so their costs are approximate and are left out of the averages once enough solo tasks exist." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与同一提供方的其他任务同时运行的任务会共享限额,因此其消耗为近似值;当单独运行的任务足够多时,这些任务不计入平均值。" + } + } + } + }, "Templates of GitHub merge settings and an optional branch ruleset. Your default template is applied automatically to new repositories." : { "localizations" : { "ko" : { @@ -22853,6 +24394,36 @@ } } }, + "The least expensive task in this window." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此时间范围内消耗最低的任务。" + } + } + } + }, + "The limit reset during this task, so its cost can't be measured." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该任务期间限额已重置,无法测量其消耗。" + } + } + } + }, + "The limit reset while the task ran, so its cost can't be measured." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "任务运行期间限额已重置,无法测量其消耗。" + } + } + } + }, "The milestone is cleared from every story and task that uses it." : { "localizations" : { "zh-Hans" : { @@ -22893,6 +24464,26 @@ } } }, + "The model that uses the most limit for the same number of tokens." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "同样 token 数下消耗限额最多的模型。" + } + } + } + }, + "The most expensive task in this window." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此时间范围内消耗最高的任务。" + } + } + } + }, "The new Commit & Push hook commits — and optionally pushes — your work the moment an agent session completes." : { "localizations" : { "ko" : { @@ -22961,6 +24552,16 @@ } } }, + "The provider's most and least expensive tasks in this window." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该提供方在此时间范围内消耗最高和最低的任务。" + } + } + } + }, "The release CI authenticates to GitHub with a RELEASE_TOKEN secret. Paste a GitHub token (a PAT with contents:write) and RxCode installs it as the repository's GitHub Actions secret for you — no terminal needed." : { "localizations" : { "ko" : { @@ -23202,6 +24803,16 @@ } } }, + "Thinking" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "思考中" + } + } + } + }, "Thinking Level" : { "localizations" : { "zh-Hans" : { @@ -23575,6 +25186,17 @@ } } }, + "Thread" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "线程" + } + } + } + }, "Thread menu" : { "localizations" : { "ko" : { @@ -23688,43 +25310,63 @@ } } }, - "Times use your local time zone." : { + "time" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "时间使用你的本地时区。" + "value" : "时长" } } } }, - "Times use your local time zone. Advanced cron expressions remain available in Expression mode." : { + "Time" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "时间使用你的本地时区。在“表达式”模式下仍可使用高级 cron 表达式。" + "value" : "时间" } } } }, - "Title" : { + "Times use your local time zone." : { "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "제목" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "标题" + "value" : "时间使用你的本地时区。" } } } }, - "Today" : { + "Times use your local time zone. Advanced cron expressions remain available in Expression mode." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时间使用你的本地时区。在“表达式”模式下仍可使用高级 cron 表达式。" + } + } + } + }, + "Title" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标题" + } + } + } + }, + "Today" : { "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -23756,6 +25398,16 @@ } } }, + "Todos %lld/%lld" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待办事项 %lld/%lld" + } + } + } + }, "Toggle Inspector" : { "localizations" : { "ko" : { @@ -23772,6 +25424,36 @@ } } }, + "Token usage" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Token 用量" + } + } + } + }, + "tokens" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + } + } + }, + "Tokens burned" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Token 消耗" + } + } + } + }, "Tools to Expose" : { "localizations" : { "zh-Hans" : { @@ -23782,6 +25464,16 @@ } } }, + "Top provider: %@ (%@)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最常用提供方:%1$@(%2$@)" + } + } + } + }, "Total occupied space" : { "localizations" : { "zh-Hans" : { @@ -23906,6 +25598,16 @@ } } }, + "turns" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "轮次" + } + } + } + }, "Type" : { "localizations" : { "ko" : { @@ -23974,6 +25676,16 @@ } } }, + "Unknown project" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知项目" + } + } + } + }, "Unpin" : { "localizations" : { "en" : { @@ -24059,6 +25771,16 @@ } } }, + "Untitled thread" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无标题线程" + } + } + } + }, "Unverified" : { "localizations" : { "ko" : { @@ -24191,6 +25913,26 @@ } } }, + "Usage limits" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用量限额" + } + } + } + }, + "Usage-limit history" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用量限额历史" + } + } + } + }, "Use a GitHub repository that exposes .claude-plugin/marketplace.json." : { "localizations" : { "ko" : { @@ -24269,6 +26011,16 @@ } } }, + "Use the filter button to hide providers or limits. Your choice is remembered." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用筛选按钮隐藏提供方或限额,选择会被记住。" + } + } + } + }, "Use the registry to add Agent Client Protocol tools, then pick them from the thread model menu." : { "localizations" : { "ko" : { @@ -24301,6 +26053,16 @@ } } }, + "Used" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已用" + } + } + } + }, "Used for new sessions. You can override the effort level per session from the toolbar." : { "localizations" : { "en" : { @@ -24922,6 +26684,16 @@ } } }, + "Week" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "周" + } + } + } + }, "Weekdays at 9:00" : { "extractionState" : "stale", "localizations" : { @@ -25403,6 +27175,16 @@ } } }, + "XHigh" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "超高" + } + } + } + }, "Yesterday" : { "localizations" : { "zh-Hans" : { @@ -25454,366 +27236,6 @@ } } } - }, - "Passing" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "通过" - } - } - } - }, - "Failing" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "失败" - } - } - } - }, - "No status" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无状态" - } - } - } - }, - "All briefings" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "所有简报" - } - } - } - }, - "Project summaries" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "项目摘要" - } - } - } - }, - "Documents" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "文档" - } - } - } - }, - "All" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "全部" - } - } - } - }, - "Docs" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "文档" - } - } - } - }, - "In this thread" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此线程中" - } - } - } - }, - "In this thread · %@" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此线程中 · %@" - } - } - } - }, - "Unknown project" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未知项目" - } - } - } - }, - "Untitled thread" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无标题线程" - } - } - } - }, - "Thread" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "线程" - } - } - } - }, - "1 project" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 个项目" - } - } - } - }, - "Low" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "低" - } - } - } - }, - "Medium" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "中" - } - } - } - }, - "High" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "高" - } - } - } - }, - "XHigh" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "超高" - } - } - } - }, - "Max" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "最高" - } - } - } - }, - "Thinking" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "思考中" - } - } - } - }, - "New worktree" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "新建工作树" - } - } - } - }, - "Branch + checkout" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "创建并切换分支" - } - } - } - }, - "Creates an isolated worktree so this chat works without touching the main checkout." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "创建独立的工作树,让此聊天可以运行而不影响主工作树。" - } - } - } - }, - "Creates the branch and checks it out in the project root, changing the main checkout." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在项目根目录创建分支并切换到该分支,这会更改主工作树。" - } - } - } - }, - "Branch name is required." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "必须填写分支名称。" - } - } - } - }, - "Branch name cannot end with “/”." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "分支名称不能以“/”结尾。" - } - } - } - }, - "Add a branch name after the prefix." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "请在此前缀后添加分支名称。" - } - } - } - }, - "Todos %lld/%lld" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "待办事项 %lld/%lld" - } - } - } - }, - "%lld repos" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld 个仓库" - } - } - } - }, - "%lld of %lld repos" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld / %lld 个仓库" - } - } - } - }, - "Global default · On" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "全局默认 · 开启" - } - } - } - }, - "Global default · Off" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "全局默认 · 关闭" - } - } - } - }, - "Inherits global default (On)" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "继承全局默认值(开启)" - } - } - } - }, - "Inherits global default (Off)" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "继承全局默认值(关闭)" - } - } - } - }, - "Forced on for this project" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此项目强制开启" - } - } - } - }, - "Forced off for this project" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此项目强制关闭" - } - } - } } }, "version" : "1.1" diff --git a/RxCode/Services/CodexAppServer+Parsing.swift b/RxCode/Services/CodexAppServer+Parsing.swift index bb5d87f5..4a9abd2c 100644 --- a/RxCode/Services/CodexAppServer+Parsing.swift +++ b/RxCode/Services/CodexAppServer+Parsing.swift @@ -294,6 +294,44 @@ extension CodexAppServer { ) } + /// Thread-cumulative (`total`) and latest-request (`last`) usage from a + /// `thread/tokenUsage/updated` notification. `turn/completed` carries no + /// usage, so these snapshots are the only per-turn token source. + static func tokenUsageBreakdowns(from params: [String: JSONValue]) -> (total: UsageInfo, last: UsageInfo)? { + guard let usage = firstObject(in: params, keys: ["tokenUsage", "token_usage"]) + ?? firstNestedObject(in: .object(params), keys: ["tokenUsage", "token_usage"]), + let total = firstObject(in: usage, keys: ["total"]) else { return nil } + let last = firstObject(in: usage, keys: ["last"]) ?? [:] + return (codexUsageBreakdown(total), codexUsageBreakdown(last)) + } + + /// Maps a Codex `TokenUsageBreakdown` onto `UsageInfo`. OpenAI's + /// `inputTokens` already includes `cachedInputTokens` and `outputTokens` + /// already includes `reasoningOutputTokens`, so cached input is split out + /// and reasoning is not added again. + static func codexUsageBreakdown(_ breakdown: [String: JSONValue]) -> UsageInfo { + let input = firstInt(in: breakdown, keys: ["inputTokens", "input_tokens"]) + let cached = firstInt(in: breakdown, keys: ["cachedInputTokens", "cached_input_tokens"]) + return UsageInfo( + inputTokens: max(input - cached, 0), + outputTokens: firstInt(in: breakdown, keys: ["outputTokens", "output_tokens"]), + cacheCreationInputTokens: 0, + cacheReadInputTokens: cached + ) + } + + /// Usage accrued between two cumulative snapshots, or nil when nothing accrued. + static func usageDelta(from start: UsageInfo, to end: UsageInfo) -> UsageInfo? { + let delta = UsageInfo( + inputTokens: max(end.inputTokens - start.inputTokens, 0), + outputTokens: max(end.outputTokens - start.outputTokens, 0), + cacheCreationInputTokens: max(end.cacheCreationInputTokens - start.cacheCreationInputTokens, 0), + cacheReadInputTokens: max(end.cacheReadInputTokens - start.cacheReadInputTokens, 0) + ) + let total = delta.inputTokens + delta.outputTokens + delta.cacheCreationInputTokens + delta.cacheReadInputTokens + return total > 0 ? delta : nil + } + static func tokenUsageOutputTokens(from params: [String: JSONValue]) -> Int? { let usage = tokenUsageSummary(from: params) let outputTokens = firstInt(in: usage, keys: ["outputTokens", "output_tokens"]) diff --git a/RxCode/Services/CodexAppServer+Turn.swift b/RxCode/Services/CodexAppServer+Turn.swift index 294a34f7..3a6c8efb 100644 --- a/RxCode/Services/CodexAppServer+Turn.swift +++ b/RxCode/Services/CodexAppServer+Turn.swift @@ -28,6 +28,10 @@ extension CodexAppServer { var turnStarted = false var turnCompleted = false var finalUsage: UsageInfo? + // Thread-cumulative usage before this turn's first request, and the + // latest cumulative snapshot. Their difference is this turn's usage. + var usageBaseline: UsageInfo? + var latestTotalUsage: UsageInfo? let startedAt = Date() // Captured per turn so we can synthesize an `ExitPlanMode` tool call when a // plan-mode turn completes. Codex never emits ExitPlanMode itself — its plan @@ -112,6 +116,14 @@ extension CodexAppServer { if let text = Self.firstString(in: params, keys: ["delta", "text", "content"]) { assistantTextBuffer += text } + case "thread/tokenUsage/updated": + if let breakdowns = Self.tokenUsageBreakdowns(from: params) { + if usageBaseline == nil { + usageBaseline = Self.usageDelta(from: breakdowns.last, to: breakdowns.total) + ?? UsageInfo(inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0) + } + latestTotalUsage = breakdowns.total + } default: break } @@ -133,6 +145,10 @@ extension CodexAppServer { } } + if finalUsage == nil, let usageBaseline, let latestTotalUsage { + finalUsage = Self.usageDelta(from: usageBaseline, to: latestTotalUsage) + } + let sid = activeThreadId ?? threadId ?? UUID().uuidString let duration = Date().timeIntervalSince(startedAt) * 1000 continuation.yield(.result(ResultEvent( diff --git a/RxCode/Services/ThreadStore+Container.swift b/RxCode/Services/ThreadStore+Container.swift index bff739fb..d8f79505 100644 --- a/RxCode/Services/ThreadStore+Container.swift +++ b/RxCode/Services/ThreadStore+Container.swift @@ -24,7 +24,9 @@ extension ThreadStore { HookStatusRecord.self, HookCardRecord.self, CustomMenuItemRecord.self, - UsageStatBucket.self + UsageStatBucket.self, + RateLimitSample.self, + RateLimitTaskCost.self ]) } @@ -50,6 +52,7 @@ extension ThreadStore { store.finalizeInterruptedHookCards() store.finalizeInterruptedCompletionChecks() store.pruneUsageBuckets() + store.pruneRateLimitHistory() return store } catch { // Fall back to an in-memory container so the app still launches. diff --git a/RxCode/Services/ThreadStore+RateLimitHistory.swift b/RxCode/Services/ThreadStore+RateLimitHistory.swift new file mode 100644 index 00000000..9e89e9d3 --- /dev/null +++ b/RxCode/Services/ThreadStore+RateLimitHistory.swift @@ -0,0 +1,63 @@ +import Foundation +import os +import SwiftData +import RxCodeCore + +/// Persisted usage-limit history (5-hour / 7-day percentages over time) and +/// per-task limit costs, shown on the briefing page. +extension ThreadStore { + /// Both tables back the briefing usage windows (up to 365 days); keep a + /// small margin, matching the daily usage buckets. + static let rateLimitHistoryRetention: TimeInterval = dailyUsageRetention + + /// Stores `usage` unless it adds nothing over the provider's latest sample. + /// Returns whether a row was written. + @discardableResult + func recordRateLimitSample(_ usage: RateLimitUsage, providerRaw: String, at date: Date = .now) -> Bool { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.providerRaw == providerRaw }, + sortBy: [SortDescriptor(\.capturedAt, order: .reverse)] + ) + descriptor.fetchLimit = 1 + let previous = (try? context.fetch(descriptor))?.first?.toPoint() + guard RateLimitSample.shouldRecord(previous: previous, usage: usage, at: date) else { return false } + context.insert(RateLimitSample(providerRaw: providerRaw, usage: usage, capturedAt: date)) + save() + return true + } + + /// Samples for one provider since `since`, oldest first. + func rateLimitSamples(providerRaw: String, since: Date) -> [RateLimitSamplePoint] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.providerRaw == providerRaw && $0.capturedAt >= since }, + sortBy: [SortDescriptor(\.capturedAt)] + ) + return ((try? context.fetch(descriptor)) ?? []).map { $0.toPoint() } + } + + func recordRateLimitTaskCost(_ cost: RateLimitTaskCost) { + context.insert(cost) + save() + } + + /// Task costs for one provider that finished since `since`. + func rateLimitTaskCosts(providerRaw: String, since: Date) -> [RateLimitTaskCostSnapshot] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.providerRaw == providerRaw && $0.endedAt >= since }, + sortBy: [SortDescriptor(\.endedAt, order: .reverse)] + ) + return ((try? context.fetch(descriptor)) ?? []).map { $0.toSnapshot() } + } + + /// Deletes samples and task costs older than the retention window. + func pruneRateLimitHistory(now: Date = .now) { + let cutoff = now.addingTimeInterval(-Self.rateLimitHistoryRetention) + do { + try context.delete(model: RateLimitSample.self, where: #Predicate { $0.capturedAt < cutoff }) + try context.delete(model: RateLimitTaskCost.self, where: #Predicate { $0.endedAt < cutoff }) + save() + } catch { + logger.error("Rate limit history prune failed: \(error.localizedDescription)") + } + } +} diff --git a/RxCode/Views/GeneralRouteWindowView.swift b/RxCode/Views/GeneralRouteWindowView.swift new file mode 100644 index 00000000..fc1257b2 --- /dev/null +++ b/RxCode/Views/GeneralRouteWindowView.swift @@ -0,0 +1,187 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI + +// MARK: - General Route Window Root + +/// Standalone window hosting a single General route (Projects, Briefing, or +/// Scheduled) without the sidebar, with its own `WindowState` so it navigates +/// independently of the main window. +struct GeneralRouteWindowRoot: View { + let workspaceManager: WorkspaceManager + let workspaceID: String + let route: GeneralRoute + @Environment(\.controlActiveState) private var controlActiveState + @State private var windowState = WindowState() + @State private var chatBridge = ChatBridge() + @State private var isReady = false + + private var appState: AppState { workspaceManager.appState(for: workspaceID) } + + var body: some View { + ZStack { + if appState.isInitialized, windowState.isInitialized, isReady { + GeneralRouteWindowView(route: route) + .hookUI() + .environment(appState) + .environment(workspaceManager) + .environment(windowState) + .environment(chatBridge) + .environment(\.openURL, workspaceOpenURLAction(appState: appState, windowState: windowState)) + .transition(.opacity) + } else { + // Plain spinner rather than `LoadingView`: the splash hides the + // window's title bar and traffic lights. + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ClaudeTheme.background) + .transition(.opacity) + } + } + .frame(minWidth: 560, minHeight: 420) + .animation(.easeInOut(duration: 0.3), value: isReady) + .onAppear { workspaceManager.markFrontmost(workspaceID) } + .onChange(of: controlActiveState) { _, state in + if state == .key { workspaceManager.markFrontmost(workspaceID) } + } + .task { + // The main window may still be booting AppState (e.g. on state restoration). + while !appState.isInitialized { + try? await Task.sleep(nanoseconds: 50000000) + } + appState.setupChatBridge(chatBridge, for: windowState) + await appState.initializeWindow(windowState) + windowState.generalRoute = route + isReady = true + } + } +} + +// MARK: - General Route Window View + +/// Shows the route's page. Opening a thread from it (e.g. a briefing card or a +/// task's thread) clears the route; the thread then shows in place with a back +/// button that returns to the route. +private struct GeneralRouteWindowView: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + let route: GeneralRoute + + private var isShowingRoute: Bool { + windowState.generalRoute != nil || windowState.selectedProject == nil + } + + private var navigationTitleText: String { + if !isShowingRoute, + let id = windowState.currentSessionId, + let title = appState.allSessionSummaries.first(where: { $0.id == id })?.title, + !title.isEmpty { + return title + } + return (windowState.generalRoute ?? route).displayNameText + } + + var body: some View { + Group { + if isShowingRoute { + routeContent + } else { + threadContent + } + } + .navigationTitle(navigationTitleText) + .sheet(item: Bindable(windowState).inspectorFile) { file in + FileInspectorView(filePath: file.path, fileName: file.name) + .frame(minWidth: 1000, idealWidth: 1400, maxWidth: 1920, + minHeight: 600, idealHeight: 1000, maxHeight: 1200) + } + .sheet(item: Bindable(windowState).diffFile) { file in + FileDiffView( + filePath: file.path, + fileName: file.name, + editHunks: file.editHunks, + gitDiffMode: file.gitDiffMode, + showFullFileDiff: file.showFullFileDiff, + originalContent: file.originalContent, + modifiedContent: file.modifiedContent + ) + .frame(minWidth: 1000, idealWidth: 1400, maxWidth: 1920, + minHeight: 600, idealHeight: 1000, maxHeight: 1200) + } + .sheet(isPresented: Binding( + get: { windowState.linkCloudProjectId != nil }, + set: { if !$0 { windowState.linkCloudProjectId = nil } } + )) { + if let projectId = windowState.linkCloudProjectId { + LinkCloudProjectSheet(projectId: projectId) + .environment(appState) + .environment(windowState) + } + } + .sheet(isPresented: Bindable(windowState).showNewProjectSheet) { + NewProjectSheet(prefersCloud: windowState.newProjectPrefersCloud) + .environment(appState) + .environment(windowState) + } + .alert("Error", isPresented: Bindable(windowState).showError) { + Button("OK", role: .cancel) {} + } message: { + Text(LocalizedStringKey(windowState.errorMessage ?? "")) + } + .focusedValue(\.startNewChat) { + appState.startNewChat(in: windowState) + } + } + + @ViewBuilder + private var routeContent: some View { + switch windowState.generalRoute ?? route { + case .tasks: TaskBoardView() + case .briefing: BriefingView() + case .scheduled: ScheduledTasksView() + case .chat: GlobalChatView() + } + } + + private var threadContent: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { + windowState.generalRoute = route + } label: { + Label(route.displayName, systemImage: "chevron.left") + } + .help("Back to \(route.displayNameText)") + + if let project = windowState.selectedProject, !project.isGlobalChat { + Label(project.name, systemImage: "folder") + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(1) + .help(project.path) + } + + Spacer() + } + .buttonStyle(.borderless) + .padding(12) + + ClaudeThemeDivider() + + ChatView(inputAccessory: { + HStack(spacing: 8) { + ChatToolbarControls(placement: .composer) + if windowState.selectedProject?.isGlobalChat == false { + BranchPickerChip() + } + } + }, aboveInputAccessory: { + VStack(spacing: 8) { + PermissionQueueBanner() + ThreadDiffBanner() + } + }) + } + .modifier(ChatDetailModifiers()) + } +} diff --git a/RxCode/Views/Sidebar/BriefingRateLimitStatsView.swift b/RxCode/Views/Sidebar/BriefingRateLimitStatsView.swift new file mode 100644 index 00000000..ea4dc224 --- /dev/null +++ b/RxCode/Views/Sidebar/BriefingRateLimitStatsView.swift @@ -0,0 +1,252 @@ +import SwiftUI +import RxCodeCore + +/// Usage-limit tiles on the briefing page, comparing Claude Code and Codex +/// side by side: current 5-hour / 7-day limits (with their history), how much +/// of the limits a task costs, and how many more tasks fit. Uses the window +/// picked in the usage statistics header. Limits are account-wide, so the +/// briefing project filter doesn't apply. +struct BriefingRateLimitStatsView: View { + @Environment(AppState.self) private var appState + + let range: UsageStatsRange + + @State private var summaries: [AgentProvider: RateLimitTaskCostSummary] = [:] + @State private var activePopover: DetailPopover? + + private enum DetailPopover { + case history + case taskCosts + case models + } + + /// Providers with usage limits that are available on this Mac. + private var providers: [AgentProvider] { + AppState.rateLimitedProviders.filter { $0 != .codex || appState.codexInstalled } + } + + private struct ReloadKey: Equatable { + let providers: [AgentProvider] + let range: UsageStatsRange + let revision: Int + let usages: [RateLimitUsage?] + } + + private var reloadKey: ReloadKey { + ReloadKey( + providers: providers, + range: range, + revision: appState.rateLimitHistoryRevision, + usages: providers.map { appState.cachedRateLimitUsage(for: $0) } + ) + } + + private var hasTasks: Bool { + summaries.values.contains { !$0.isEmpty } + } + + /// Emits the tiles as separate children so the parent grid lays them out + /// alongside its own tiles. The shared `.task` / `.sheet` hang off the + /// first tile so they aren't duplicated per child. + var body: some View { + limitsTile + .task(id: reloadKey) { + var result: [AgentProvider: RateLimitTaskCostSummary] = [:] + for provider in providers { + result[provider] = appState.rateLimitTaskCostSummary(for: provider, range: range) + } + summaries = result + } + taskCostTile + remainingTile + } + + /// Popover for `kind`, anchored to the tile that opened it. + @ViewBuilder + private func detailPopover(_ kind: DetailPopover) -> some View { + switch kind { + case .history: + RateLimitHistoryPopover(providers: providers, range: range) + case .taskCosts: + RateLimitTaskCostPopover(providers: providers, range: range) + case .models: + RateLimitModelEstimatePopover(providers: providers, range: range) + } + } + + private func popoverBinding(_ kind: DetailPopover) -> Binding { + Binding( + get: { activePopover == kind }, + set: { if !$0 { activePopover = nil } } + ) + } + + // MARK: - Tiles + + private var limitsTile: some View { + Button { + activePopover = .history + } label: { + BriefingProviderStatTile( + icon: "gauge.with.dots.needle.33percent", + title: "Usage limits", + rows: providers.map { provider in + let usage = appState.cachedRateLimitUsage(for: provider) + return .init( + id: provider.rawValue, + label: provider.displayNameText, + value: usage.map { usage in + usage.hasFiveHourLimit + ? "\(Self.formatPercent(usage.fiveHourPercent)) · \(Self.formatPercent(usage.sevenDayPercent))" + : Self.formatPercent(usage.sevenDayPercent) + } ?? "—", + detail: usage.flatMap(Self.resetDetail) + ) + }, + footnote: String(localized: "5-hour · 7-day used"), + accessoryIcon: "chart.xyaxis.line", + help: "Show the usage-limit history chart." + ) + } + .buttonStyle(.plain) + .popover(isPresented: popoverBinding(.history), arrowEdge: .bottom) { + detailPopover(.history) + } + } + + private var taskCostTile: some View { + Button { + activePopover = .taskCosts + } label: { + BriefingProviderStatTile( + icon: "chart.bar.xaxis", + title: "Limit cost per task", + rows: providers.map { provider in + let summary = summaries[provider] ?? RateLimitTaskCostSummary() + let hasFiveHour = hasFiveHourLimit(provider) + return .init( + id: provider.rawValue, + label: provider.displayNameText, + value: summary.sampleCount > 0 + ? (hasFiveHour + ? "\(Self.formatOptionalDelta(summary.averageFiveHour)) · \(Self.formatOptionalDelta(summary.averageSevenDay))" + : Self.formatOptionalDelta(summary.averageSevenDay)) + : "—", + detail: Self.costDetail(summary, hasFiveHour: hasFiveHour) + ) + }, + footnote: String(localized: "Average of 5-hour · 7-day per task"), + accessoryIcon: hasTasks ? "list.bullet" : nil, + help: "Show the usage-limit cost of each task." + ) + } + .buttonStyle(.plain) + .popover(isPresented: popoverBinding(.taskCosts), arrowEdge: .bottom) { + detailPopover(.taskCosts) + } + .disabled(!hasTasks) + } + + private var remainingTile: some View { + Button { + activePopover = .models + } label: { + BriefingProviderStatTile( + icon: "hourglass", + title: "Tasks remaining", + rows: providers.map { provider in + let summary = summaries[provider] ?? RateLimitTaskCostSummary() + let hasUsage = appState.cachedRateLimitUsage(for: provider) != nil + return .init( + id: provider.rawValue, + label: provider.displayNameText, + value: hasUsage + ? (hasFiveHourLimit(provider) + ? String(localized: "5h \(Self.formatEstimate(summary.remainingFiveHour)) · 7d \(Self.formatEstimate(summary.remainingSevenDay))") + : String(localized: "7d \(Self.formatEstimate(summary.remainingSevenDay))")) + : "—", + detail: Self.basisDetail(summary) + ) + }, + footnote: String(localized: "Tasks left in 5-hour · 7-day limits"), + accessoryIcon: hasTasks ? "list.bullet.rectangle" : nil, + help: "Tasks of average cost that fit in what's left of each limit. Click for estimates by model." + ) + } + .buttonStyle(.plain) + .popover(isPresented: popoverBinding(.models), arrowEdge: .bottom) { + detailPopover(.models) + } + .disabled(!hasTasks) + } + + /// False for plans without a separate 5-hour limit; their tiles show only + /// the 7-day figures. + private func hasFiveHourLimit(_ provider: AgentProvider) -> Bool { + appState.cachedRateLimitUsage(for: provider)?.hasFiveHourLimit ?? true + } + + private static func resetDetail(_ usage: RateLimitUsage) -> String? { + if usage.hasFiveHourLimit { + return usage.fiveHourResetsAt.map { + String(localized: "5-hour resets \($0.formatted(.relative(presentation: .named)))") + } + } + return usage.sevenDayResetsAt.map { + String(localized: "7-day resets \($0.formatted(.relative(presentation: .named)))") + } + } + + private static func costDetail(_ summary: RateLimitTaskCostSummary, hasFiveHour: Bool) -> String { + guard summary.sampleCount > 0 else { return String(localized: "No measured tasks yet") } + let delta = hasFiveHour ? \RateLimitTaskCostSnapshot.fiveHourDelta : \RateLimitTaskCostSnapshot.sevenDayDelta + let most = summary.mostExpensive?[keyPath: delta].map(formatDelta) + let least = summary.leastExpensive?[keyPath: delta].map(formatDelta) + switch (most, least, hasFiveHour) { + case let (most?, least?, true): return String(localized: "5h most \(most) · least \(least)") + case let (most?, nil, true): return String(localized: "5h most \(most)") + case let (most?, least?, false): return String(localized: "7d most \(most) · least \(least)") + case let (most?, nil, false): return String(localized: "7d most \(most)") + default: + return summary.sampleCount == 1 + ? String(localized: "1 task measured") + : String(localized: "\(summary.sampleCount) tasks measured") + } + } + + private static func basisDetail(_ summary: RateLimitTaskCostSummary) -> String { + let count = summary.sampleCount + guard count > 0 else { return String(localized: "Estimates appear after tasks finish") } + switch (count == 1, summary.usesIsolatedSamplesOnly) { + case (true, true): return String(localized: "Based on 1 task run alone") + case (true, false): return String(localized: "Based on 1 task") + case (false, true): return String(localized: "Based on \(count) tasks run alone") + case (false, false): return String(localized: "Based on \(count) tasks") + } + } + + // MARK: - Formatting + + static func formatPercent(_ value: Double) -> String { + (value / 100).formatted(.percent.precision(.fractionLength(0...1))) + } + + /// Percentage points of a limit, e.g. `1.5%`. + static func formatDelta(_ value: Double) -> String { + (value / 100).formatted(.percent.precision(.fractionLength(0...2))) + } + + static func formatOptionalDelta(_ value: Double?) -> String { + value.map(formatDelta) ?? "—" + } + + /// Remaining tasks with their unit, e.g. `~63 tasks`. + static func formatEstimate(_ estimate: RateLimitTaskCostSummary.Estimate) -> String { + switch estimate { + case .insufficientData: "—" + case .unbounded: String(localized: "Many") + case .tasks(1): String(localized: "~1 task") + case .tasks(let count): String(localized: "~\(count) tasks") + } + } +} diff --git a/RxCode/Views/Sidebar/BriefingStatTile.swift b/RxCode/Views/Sidebar/BriefingStatTile.swift new file mode 100644 index 00000000..f81d6dd1 --- /dev/null +++ b/RxCode/Views/Sidebar/BriefingStatTile.swift @@ -0,0 +1,200 @@ +import SwiftUI +import RxCodeCore + +/// Fixed-height statistic card used by the briefing usage panels: icon and +/// title, a large value, a detail line, and an optional footnote. +struct BriefingStatTile: View { + /// Shared by every briefing statistic card so tiles line up in the grid. + /// Fits a provider tile with two rows plus its footnote. + static let height: CGFloat = 152 + + let icon: String + let title: LocalizedStringKey + let value: String + let detail: String + let footnote: String? + /// Trailing glyph hinting that the tile opens more detail; nil when inert. + let accessoryIcon: String? + let help: LocalizedStringKey + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.textSecondary) + Spacer(minLength: 0) + if let accessoryIcon { + Image(systemName: accessoryIcon) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + Text(value) + .font(.system(size: 20, weight: .semibold).monospacedDigit()) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .contentTransition(.numericText()) + Text(detail) + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + Spacer(minLength: 0) + if let footnote { + Text(footnote) + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: Self.height, maxHeight: Self.height, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) + .fill(ClaudeTheme.surfacePrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + ) + .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous)) + .help(help) + .animation(.easeInOut(duration: 0.2), value: value) + } +} + +/// Capsule dropdown used in the briefing usage panel headers. +struct BriefingPanelMenu: View { + let options: [Option] + let selection: Option + let title: (Option) -> String + let help: String + let onSelect: (Option) -> Void + + var body: some View { + Menu { + ForEach(options, id: \.self) { option in + Button { + onSelect(option) + } label: { + if option == selection { + Label(title(option), systemImage: "checkmark") + } else { + Text(title(option)) + } + } + } + } label: { + HStack(spacing: 4) { + Text(title(selection)) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .semibold)) + } + .foregroundStyle(ClaudeTheme.textSecondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule(style: .continuous) + .fill(ClaudeTheme.surfaceSecondary) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(help) + } +} + +/// Briefing statistic card comparing one metric across providers: a row per +/// provider with its value and an optional detail line. +struct BriefingProviderStatTile: View { + struct Row: Identifiable { + let id: String + let label: String + let value: String + let detail: String? + } + + let icon: String + let title: LocalizedStringKey + let rows: [Row] + let footnote: String? + /// Trailing glyph hinting that the tile opens more detail; nil when inert. + let accessoryIcon: String? + let help: LocalizedStringKey + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.textSecondary) + Spacer(minLength: 0) + if let accessoryIcon { + Image(systemName: accessoryIcon) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + ForEach(rows) { row in + VStack(alignment: .leading, spacing: 1) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.label) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(1) + Spacer(minLength: 4) + Text(row.value) + .font(.system(size: 15, weight: .semibold).monospacedDigit()) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + .contentTransition(.numericText()) + } + if let detail = row.detail { + Text(detail) + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + } + } + Spacer(minLength: 0) + if let footnote { + Text(footnote) + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + } + .padding(14) + .frame( + maxWidth: .infinity, + minHeight: BriefingStatTile.height, + maxHeight: BriefingStatTile.height, + alignment: .topLeading + ) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) + .fill(ClaudeTheme.surfacePrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + ) + .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous)) + .help(help) + } +} diff --git a/RxCode/Views/Sidebar/BriefingUsageStatsView.swift b/RxCode/Views/Sidebar/BriefingUsageStatsView.swift index b741d0e5..58d65009 100644 --- a/RxCode/Views/Sidebar/BriefingUsageStatsView.swift +++ b/RxCode/Views/Sidebar/BriefingUsageStatsView.swift @@ -36,6 +36,9 @@ struct BriefingUsageStatsView: View { modelTile sessionTimeTile tokensTile + // Claude Code / Codex usage limits share this panel's window + // picker and wrap in the same row as the usage tiles. + BriefingRateLimitStatsView(range: range) } } .task(id: ReloadKey(range: range, revision: appState.usageStatsRevision, projectIds: projectIds)) { @@ -58,42 +61,12 @@ struct BriefingUsageStatsView: View { /// Dropdown button for the time window, styled like the briefing hero's /// Autopilot menu. private var rangeMenu: some View { - Menu { - ForEach(UsageStatsRange.allCases) { option in - Button { - rangeRaw = option.rawValue - } label: { - if option == range { - Label(option.longTitle, systemImage: "checkmark") - } else { - Text(option.longTitle) - } - } - } - } label: { - HStack(spacing: 4) { - Text(range.longTitle) - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .semibold)) - } - .foregroundStyle(ClaudeTheme.textSecondary) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background( - Capsule(style: .continuous) - .fill(ClaudeTheme.surfaceSecondary) - ) - .overlay( - Capsule(style: .continuous) - .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) - ) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .help("Choose the time window for usage statistics.") + BriefingPanelMenu( + options: UsageStatsRange.allCases, + selection: range, + title: \.localizedLongTitle, + help: "Choose the time window for usage statistics." + ) { rangeRaw = $0.rawValue } } // MARK: - Tiles @@ -104,8 +77,6 @@ struct BriefingUsageStatsView: View { case tokens } - private static let tileMinHeight: CGFloat = 116 - private var modelTile: some View { let top = summary.topModel let provider = summary.topProvider @@ -117,21 +88,21 @@ struct BriefingUsageStatsView: View { value: top.map { modelName($0) } ?? "—", detail: top.map { usage in let providerLabel = AgentProvider(rawValue: usage.providerRaw)?.displayNameText ?? usage.providerRaw - return "\(providerLabel) · \(share(usage.turns)) of turns" - } ?? "No agent turns yet", + return String(localized: "\(providerLabel) · \(share(usage.turns)) of turns") + } ?? String(localized: "No agent turns yet"), footnote: providerName.map { name in - "Top provider: \(name) (\(share(provider?.turns ?? 0)))" + String(localized: "Top provider: \(name) (\(share(provider?.turns ?? 0)))") }, isInteractive: !summary.isEmpty, help: "Show the model and provider breakdown." ) } popover: { UsageBreakdownPopover( - title: "Model usage", - subtitle: "\(range.longTitle) · share of \(summary.turns) \(summary.turns == 1 ? "turn" : "turns")", + title: String(localized: "Model usage"), + subtitle: String(localized: "\(range.localizedLongTitle) · share of \(Self.formatTurns(summary.turns))"), charts: [ - .init(title: "By model", unit: "turns", slices: modelSlices), - .init(title: "By provider", unit: "turns", slices: providerSlices), + .init(title: String(localized: "By model"), unit: String(localized: "turns"), slices: modelSlices), + .init(title: String(localized: "By provider"), unit: String(localized: "turns"), slices: providerSlices), ] ) } @@ -144,21 +115,21 @@ struct BriefingUsageStatsView: View { title: "Session time", value: summary.isEmpty ? "—" : Self.formatDuration(summary.sessionSeconds), detail: summary.isEmpty - ? "No agent turns yet" - : "\(summary.turns) \(summary.turns == 1 ? "turn" : "turns")", + ? String(localized: "No agent turns yet") + : Self.formatTurns(summary.turns), footnote: summary.isEmpty ? nil - : "Avg \(Self.formatDuration(summary.sessionSeconds / Double(summary.turns))) per turn", + : String(localized: "Avg \(Self.formatDuration(summary.sessionSeconds / Double(summary.turns))) per turn"), isInteractive: !summary.isEmpty, help: "Show session time by provider and model." ) } popover: { UsageBreakdownPopover( - title: "Session time", - subtitle: "\(range.longTitle) · \(Self.formatDuration(summary.sessionSeconds)) of agent work", + title: String(localized: "Session time"), + subtitle: String(localized: "\(range.localizedLongTitle) · \(Self.formatDuration(summary.sessionSeconds)) of agent work"), charts: [ - .init(title: "By provider", unit: "time", slices: providerTimeSlices, format: Self.formatDuration), - .init(title: "By model", unit: "time", slices: modelTimeSlices, format: Self.formatDuration), + .init(title: String(localized: "By provider"), unit: String(localized: "time"), slices: providerTimeSlices, format: Self.formatDuration), + .init(title: String(localized: "By model"), unit: String(localized: "time"), slices: modelTimeSlices, format: Self.formatDuration), ] ) } @@ -171,21 +142,21 @@ struct BriefingUsageStatsView: View { title: "Tokens burned", value: summary.isEmpty ? "—" : Self.formatTokens(summary.totalTokens), detail: summary.isEmpty - ? "No agent turns yet" - : "\(Self.formatTokens(summary.inputTokens)) in · \(Self.formatTokens(summary.outputTokens)) out", + ? String(localized: "No agent turns yet") + : String(localized: "\(Self.formatTokens(summary.inputTokens)) in · \(Self.formatTokens(summary.outputTokens)) out"), footnote: summary.isEmpty ? nil - : "\(Self.formatTokens(summary.cacheReadTokens + summary.cacheCreationTokens)) cache", + : String(localized: "\(Self.formatTokens(summary.cacheReadTokens + summary.cacheCreationTokens)) cache"), isInteractive: !summary.isEmpty, help: "Show the token breakdown." ) } popover: { UsageBreakdownPopover( - title: "Token usage", - subtitle: "\(range.longTitle) · \(summary.totalTokens.formatted()) tokens", + title: String(localized: "Token usage"), + subtitle: String(localized: "\(range.localizedLongTitle) · \(summary.totalTokens.formatted()) tokens"), charts: [ - .init(title: "By type", unit: "tokens", slices: tokenTypeSlices), - .init(title: "By model", unit: "tokens", slices: modelTokenSlices), + .init(title: String(localized: "By type"), unit: String(localized: "tokens"), slices: tokenTypeSlices), + .init(title: String(localized: "By model"), unit: String(localized: "tokens"), slices: modelTokenSlices), ] ) } @@ -217,57 +188,22 @@ struct BriefingUsageStatsView: View { private func tile( icon: String, - title: String, + title: LocalizedStringKey, value: String, detail: String, footnote: String?, isInteractive: Bool, - help: String + help: LocalizedStringKey ) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(ClaudeTheme.accent) - Text(title) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(ClaudeTheme.textSecondary) - Spacer(minLength: 0) - if isInteractive { - Image(systemName: "chart.pie") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) - } - } - Text(value) - .font(.system(size: 20, weight: .semibold).monospacedDigit()) - .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(1) - .truncationMode(.middle) - .contentTransition(.numericText()) - Text(detail) - .font(.system(size: 11)) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - // Always reserve the footnote line so every tile has the same height. - Text(footnote ?? " ") - .font(.system(size: 10.5)) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - } - .padding(14) - .frame(maxWidth: .infinity, minHeight: Self.tileMinHeight, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) - .fill(ClaudeTheme.surfacePrimary) - ) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) - .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + BriefingStatTile( + icon: icon, + title: title, + value: value, + detail: detail, + footnote: footnote, + accessoryIcon: isInteractive ? "chart.pie" : nil, + help: help ) - .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous)) - .help(help) - .animation(.easeInOut(duration: 0.2), value: value) } // MARK: - Breakdown slices @@ -304,10 +240,10 @@ struct BriefingUsageStatsView: View { private var tokenTypeSlices: [UsageBreakdownPopover.Slice] { UsageBreakdownPopover.Slice.folded([ - (id: "input", label: "Input", detail: nil, value: Double(summary.inputTokens)), - (id: "output", label: "Output", detail: nil, value: Double(summary.outputTokens)), - (id: "cacheRead", label: "Cache read", detail: nil, value: Double(summary.cacheReadTokens)), - (id: "cacheWrite", label: "Cache write", detail: nil, value: Double(summary.cacheCreationTokens)), + (id: "input", label: String(localized: "Input"), detail: nil, value: Double(summary.inputTokens)), + (id: "output", label: String(localized: "Output"), detail: nil, value: Double(summary.outputTokens)), + (id: "cacheRead", label: String(localized: "Cache read"), detail: nil, value: Double(summary.cacheReadTokens)), + (id: "cacheWrite", label: String(localized: "Cache write"), detail: nil, value: Double(summary.cacheCreationTokens)), ], sorted: false) } @@ -331,6 +267,10 @@ struct BriefingUsageStatsView: View { return (Double(turns) / Double(summary.turns)).formatted(.percent.precision(.fractionLength(0))) } + static func formatTurns(_ count: Int) -> String { + count == 1 ? String(localized: "1 turn") : String(localized: "\(count) turns") + } + static func formatDuration(_ seconds: Double) -> String { let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated @@ -343,3 +283,15 @@ struct BriefingUsageStatsView: View { count.formatted(.number.notation(.compactName).precision(.fractionLength(0...1))) } } + +extension UsageStatsRange { + /// `longTitle` localized for display; the core package only has English. + var localizedLongTitle: String { + switch self { + case .day: String(localized: "Last 24 hours") + case .week: String(localized: "Last 7 days") + case .month: String(localized: "Last 30 days") + case .year: String(localized: "Last 365 days") + } + } +} diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index dd0c9b79..12d22f3f 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -261,6 +261,19 @@ struct ProjectTreeView: View { } label: { Label("Chat in New Window", systemImage: "macwindow.badge.plus") } + } else { + Button { + openWindow( + id: "route-window", + value: GeneralRouteWindowValue( + route: route, + instanceId: UUID(), + workspaceID: appState.activeWorkspace.id + ) + ) + } label: { + Label("Open in New Window", systemImage: "macwindow.badge.plus") + } } } } diff --git a/RxCode/Views/Sidebar/RateLimitStatsPopovers.swift b/RxCode/Views/Sidebar/RateLimitStatsPopovers.swift new file mode 100644 index 00000000..8a443815 --- /dev/null +++ b/RxCode/Views/Sidebar/RateLimitStatsPopovers.swift @@ -0,0 +1,874 @@ +import Charts +import SwiftUI +import RxCodeCore + +/// Shared title block for the usage-limit detail popovers. Content scrolls +/// once it outgrows the popover's height cap. +private struct RateLimitPopoverScaffold: View { + let title: String + let subtitle: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(subtitle) + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + CappedScrollView(maxHeight: 560) { + VStack(alignment: .leading, spacing: 14) { + content + } + } + } + .padding(18) + .frame(width: 680, alignment: .leading) + } +} + +/// Scrolls its content once it grows past `maxHeight`; shorter content keeps +/// its natural height so the popover doesn't show empty space. +private struct CappedScrollView: View { + let maxHeight: CGFloat + @ViewBuilder let content: Content + + @State private var contentHeight: CGFloat = 0 + + var body: some View { + ScrollView { + content + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { contentHeight = $0 } + } + .scrollIndicators(.automatic) + .frame(height: min(max(contentHeight, 1), maxHeight)) + } +} + +/// "How to read this" block: each term shown in the table next to what it +/// means. +private struct RateLimitGlossary: View { + struct Entry: Identifiable { + let term: LocalizedStringKey + let detail: LocalizedStringKey + let id = UUID() + } + + let entries: [Entry] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("How to read this") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + .tracking(0.6) + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 4) { + ForEach(entries) { entry in + GridRow { + Text(entry.term) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + .gridColumnAlignment(.trailing) + Text(entry.detail) + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium, style: .continuous) + .fill(ClaudeTheme.surfaceSecondary.opacity(0.6)) + ) + } +} + +/// One provider's block in a popover table: colored header, then rows +/// separated by dividers. +private struct ProviderTableSection: View { + let provider: AgentProvider + let rows: [Row] + @ViewBuilder let row: (Row) -> RowContent + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + sectionHeader(provider) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + .padding(.bottom, 6) + Divider() + ForEach(Array(rows.enumerated()), id: \.element.id) { index, item in + if index > 0 { Divider().opacity(0.5) } + row(item) + .padding(.vertical, 6) + } + } + } +} + +private func emptyMessage(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(.system(size: 12)) + .foregroundStyle(ClaudeTheme.textTertiary) + .frame(maxWidth: .infinity, minHeight: 120) +} + +private func providerColor(_ provider: AgentProvider) -> Color { + UsageBreakdownPopover.Slice.palette[provider == .codex ? 1 : 0] +} + +/// False for plans without a separate 5-hour limit. +@MainActor +private func hasFiveHourLimit(_ provider: AgentProvider, in appState: AppState) -> Bool { + appState.cachedRateLimitUsage(for: provider)?.hasFiveHourLimit ?? true +} + +private func sectionHeader(_ provider: AgentProvider) -> some View { + HStack(spacing: 6) { + Circle().fill(providerColor(provider)).frame(width: 7, height: 7) + Text(provider.displayNameText) + } +} + +// MARK: - History + +/// Popover with the 5-hour and 7-day usage-limit history of every provider as +/// a line chart. +struct RateLimitHistoryPopover: View { + @Environment(AppState.self) private var appState + + let providers: [AgentProvider] + let range: UsageStatsRange + + @State private var samples: [AgentProvider: [RateLimitSamplePoint]] = [:] + @State private var selectedDate: Date? + /// Comma-separated raw values of the series switched off in the filter. + @AppStorage("rateLimitHistoryHiddenProviders") private var hiddenProvidersRaw = "" + @AppStorage("rateLimitHistoryHiddenWindows") private var hiddenWindowsRaw = "" + @State private var showsFilter = false + + /// Points plotted per provider; longer histories are thinned evenly. + private static let maxPointsPerProvider = 400 + + private enum LimitWindow: String, CaseIterable { + case fiveHour + case sevenDay + + var title: LocalizedStringKey { + switch self { + case .fiveHour: "5-hour limit" + case .sevenDay: "7-day limit" + } + } + } + + private struct Point: Identifiable { + let provider: AgentProvider + let window: LimitWindow + let date: Date + let percent: Double + var id: String { "\(provider.rawValue)|\(window.rawValue)|\(date.timeIntervalSince1970)" } + } + + private var hiddenProviders: Set { + Set(hiddenProvidersRaw.split(separator: ",").map(String.init)) + } + + private var hiddenWindows: Set { + Set(hiddenWindowsRaw.split(separator: ",").map(String.init)) + } + + private var visibleProviders: [AgentProvider] { + providers.filter { !hiddenProviders.contains($0.rawValue) } + } + + private var visibleWindows: [LimitWindow] { + LimitWindow.allCases.filter { !hiddenWindows.contains($0.rawValue) } + } + + /// Visible windows the provider's plan actually has. + private func windows(for provider: AgentProvider) -> [LimitWindow] { + hasFiveHourLimit(provider, in: appState) ? visibleWindows : visibleWindows.filter { $0 != .fiveHour } + } + + private var points: [Point] { + visibleProviders.flatMap { provider in + (samples[provider] ?? []).flatMap { sample in + windows(for: provider).map { window in + Point( + provider: provider, + window: window, + date: sample.capturedAt, + percent: window == .fiveHour ? sample.fiveHourPercent : sample.sevenDayPercent + ) + } + } + } + } + + private var isFiltered: Bool { + !hiddenProviders.isEmpty || !hiddenWindows.isEmpty + } + + private var isEmpty: Bool { + samples.values.allSatisfy(\.isEmpty) + } + + var body: some View { + RateLimitPopoverScaffold( + title: String(localized: "Usage-limit history"), + subtitle: String(localized: "\(range.localizedLongTitle) · share of each limit used. Solid lines are 5-hour limits, dashed lines 7-day limits.") + ) { + if isEmpty { + emptyMessage("No usage-limit history in this window yet.") + } else { + chart + legend + RateLimitGlossary(entries: [ + .init(term: "Line", detail: "One provider's limit. Its height is the share of that limit used at that moment; it drops when the limit resets."), + .init(term: "Hover", detail: "Point at the chart to read every provider's usage at that time."), + .init(term: "Filter", detail: "Use the filter button to hide providers or limits. Your choice is remembered."), + .init(term: "7-day only", detail: "Plans without a separate 5-hour limit show only their 7-day line."), + ]) + } + } + .task(id: appState.rateLimitHistoryRevision) { + var result: [AgentProvider: [RateLimitSamplePoint]] = [:] + for provider in providers { + result[provider] = Self.thinned(appState.rateLimitSamples(for: provider, range: range)) + } + samples = result + } + } + + private static func thinned(_ points: [RateLimitSamplePoint]) -> [RateLimitSamplePoint] { + guard points.count > maxPointsPerProvider else { return points } + let step = Double(points.count - 1) / Double(maxPointsPerProvider - 1) + return (0.. some View { + VStack(alignment: .leading, spacing: 3) { + Text(date.formatted(date: .abbreviated, time: .shortened)) + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + ForEach(visibleProviders, id: \.self) { provider in + if let sample = (samples[provider] ?? []).min(by: { + abs($0.capturedAt.timeIntervalSince(date)) < abs($1.capturedAt.timeIntervalSince(date)) + }) { + Text(verbatim: "\(provider.displayNameText) \(readoutValues(sample, provider: provider))") + .font(.system(size: 11, weight: .medium).monospacedDigit()) + } + } + } + .foregroundStyle(ClaudeTheme.textPrimary) + .padding(8) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall, style: .continuous) + .fill(ClaudeTheme.surfaceSecondary) + ) + } + + /// The visible windows' readings, e.g. `36% · 19%`. + private func readoutValues(_ sample: RateLimitSamplePoint, provider: AgentProvider) -> String { + windows(for: provider).map { window in + BriefingRateLimitStatsView.formatPercent(window == .fiveHour ? sample.fiveHourPercent : sample.sevenDayPercent) + } + .joined(separator: " · ") + } + + private var legend: some View { + HStack(spacing: 16) { + ForEach(visibleProviders, id: \.self) { provider in + HStack(spacing: 6) { + Capsule().fill(providerColor(provider)).frame(width: 14, height: 3) + Text(provider.displayNameText) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.textSecondary) + if let latest = samples[provider]?.last, !windows(for: provider).isEmpty { + Text("now \(readoutValues(latest, provider: provider))") + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + } + Spacer() + Text("Solid 5-hour · dashed 7-day") + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + filterButton + } + } + + private var filterButton: some View { + Button { + showsFilter.toggle() + } label: { + Image(systemName: isFiltered + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + .buttonStyle(.borderless) + .help("Choose which providers and limits to show.") + .popover(isPresented: $showsFilter, arrowEdge: .bottom) { + filterPopover + } + } + + private var filterPopover: some View { + VStack(alignment: .leading, spacing: 8) { + filterSectionTitle("Providers") + ForEach(providers, id: \.self) { provider in + Toggle(isOn: visibility(of: provider.rawValue, in: $hiddenProvidersRaw)) { + HStack(spacing: 6) { + Circle().fill(providerColor(provider)).frame(width: 7, height: 7) + Text(provider.displayNameText) + } + } + } + Divider() + filterSectionTitle("Usage limits") + ForEach(LimitWindow.allCases, id: \.self) { window in + Toggle(window.title, isOn: visibility(of: window.rawValue, in: $hiddenWindowsRaw)) + } + } + .toggleStyle(.checkbox) + .font(.system(size: 12)) + .padding(14) + .frame(minWidth: 200, alignment: .leading) + } + + private func filterSectionTitle(_ title: LocalizedStringKey) -> some View { + Text(title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + } + + /// Checkbox binding that is on while `item` isn't in the persisted, + /// comma-separated hidden list. + private func visibility(of item: String, in hiddenRaw: Binding) -> Binding { + Binding( + get: { !hiddenRaw.wrappedValue.split(separator: ",").contains(Substring(item)) }, + set: { isVisible in + var hidden = Set(hiddenRaw.wrappedValue.split(separator: ",").map(String.init)) + if isVisible { + hidden.remove(item) + } else { + hidden.insert(item) + } + hiddenRaw.wrappedValue = hidden.sorted().joined(separator: ",") + } + ) + } +} + +// MARK: - Task costs + +/// Popover listing the usage-limit cost of each measured task per provider, +/// with each provider's most and least expensive tasks called out. +struct RateLimitTaskCostPopover: View { + @Environment(AppState.self) private var appState + + let providers: [AgentProvider] + let range: UsageStatsRange + + @State private var summaries: [AgentProvider: RateLimitTaskCostSummary] = [:] + + private var isEmpty: Bool { + summaries.values.allSatisfy(\.isEmpty) + } + + /// Tasks ranked by 5-hour cost, unmeasurable ones last. + private func rankedTasks(_ summary: RateLimitTaskCostSummary) -> [RateLimitTaskCostSnapshot] { + summary.tasks.sorted { + let lhs = $0.fiveHourDelta ?? -1 + let rhs = $1.fiveHourDelta ?? -1 + if lhs != rhs { return lhs > rhs } + return $0.endedAt > $1.endedAt + } + } + + var body: some View { + RateLimitPopoverScaffold( + title: String(localized: "Limit cost per task"), + subtitle: String(localized: "\(range.localizedLongTitle) · change in each limit between a task's start and finish.") + ) { + if isEmpty { + emptyMessage("No measured tasks in this window yet.") + } else { + ForEach(providers, id: \.self) { provider in + let summary = summaries[provider] ?? RateLimitTaskCostSummary() + if !summary.isEmpty { + ProviderTableSection(provider: provider, rows: rankedTasks(summary)) { task in + row(task, provider: provider, summary: summary) + } + } + } + RateLimitGlossary(entries: [ + .init(term: "Row", detail: "One finished task: its chat, model, finish time and how long it ran."), + .init(term: "5h · 7d", detail: "Percentage points of the 5-hour and 7-day limits the task used, measured from its start to its finish."), + .init(term: "Most · Least", detail: "The provider's most and least expensive tasks in this window."), + .init(term: "—", detail: "The limit reset while the task ran, so its cost can't be measured."), + .init(term: "Ran with others", detail: "Tasks running at the same time share one limit, so their costs are approximate and are left out of the averages once enough solo tasks exist."), + ]) + } + } + .task(id: appState.rateLimitHistoryRevision) { + var result: [AgentProvider: RateLimitTaskCostSummary] = [:] + for provider in providers { + result[provider] = appState.rateLimitTaskCostSummary(for: provider, range: range) + } + summaries = result + } + } + + private func row(_ task: RateLimitTaskCostSnapshot, provider: AgentProvider, summary: RateLimitTaskCostSummary) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(task.threadTitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + if task.id == summary.mostExpensive?.id { + badge("Most") + .help("The most expensive task in this window.") + } else if task.id == summary.leastExpensive?.id { + badge("Least") + .help("The least expensive task in this window.") + } + } + Text(subtitle(for: task, provider: provider)) + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + Spacer(minLength: 8) + if hasFiveHourLimit(provider, in: appState) { + cost("5h used", task.fiveHourDelta) + } + cost("7d used", task.sevenDayDelta) + } + } + + private func subtitle(for task: RateLimitTaskCostSnapshot, provider: AgentProvider) -> String { + var parts: [String] = [] + if !task.model.isEmpty { + parts.append(appState.usageModelDisplayName(task.model, provider: provider)) + } + parts.append(task.endedAt.formatted(date: .abbreviated, time: .shortened)) + parts.append(BriefingUsageStatsView.formatDuration(task.endedAt.timeIntervalSince(task.startedAt))) + if task.concurrentRuns > 0 { + parts.append(task.concurrentRuns == 1 + ? String(localized: "ran with 1 other task") + : String(localized: "ran with \(task.concurrentRuns) other tasks")) + } + return parts.joined(separator: " · ") + } + + private func cost(_ label: LocalizedStringKey, _ delta: Double?) -> some View { + VStack(alignment: .trailing, spacing: 1) { + Text(BriefingRateLimitStatsView.formatOptionalDelta(delta)) + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(label) + .font(.system(size: 10)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .frame(width: 64, alignment: .trailing) + .help(delta == nil + ? String(localized: "The limit reset during this task, so its cost can't be measured.") + : String(localized: "Percentage points of this limit the task used.")) + } + + private func badge(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(ClaudeTheme.accent.opacity(0.12))) + } +} + +// MARK: - Model estimates + +/// Popover with limit cost and remaining-task estimates per model for every +/// provider, plus advice for the model each provider would run next. +struct RateLimitModelEstimatePopover: View { + @Environment(AppState.self) private var appState + + let providers: [AgentProvider] + let range: UsageStatsRange + + @State private var summaries: [AgentProvider: RateLimitTaskCostSummary] = [:] + @State private var weeklyCosts: [AgentProvider: [RateLimitWeeklyCost]] = [:] + + private var isEmpty: Bool { + summaries.values.allSatisfy { $0.models.isEmpty } + } + + /// The model advice is given for: the selected model when the provider is + /// selected, otherwise its most recently used model. + private func focusModel(for provider: AgentProvider) -> String? { + if appState.selectedAgentProvider == provider { return appState.selectedModel } + return summaries[provider]?.tasks.first?.model + } + + /// Model drawing down the limit fastest per token, when it's comparable. + private func highestTierModel(_ summary: RateLimitTaskCostSummary) -> String? { + let rated = summary.models.filter { $0.fiveHourPerMillionTokens != nil } + guard rated.count > 1 else { return nil } + return rated.max { ($0.fiveHourPerMillionTokens ?? 0) < ($1.fiveHourPerMillionTokens ?? 0) }?.model + } + + var body: some View { + RateLimitPopoverScaffold( + title: String(localized: "Estimates by model"), + subtitle: String(localized: "\(range.localizedLongTitle) · tasks of each model's average cost that fit in what's left of each limit.") + ) { + ForEach(providers, id: \.self) { provider in + if let advice = appState.rateLimitAdvice(for: provider, model: focusModel(for: provider)) { + adviceCard(advice, provider: provider) + } + } + + if isEmpty { + emptyMessage("No measured tasks in this window yet.") + } else { + ForEach(providers, id: \.self) { provider in + let summary = summaries[provider] ?? RateLimitTaskCostSummary() + if !summary.models.isEmpty { + let topTier = highestTierModel(summary) + ProviderTableSection(provider: provider, rows: summary.models) { stats in + row(stats, provider: provider, isHighestTier: stats.model == topTier) + } + } + } + if !perMillionBars.isEmpty { + perMillionChart + } + RateLimitGlossary(entries: [ + .init(term: "5h / task", detail: "Average share of the 5-hour limit one task of this model uses. Plans without a 5-hour limit show 7d / task instead."), + .init(term: "Left in 5h · 7d", detail: "How many more tasks of that average cost fit before each limit is reached, based on what's used now."), + .init(term: "Many", detail: "Tasks so far barely moved the limit, so there's no practical cap yet."), + .init(term: "—", detail: "Not enough measured tasks to estimate."), + .init(term: "Highest tier", detail: "The model that uses the most limit for the same number of tokens."), + .init(term: "Per 1M tokens", detail: "Share of each limit a million tokens uses. Tokens are input and output tokens, including cache writes; cache reads aren't counted. Larger models cost more for the same tokens; for light tasks a cheaper model usually does the job and leaves more room. 0% means the limit didn't move, which is common for the 7-day limit."), + ]) + } + if !weeklyPoints.isEmpty { + weeklyChart + } + } + .task(id: appState.rateLimitHistoryRevision) { + var result: [AgentProvider: RateLimitTaskCostSummary] = [:] + var weekly: [AgentProvider: [RateLimitWeeklyCost]] = [:] + for provider in providers { + result[provider] = appState.rateLimitTaskCostSummary(for: provider, range: range) + weekly[provider] = appState.rateLimitWeeklyCosts(for: provider) + } + summaries = result + weeklyCosts = weekly + } + } + + private func adviceCard(_ advice: RateLimitAdvice, provider: AgentProvider) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: advice.severity == .warning ? "exclamationmark.triangle.fill" : "lightbulb") + .foregroundStyle(advice.severity == .warning ? ClaudeTheme.statusWarning : ClaudeTheme.accent) + Text(focusModel(for: provider).map { "\(provider.displayNameText) · \(appState.usageModelDisplayName($0, provider: provider))" } + ?? provider.displayNameText) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + } + ForEach(advice.reasons, id: \.self) { reason in + Text(reason) + .font(.system(size: 11.5)) + .foregroundStyle(ClaudeTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + ForEach(advice.suggestions) { suggestion in + Text("• \(suggestion.title) — \(suggestion.detail)") + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium, style: .continuous) + .fill(ClaudeTheme.surfaceSecondary) + ) + } + + private func row(_ stats: RateLimitTaskCostSummary.ModelStats, provider: AgentProvider, isHighestTier: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(modelLabel(stats, provider: provider)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + if isHighestTier { + Text("Highest tier") + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(ClaudeTheme.accent.opacity(0.12))) + } + } + Text(subtitle(for: stats)) + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + Spacer(minLength: 8) + if hasFiveHourLimit(provider, in: appState) { + column("5h / task", BriefingRateLimitStatsView.formatOptionalDelta(stats.averageFiveHour), + help: "Average share of the 5-hour limit one task uses.") + column("Left in 5h", BriefingRateLimitStatsView.formatEstimate(stats.remainingFiveHour), + help: "Tasks of average cost that still fit in the 5-hour limit.") + } else { + column("7d / task", BriefingRateLimitStatsView.formatOptionalDelta(stats.averageSevenDay), + help: "Average share of the 7-day limit one task uses.") + } + column("Left in 7d", BriefingRateLimitStatsView.formatEstimate(stats.remainingSevenDay), + help: "Tasks of average cost that still fit in the 7-day limit.") + } + } + + private func subtitle(for stats: RateLimitTaskCostSummary.ModelStats) -> String { + var parts = [stats.sampleCount == 1 ? String(localized: "1 task") : String(localized: "\(stats.sampleCount) tasks")] + if stats.medianTokens > 0 { + parts.append(String(localized: "median \(BriefingUsageStatsView.formatTokens(Int(stats.medianTokens))) tokens")) + } + return parts.joined(separator: " · ") + } + + // MARK: - Cost per million tokens + + private struct PerMillionBar: Identifiable { + let provider: AgentProvider + let model: String + let window: String + let value: Double + var id: String { "\(provider.rawValue)|\(model)|\(window)" } + } + + /// One bar per model and limit: percentage points of the limit that a + /// million tokens cost. + private var perMillionBars: [PerMillionBar] { + let fiveHour = String(localized: "5-hour limit") + let sevenDay = String(localized: "7-day limit") + return providers.flatMap { provider in + let hasFiveHour = hasFiveHourLimit(provider, in: appState) + return (summaries[provider]?.models ?? []).flatMap { stats -> [PerMillionBar] in + let model = modelLabel(stats, provider: provider) + var bars: [PerMillionBar] = [] + if hasFiveHour, let value = stats.fiveHourPerMillionTokens { + bars.append(PerMillionBar(provider: provider, model: model, window: fiveHour, value: value)) + } + if let value = stats.sevenDayPerMillionTokens { + bars.append(PerMillionBar(provider: provider, model: model, window: sevenDay, value: value)) + } + return bars + } + } + } + + private var perMillionChart: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Limit used per 1M tokens") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + .tracking(0.6) + Text("Counts input and output tokens, including cache writes. Cache reads are left out because they barely count toward limits.") + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Chart(perMillionBars) { bar in + BarMark( + x: .value("Limit used", bar.value), + y: .value("Model", bar.model) + ) + .foregroundStyle(by: .value("Limit", bar.window)) + .position(by: .value("Limit", bar.window)) + .annotation(position: .trailing, spacing: 4) { + Text(BriefingRateLimitStatsView.formatDelta(bar.value)) + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(ClaudeTheme.textSecondary) + } + } + .chartForegroundStyleScale( + domain: [String(localized: "5-hour limit"), String(localized: "7-day limit")], + range: [UsageBreakdownPopover.Slice.palette[0], UsageBreakdownPopover.Slice.palette[2]] + ) + .chartXAxis { + AxisMarks { value in + AxisGridLine() + AxisValueLabel { + if let percent = value.as(Double.self) { + Text(BriefingRateLimitStatsView.formatDelta(percent)) + } + } + } + } + .chartLegend(position: .top, alignment: .leading) + .frame(height: CGFloat(max(Set(perMillionBars.map(\.model)).count, 1)) * 44 + 40) + } + } + + // MARK: - Weekly trend + + private struct WeeklyPoint: Identifiable { + let provider: AgentProvider + let isFiveHour: Bool + let week: RateLimitWeeklyCost + let value: Double + var id: String { "\(provider.rawValue)|\(isFiveHour)|\(week.weekStart.timeIntervalSince1970)" } + } + + /// One point per provider, limit and week. + private var weeklyPoints: [WeeklyPoint] { + providers.flatMap { provider in + let hasFiveHour = hasFiveHourLimit(provider, in: appState) + return (weeklyCosts[provider] ?? []).flatMap { week -> [WeeklyPoint] in + var points: [WeeklyPoint] = [] + if hasFiveHour, let value = week.fiveHourPerMillionTokens { + points.append(WeeklyPoint(provider: provider, isFiveHour: true, week: week, value: value)) + } + if let value = week.sevenDayPerMillionTokens { + points.append(WeeklyPoint(provider: provider, isFiveHour: false, week: week, value: value)) + } + return points + } + } + } + + private var weeklyChart: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Limit used per 1M tokens by week") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + .tracking(0.6) + Text("Last 12 weeks, regardless of the window picked above. A rising line means the same tokens cost more of the limit than before. Solid lines are 5-hour limits, dashed lines 7-day limits.") + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Chart(weeklyPoints) { point in + LineMark( + x: .value("Week", point.week.weekStart, unit: .weekOfYear), + y: .value("Limit used", point.value), + series: .value("Series", "\(point.provider.rawValue) \(point.isFiveHour)") + ) + .foregroundStyle(by: .value("Provider", point.provider.displayNameText)) + .lineStyle(point.isFiveHour + ? StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round) + : StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round, dash: [4, 3])) + PointMark( + x: .value("Week", point.week.weekStart, unit: .weekOfYear), + y: .value("Limit used", point.value) + ) + .foregroundStyle(by: .value("Provider", point.provider.displayNameText)) + .symbolSize(24) + } + .chartForegroundStyleScale( + domain: providers.map(\.displayNameText), + range: providers.map(providerColor) + ) + .chartXAxis { + AxisMarks(values: .stride(by: .weekOfYear)) { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.month(.abbreviated).day(), centered: true) + } + } + .chartYAxis { + AxisMarks { value in + AxisGridLine() + AxisValueLabel { + if let percent = value.as(Double.self) { + Text(BriefingRateLimitStatsView.formatDelta(percent)) + } + } + } + } + .chartLegend(position: .top, alignment: .leading) + .frame(height: 200) + } + } + + private func modelLabel(_ stats: RateLimitTaskCostSummary.ModelStats, provider: AgentProvider) -> String { + stats.model.isEmpty ? String(localized: "Default") : appState.usageModelDisplayName(stats.model, provider: provider) + } + + private func column(_ label: LocalizedStringKey, _ value: String, help: LocalizedStringKey) -> some View { + VStack(alignment: .trailing, spacing: 1) { + Text(value) + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(label) + .font(.system(size: 10)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .frame(width: 84, alignment: .trailing) + .help(help) + } +} diff --git a/RxCode/Views/Sidebar/UsageBreakdownPopover.swift b/RxCode/Views/Sidebar/UsageBreakdownPopover.swift index 5adeb546..b898e0a2 100644 --- a/RxCode/Views/Sidebar/UsageBreakdownPopover.swift +++ b/RxCode/Views/Sidebar/UsageBreakdownPopover.swift @@ -41,8 +41,8 @@ struct UsageBreakdownPopover: View { if !rest.isEmpty { slices.append(Slice( id: "other", - label: "Other", - detail: "\(rest.count) more", + label: String(localized: "Other"), + detail: String(localized: "\(rest.count) more"), value: rest.reduce(0) { $0 + $1.value }, color: otherColor )) diff --git a/RxCodeTests/CodexTokenUsageTests.swift b/RxCodeTests/CodexTokenUsageTests.swift new file mode 100644 index 00000000..c67370c3 --- /dev/null +++ b/RxCodeTests/CodexTokenUsageTests.swift @@ -0,0 +1,67 @@ +import RxCodeCore +import XCTest +@testable import RxCode + +/// Codex's `turn/completed` carries no usage, so per-turn tokens must come from +/// the cumulative `thread/tokenUsage/updated` snapshots. Without this the +/// briefing token-usage panel never counted Codex turns. +final class CodexTokenUsageTests: XCTestCase { + + private func breakdown(input: Int, cached: Int, output: Int, reasoning: Int = 0) -> JSONValue { + .object([ + "totalTokens": .number(Double(input + output)), + "inputTokens": .number(Double(input)), + "cachedInputTokens": .number(Double(cached)), + "outputTokens": .number(Double(output)), + "reasoningOutputTokens": .number(Double(reasoning)), + ]) + } + + private func params(total: JSONValue, last: JSONValue) -> [String: JSONValue] { + [ + "threadId": .string("thread-1"), + "turnId": .string("turn-1"), + "tokenUsage": .object([ + "total": total, + "last": last, + "modelContextWindow": .number(272_000), + ]), + ] + } + + func testBreakdownSplitsCachedInputAndDoesNotDoubleCountReasoning() throws { + let parsed = try XCTUnwrap(CodexAppServer.tokenUsageBreakdowns(from: params( + total: breakdown(input: 1_000, cached: 800, output: 150, reasoning: 50), + last: breakdown(input: 1_000, cached: 800, output: 150, reasoning: 50) + ))) + XCTAssertEqual(parsed.total.inputTokens, 200) + XCTAssertEqual(parsed.total.cacheReadInputTokens, 800) + XCTAssertEqual(parsed.total.outputTokens, 150) + XCTAssertEqual(parsed.total.cacheCreationInputTokens, 0) + } + + func testTurnUsageIsDeltaFromPreTurnBaseline() throws { + // Earlier turns already accrued 5,000 input (4,000 cached) / 300 output. + let first = try XCTUnwrap(CodexAppServer.tokenUsageBreakdowns(from: params( + total: breakdown(input: 6_000, cached: 4_500, output: 400), + last: breakdown(input: 1_000, cached: 500, output: 100) + ))) + let baseline = try XCTUnwrap(CodexAppServer.usageDelta(from: first.last, to: first.total)) + let second = try XCTUnwrap(CodexAppServer.tokenUsageBreakdowns(from: params( + total: breakdown(input: 7_500, cached: 5_500, output: 600), + last: breakdown(input: 1_500, cached: 1_000, output: 200) + ))) + + let turn = try XCTUnwrap(CodexAppServer.usageDelta(from: baseline, to: second.total)) + XCTAssertEqual(turn.inputTokens, 1_000) + XCTAssertEqual(turn.cacheReadInputTokens, 1_500) + XCTAssertEqual(turn.outputTokens, 300) + } + + func testUsageDeltaIsNilWhenNothingAccrued() { + let usage = CodexAppServer.codexUsageBreakdown([ + "inputTokens": .number(10), "outputTokens": .number(5), + ]) + XCTAssertNil(CodexAppServer.usageDelta(from: usage, to: usage)) + } +}