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
2 changes: 2 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ The dialog offers:
- **`r` / `Enter`** — retry immediately.
- **`s` / `h`** — switch to Sonnet or Haiku and retry at once. Anthropic
Max limits are model-tier scoped, so a lower tier usually still works.
Offered only when the active provider is Anthropic — switching a Codex
session to an Anthropic model would persist a broken provider config.
- **`d`** — clean duplicate account profiles. If multiple stored profiles
point at the same underlying account (repeated Claude Code credential
imports), switching between them cannot escape the limit; this collapses
Expand Down
13 changes: 9 additions & 4 deletions src-rust/crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3004,15 +3004,16 @@ impl App {
/// event handling, never in a render path) so the modal can tell the user
/// when "switch accounts" would be a no-op and offer a one-key cleanup.
pub fn open_rate_limit_recovery(&mut self, message: String, retry_after_secs: Option<u64>) {
let duplicates = if self.config.provider.as_deref().unwrap_or("anthropic") == "anthropic" {
let is_anthropic = self.config.provider.as_deref().unwrap_or("anthropic") == "anthropic";
let duplicates = if is_anthropic {
let registry = claurst_core::accounts::AccountRegistry::load();
claurst_core::accounts::count_duplicate_anthropic_profiles(&registry)
} else {
0
};
let model = self.model_name.clone();
self.rate_limit_recovery
.open(message, model, retry_after_secs, duplicates);
.open(message, model, retry_after_secs, duplicates, is_anthropic);
}

/// Handle a key press while the recovery modal is open.
Expand All @@ -3029,7 +3030,8 @@ impl App {
self.status_message = Some("Retrying…".to_string());
}
KeyCode::Char('s')
if !self.rate_limit_recovery.model.contains("sonnet")
if self.rate_limit_recovery.tier_switch_available
&& !self.rate_limit_recovery.model.contains("sonnet")
&& !self.rate_limit_recovery.model.contains("haiku") =>
{
self.set_model(SONNET_MODEL.to_string());
Expand All @@ -3038,7 +3040,10 @@ impl App {
.request_retry(Some(SONNET_MODEL.to_string()));
self.status_message = Some(format!("Retrying on {}…", SONNET_MODEL));
}
KeyCode::Char('h') if !self.rate_limit_recovery.model.contains("haiku") => {
KeyCode::Char('h')
if self.rate_limit_recovery.tier_switch_available
&& !self.rate_limit_recovery.model.contains("haiku") =>
{
self.set_model(HAIKU_MODEL.to_string());
self.persist_provider_and_model();
self.rate_limit_recovery
Expand Down
95 changes: 78 additions & 17 deletions src-rust/crates/tui/src/rate_limit_recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ pub struct RateLimitRecoveryState {
/// Redundant duplicate profiles detected in the account registry
/// (same underlying account imported more than once).
pub duplicate_profiles: usize,
/// Whether the one-key Anthropic tier-switch actions (`s`/`h`) apply.
/// False when the active provider is not Anthropic — switching a Codex
/// session to Sonnet/Haiku would persist a broken provider config.
pub tier_switch_available: bool,
/// Retry directive waiting to be consumed by the main loop.
pending_retry: Option<RetryDirective>,
}
Expand All @@ -77,12 +81,14 @@ impl RateLimitRecoveryState {
model: String,
retry_after_secs: Option<u64>,
duplicate_profiles: usize,
tier_switch_available: bool,
) {
self.visible = true;
self.message = message;
self.model = model;
self.retry_after_secs = retry_after_secs;
self.duplicate_profiles = duplicate_profiles;
self.tier_switch_available = tier_switch_available;
self.pending_retry = None;
self.auto_retry_deadline = retry_after_secs
.map(Duration::from_secs)
Expand Down Expand Up @@ -282,13 +288,15 @@ fn action_lines(state: &RateLimitRecoveryState) -> Vec<Line<'static>> {

let mut keys: Vec<Span<'static>> = vec![Span::styled("[r]", key_style)];
keys.push(Span::styled(" retry now ", text_style));
if !state.is_sonnet() && !state.is_haiku() {
keys.push(Span::styled("[s]", key_style));
keys.push(Span::styled(" continue on Sonnet ", text_style));
}
if !state.is_haiku() {
keys.push(Span::styled("[h]", key_style));
keys.push(Span::styled(" continue on Haiku", text_style));
if state.tier_switch_available {
if !state.is_sonnet() && !state.is_haiku() {
keys.push(Span::styled("[s]", key_style));
keys.push(Span::styled(" continue on Sonnet ", text_style));
}
if !state.is_haiku() {
keys.push(Span::styled("[h]", key_style));
keys.push(Span::styled(" continue on Haiku", text_style));
}
}
lines.push(Line::from(keys));

Expand Down Expand Up @@ -325,7 +333,13 @@ mod tests {
#[test]
fn open_arms_countdown_for_short_delays() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "claude-opus-4-8".into(), Some(30), 0);
s.open(
"limited".into(),
"claude-opus-4-8".into(),
Some(30),
0,
true,
);
assert!(s.visible);
assert!(s.auto_retry_deadline.is_some());
assert!(s.countdown_secs().unwrap() <= 30);
Expand All @@ -334,15 +348,21 @@ mod tests {
#[test]
fn open_does_not_arm_countdown_for_long_delays() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "claude-opus-4-8".into(), Some(3600), 0);
s.open(
"limited".into(),
"claude-opus-4-8".into(),
Some(3600),
0,
true,
);
assert!(s.visible);
assert!(s.auto_retry_deadline.is_none());
}

#[test]
fn open_does_not_arm_countdown_without_retry_after() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "claude-opus-4-8".into(), None, 0);
s.open("limited".into(), "claude-opus-4-8".into(), None, 0, true);
assert!(s.auto_retry_deadline.is_none());
}

Expand All @@ -352,20 +372,38 @@ mod tests {
auto_retries_used: MAX_AUTO_RETRIES,
..Default::default()
};
s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0);
s.open(
"limited".into(),
"claude-opus-4-8".into(),
Some(10),
0,
true,
);
assert!(
s.auto_retry_deadline.is_none(),
"budget exhausted — no more auto-retries"
);
s.reset_episode();
s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0);
s.open(
"limited".into(),
"claude-opus-4-8".into(),
Some(10),
0,
true,
);
assert!(s.auto_retry_deadline.is_some());
}

#[test]
fn tick_fires_retry_at_deadline() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0);
s.open(
"limited".into(),
"claude-opus-4-8".into(),
Some(10),
0,
true,
);
// Force the deadline into the past.
s.auto_retry_deadline = Some(Instant::now() - Duration::from_secs(1));
s.tick();
Expand All @@ -379,7 +417,13 @@ mod tests {
#[test]
fn manual_retry_with_model_switch() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "claude-opus-4-8".into(), Some(3600), 0);
s.open(
"limited".into(),
"claude-opus-4-8".into(),
Some(3600),
0,
true,
);
s.request_retry(Some(HAIKU_MODEL.to_string()));
assert!(!s.visible);
let directive = s.take_retry_directive().expect("retry queued");
Expand All @@ -389,7 +433,13 @@ mod tests {
#[test]
fn dismiss_cancels_everything() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0);
s.open(
"limited".into(),
"claude-opus-4-8".into(),
Some(10),
0,
true,
);
s.dismiss();
assert!(!s.visible);
s.tick();
Expand All @@ -399,7 +449,7 @@ mod tests {
#[test]
fn tier_actions_reflect_current_model() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "claude-opus-4-8".into(), None, 0);
s.open("limited".into(), "claude-opus-4-8".into(), None, 0, true);
let text = lines_text(&action_lines(&s));
assert!(text.contains("[s]"));
assert!(text.contains("[h]"));
Expand All @@ -415,10 +465,20 @@ mod tests {
assert!(!text.contains("[h]"));
}

#[test]
fn tier_actions_hidden_for_non_anthropic_provider() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "gpt-5-codex".into(), None, 0, false);
let text = lines_text(&action_lines(&s));
assert!(!text.contains("[s]"));
assert!(!text.contains("[h]"));
assert!(text.contains("[r]"));
}

#[test]
fn duplicate_cleanup_action_appears_when_duplicates_exist() {
let mut s = RateLimitRecoveryState::default();
s.open("limited".into(), "claude-opus-4-8".into(), None, 18);
s.open("limited".into(), "claude-opus-4-8".into(), None, 18, true);
let text = lines_text(&action_lines(&s));
assert!(text.contains("[d]"));
assert!(text.contains("18 duplicate account profiles"));
Expand All @@ -436,6 +496,7 @@ mod tests {
"claude-opus-4-8".into(),
Some(30),
2,
true,
);
let area = Rect {
x: 0,
Expand Down