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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Packages/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions Packages/Sources/RxCodeChatKit/ChatBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
73 changes: 73 additions & 0 deletions Packages/Sources/RxCodeChatKit/RateLimitAdvicePopover.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
26 changes: 12 additions & 14 deletions Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1228,8 +1228,6 @@
}
},
"Combine every queued message and send it as a single turn" : {


"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand Down Expand Up @@ -1740,8 +1738,6 @@
}
},
"Couldn't reach the current response — still queued, sends when it ends." : {


"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand Down Expand Up @@ -3152,8 +3148,6 @@
}
},
"Interrupt and send all now" : {


"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand All @@ -3164,8 +3158,6 @@
}
},
"Interrupt and send now" : {


"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand Down Expand Up @@ -4978,8 +4970,6 @@
}
},
"Send before the current response ends — steer it in, or interrupt" : {


"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand Down Expand Up @@ -5516,8 +5506,6 @@
}
},
"Steer all into current response" : {


"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand All @@ -5528,8 +5516,6 @@
}
},
"Steer into current response" : {


"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand Down Expand Up @@ -5598,6 +5584,12 @@
}
}
}
},
"Suggestions" : {

},
"Switch" : {

},
"Switch to horizontal scroll" : {
"localizations" : {
Expand Down Expand Up @@ -6080,6 +6072,12 @@
}
}
}
},
"Usage limit running low" : {

},
"Usage limit tip" : {

},
"Usage:" : {
"localizations" : {
Expand Down
50 changes: 41 additions & 9 deletions Packages/Sources/RxCodeChatKit/StatusLineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,8 @@ struct StatusLineView: View {

usageSegments()

rateLimitAdviceSegment()

Divider().frame(height: 12)

contextSegment()
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
Loading
Loading