Improve provider reliability and usage visibility

This commit is contained in:
2026-08-21 19:12:14 -05:00
parent 19b2c5f687
commit be1dbb600a
28 changed files with 1070 additions and 592 deletions
+15 -17
View File
@@ -857,26 +857,24 @@ impl BlocklistAIActionModel {
});
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, |me, _, event, ctx| {
match event {
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
ctx.subscribe_to_model(&history_model, |me, _, event, ctx| match event {
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
me.cleanup_conversation_state(*conversation_id, ctx);
}
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface {
cleared_conversation_ids,
..
} => {
for conversation_id in cleared_conversation_ids {
me.cleanup_conversation_state(*conversation_id, ctx);
}
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface {
cleared_conversation_ids,
..
} => {
for conversation_id in cleared_conversation_ids {
me.cleanup_conversation_state(*conversation_id, ctx);
}
}
_ => {}
}
_ => {}
});
Self {
@@ -55,7 +55,9 @@ use crate::ai::blocklist::prompt::prompt_alert::{PromptAlertEvent, PromptAlertVi
use crate::ai::blocklist::usage::icon_for_context_window_usage;
use crate::ai::blocklist::BlocklistAIInputModel;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::AIExecutionProfileAppExt;
use crate::ai::harness_availability::HarnessAvailabilityModel;
use crate::ai::llms::LLMPreferences;
use crate::ai::AIRequestUsageModel;
use crate::appearance::Appearance;
use crate::auth::{AuthManager, AuthStateProvider};
@@ -123,6 +125,16 @@ const FAST_FORWARD_LOCKED_TOOLTIP: &str =
const CLOUD_MODE_V2_FOOTER_GAP: f32 = 4.;
fn format_llm_token_count(tokens: u32) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}k", tokens as f64 / 1_000.0)
} else {
tokens.to_string()
}
}
/// Voice input state for the CLI agent footer. Unlike the editor-based voice
/// flow (which goes through Input → EditorView), this state is self-contained
/// so that transcribed text can be written directly to the PTY.
@@ -194,6 +206,8 @@ pub struct AgentInputFooter {
nld_button: ViewHandle<ActionButton>,
file_button: ViewHandle<ActionButton>,
context_window_button: ViewHandle<ActionButton>,
llm_context_usage_button: ViewHandle<ActionButton>,
llm_cache_details_button: ViewHandle<ActionButton>,
model_selector: ViewHandle<ProfileModelSelector>,
environment_selector: Option<ViewHandle<EnvironmentSelector>>,
handoff_environment_selector: ViewHandle<EnvironmentSelector>,
@@ -610,6 +624,22 @@ impl AgentInputFooter {
.with_tooltip_alignment(TooltipAlignment::Left)
});
let llm_context_usage_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Ctx —", AgentInputButtonTheme)
.with_icon(Icon::ContextRemaining100)
.with_tooltip("LLM context usage is available after the first model call")
.with_size(button_size)
.with_tooltip_alignment(TooltipAlignment::Left)
});
let llm_cache_details_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Cache —", AgentInputButtonTheme)
.with_icon(Icon::LayersThree01)
.with_tooltip("LLM cache details are available after the first model call")
.with_size(button_size)
.with_tooltip_alignment(TooltipAlignment::Left)
});
let profile_model_selector_full = ctx.add_typed_action_view(|ctx| {
let mut selector = ProfileModelSelector::new(
menu_positioning_provider.clone(),
@@ -752,7 +782,12 @@ impl AgentInputFooter {
},
);
// Subscribe to AIExecutionProfilesModel to potentially show/hide the profile selector button when profiles are added/removed
ctx.subscribe_to_model(&AIExecutionProfilesModel::handle(ctx), |_, _, _, ctx| {
ctx.subscribe_to_model(&AIExecutionProfilesModel::handle(ctx), |me, _, _, ctx| {
me.update_llm_usage_buttons(ctx);
ctx.notify();
});
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, _, ctx| {
me.update_llm_usage_buttons(ctx);
ctx.notify();
});
@@ -775,6 +810,7 @@ impl AgentInputFooter {
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } => {
me.sync_fast_forward_button(ctx);
me.update_context_window_button(ctx);
me.update_llm_usage_buttons(ctx);
me.model_selector.update(ctx, |_, ctx| ctx.notify());
ctx.notify();
}
@@ -783,6 +819,7 @@ impl AgentInputFooter {
| BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. } => {
me.update_context_window_button(ctx);
me.update_llm_usage_buttons(ctx);
me.model_selector.update(ctx, |_, ctx| ctx.notify());
ctx.notify();
}
@@ -836,6 +873,8 @@ impl AgentInputFooter {
plugin_operation_in_progress: false,
plugin_chip_ready: false,
context_window_button,
llm_context_usage_button,
llm_cache_details_button,
model_selector: profile_model_selector_full,
environment_selector,
handoff_environment_selector,
@@ -860,6 +899,7 @@ impl AgentInputFooter {
};
me.sync_fast_forward_button(ctx);
me.update_context_window_button(ctx);
me.update_llm_usage_buttons(ctx);
me.update_display_chips(&prompt, ctx);
me
}
@@ -1463,6 +1503,8 @@ impl AgentInputFooter {
AgentToolbarItemKind::ModelSelector
| AgentToolbarItemKind::NLDToggle
| AgentToolbarItemKind::ContextWindowUsage
| AgentToolbarItemKind::LLMContextUsage
| AgentToolbarItemKind::LLMCacheDetails
| AgentToolbarItemKind::FastForwardToggle
| AgentToolbarItemKind::HandoffToCloud => None,
}
@@ -1970,6 +2012,101 @@ impl AgentInputFooter {
}
}
fn update_llm_usage_buttons(&self, ctx: &mut ViewContext<Self>) {
let metrics = BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.terminal_view_id)
.map(|conversation| {
(
conversation.current_context_tokens(),
conversation.context_window_usage(),
conversation.latest_model_call_cache_read_tokens(),
conversation.latest_model_call_cache_write_tokens(),
conversation.latest_model_call_cache_miss_tokens(),
)
});
let Some((current_context, reported_usage, cache_read, cache_write, cache_miss)) = metrics
else {
self.llm_context_usage_button.update(ctx, |button, ctx| {
button.set_label("Ctx —", ctx);
button.set_icon(Some(Icon::ContextRemaining100), ctx);
button.set_tooltip(
Some("LLM context usage is available after the first model call"),
ctx,
);
});
self.llm_cache_details_button.update(ctx, |button, ctx| {
button.set_label("Cache —", ctx);
button.set_tooltip(
Some("LLM cache details are available after the first model call"),
ctx,
);
});
return;
};
let active_model =
LLMPreferences::as_ref(ctx).get_active_base_model(ctx, Some(self.terminal_view_id));
let profile_context = AIExecutionProfilesModel::as_ref(ctx)
.active_profile(Some(self.terminal_view_id), ctx)
.data()
.context_window_display_value(ctx);
let model_max_context = active_model
.context_window
.default_max
.max(active_model.context_window.max);
let max_context = profile_context
.or((model_max_context > 0).then_some(model_max_context))
.unwrap_or(200_000);
let context_usage = if max_context > 0 && current_context > 0 {
(current_context as f32 / max_context as f32).clamp(0.0, 1.0)
} else {
reported_usage.clamp(0.0, 1.0)
};
let context_pct = context_usage * 100.0;
let context_label = format!(
"Ctx {context_pct:.1}% · {}/{}",
format_llm_token_count(current_context),
format_llm_token_count(max_context),
);
let context_tooltip = format!(
"Current LLM context: {} of {} tokens ({context_pct:.1}%). This is not cumulative.",
format_llm_token_count(current_context),
format_llm_token_count(max_context),
);
let context_icon = icon_for_context_window_usage(context_usage);
self.llm_context_usage_button.update(ctx, |button, ctx| {
button.set_label(context_label, ctx);
button.set_icon(Some(context_icon), ctx);
button.set_tooltip(Some(context_tooltip), ctx);
});
let cache_total = cache_read
.saturating_add(cache_write)
.saturating_add(cache_miss);
let cache_hit_pct = if cache_total > 0 {
cache_read as f64 / cache_total as f64 * 100.0
} else {
0.0
};
let cache_label = format!(
"Cache {cache_hit_pct:.1}% · R {} · W {} · M {}",
format_llm_token_count(cache_read),
format_llm_token_count(cache_write),
format_llm_token_count(cache_miss),
);
let cache_tooltip = format!(
"Latest model call: {} cache-read, {} cache-write, {} cache-miss tokens.",
format_llm_token_count(cache_read),
format_llm_token_count(cache_write),
format_llm_token_count(cache_miss),
);
self.llm_cache_details_button.update(ctx, |button, ctx| {
button.set_label(cache_label, ctx);
button.set_tooltip(Some(cache_tooltip), ctx);
});
}
/// Schedules a refresh of the context-window button at the prompt-cache
/// expiry instant so the notification dot appears while the conversation is idle.
fn reschedule_prompt_cache_expiry_timer(
@@ -2101,6 +2238,14 @@ impl AgentInputFooter {
stack.finish()
})
}
AgentToolbarItemKind::LLMContextUsage => BlocklistAIHistoryModel::as_ref(app)
.active_conversation(self.terminal_view_id)
.is_some()
.then(|| ChildView::new(&self.llm_context_usage_button).finish()),
AgentToolbarItemKind::LLMCacheDetails => BlocklistAIHistoryModel::as_ref(app)
.active_conversation(self.terminal_view_id)
.is_some()
.then(|| ChildView::new(&self.llm_cache_details_button).finish()),
AgentToolbarItemKind::ShareSession => {
if is_conversation_transcript_context {
return None;
@@ -52,6 +52,8 @@ pub enum AgentToolbarItemKind {
ModelSelector,
NLDToggle,
ContextWindowUsage,
LLMContextUsage,
LLMCacheDetails,
// CLI agent only
FileExplorer,
@@ -83,6 +85,8 @@ impl AgentToolbarItemKind {
Self::ModelSelector
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::LLMContextUsage
| Self::LLMCacheDetails
| Self::FastForwardToggle
| Self::HandoffToCloud
| Self::ShareSession => ToolbarAvailability::AgentViewOnly,
@@ -111,6 +115,8 @@ impl AgentToolbarItemKind {
| Self::ModelSelector
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::LLMContextUsage
| Self::LLMCacheDetails
| Self::RichInput
| Self::VoiceInput => true,
}
@@ -123,7 +129,9 @@ impl AgentToolbarItemKind {
Self::NLDToggle => "Autodetection",
Self::VoiceInput => "Voice Input",
Self::FileAttach => "Attach File",
Self::ContextWindowUsage => "Context Usage",
Self::ContextWindowUsage => "Warp Context Indicator",
Self::LLMContextUsage => "LLM Context Usage",
Self::LLMCacheDetails => "LLM Cache Details",
Self::FileExplorer => "File Explorer",
Self::RichInput => "Rich Input",
Self::Settings => "Settings",
@@ -141,6 +149,8 @@ impl AgentToolbarItemKind {
Self::VoiceInput => Some(Icon::Microphone),
Self::FileAttach => Some(Icon::Plus),
Self::ContextWindowUsage => Some(Icon::ContextRemaining100),
Self::LLMContextUsage => Some(Icon::ContextRemaining100),
Self::LLMCacheDetails => Some(Icon::LayersThree01),
Self::FileExplorer => Some(Icon::FileCopy),
Self::RichInput => Some(Icon::TextInput),
Self::Settings => Some(Icon::Settings),
@@ -163,6 +173,8 @@ impl AgentToolbarItemKind {
Self::ContextChip(_)
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::LLMContextUsage
| Self::LLMCacheDetails
| Self::FastForwardToggle
| Self::HandoffToCloud
| Self::ShareSession
@@ -214,7 +226,8 @@ impl AgentToolbarItemKind {
pub fn default_right() -> Vec<Self> {
let mut items = vec![
Self::ContextChip(ContextChipKind::AgentPlanAndTodoList),
Self::ContextWindowUsage,
Self::LLMContextUsage,
Self::LLMCacheDetails,
Self::ModelSelector,
];
if FeatureFlag::OzHandoff.is_enabled()
@@ -240,6 +253,8 @@ impl AgentToolbarItemKind {
Self::VoiceInput,
Self::FileAttach,
Self::ContextWindowUsage,
Self::LLMContextUsage,
Self::LLMCacheDetails,
]);
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
items.push(Self::FastForwardToggle);
@@ -81,11 +81,7 @@ pub(crate) fn conversation_id_for_agent_id_in_orchestration(
history,
orchestrator_id,
))
.find(|conversation_id| {
history
.conversation(conversation_id)
.is_some_and(|conversation| matches(conversation))
})
.find(|conversation_id| history.conversation(conversation_id).is_some_and(&matches))
.or_else(|| conversation_id_for_agent_id(canonical_id, app))
}
+6
View File
@@ -1013,6 +1013,11 @@ impl BlocklistAIStatusBar {
.or(self.warping_message)
.unwrap_or_else(|| random_load_output_message())
.to_owned();
let retry_status_text = self
.controller
.as_ref(app)
.provider_retry_status(conversation.id())
.map(|status| status.label());
let secondary_element = if fallback_warping_text.is_some() {
Some(render_fallback_explanation(model.as_ref(), app))
} else {
@@ -1071,6 +1076,7 @@ impl BlocklistAIStatusBar {
)),
force_refresh_button,
default_warping_text,
retry_status_text,
secondary_element,
last_snapshot_at,
warping_start_time: self.warping_start_time,
@@ -218,6 +218,7 @@ pub struct WarpingProps<'a, V> {
pub action_model: &'a BlocklistAIActionModel,
pub terminal_model: &'a TerminalModel,
pub default_warping_text: String,
pub retry_status_text: Option<String>,
pub secondary_element: Option<Box<dyn Element>>,
/// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot.
pub last_snapshot_at: Option<instant::Instant>,
@@ -337,14 +338,6 @@ pub fn render_warping_indicator<V: View>(
let mut should_render_waiting_icon = false;
let mut non_shimmering_text = None;
if let Some(status_message) = props
.model
.conversation(app)
.and_then(|conversation| conversation.status_error_message())
.filter(|message| message.starts_with("Retrying LLM request"))
{
non_shimmering_text = Some(format!("{status_message}"));
}
let message = if let Some(summarization_type) = summarization_type {
// Choose the appropriate message based on summarization type
let base_message = match summarization_type {
@@ -486,6 +479,13 @@ pub fn render_warping_indicator<V: View>(
}
};
if let Some(retry_status_text) = props.retry_status_text.as_deref() {
non_shimmering_text = Some(match non_shimmering_text {
Some(text) if !text.is_empty() => format!("{text} {retry_status_text}"),
Some(_) | None => format!(" {retry_status_text}"),
});
}
let appearance = Appearance::as_ref(app);
let mut buttons_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
File diff suppressed because it is too large Load Diff
+21 -12
View File
@@ -75,6 +75,7 @@ fn provider_lifecycle_logs_expose_llm_completion_and_sanitize_errors() {
runtime_id: "rig:openai".to_owned(),
model_id: "test-model".to_owned(),
retry_attempt: 1,
max_retries: 3,
elapsed_ms: 120_000,
error: retry_error,
},
@@ -112,6 +113,7 @@ fn provider_lifecycle_logs_expose_llm_completion_and_sanitize_errors() {
assert_eq!(records[0].context["llm_finished"], false);
assert_eq!(records[1].context["llm_finished"], false);
assert_eq!(records[2].context["llm_finished"], false);
assert_eq!(records[2].context["max_retries"], 3);
assert_eq!(records[3].context["llm_finished"], true);
assert_eq!(records[2].level, RemoteLogLevel::Warn);
assert_eq!(records[2].context["error"], "[redacted] connection failed");
@@ -124,6 +126,16 @@ fn provider_lifecycle_logs_expose_llm_completion_and_sanitize_errors() {
assert_eq!(records[3].context["tool_call_count"], 2);
}
#[test]
fn provider_retry_status_uses_configured_budget_in_label() {
let status = super::ProviderRetryStatus {
attempt: 1,
max_retries: 5,
};
assert_eq!(status.label(), "(Retry 1/5)");
}
#[test]
fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
let conversation_id = AIConversationId::new();
@@ -455,6 +467,7 @@ fn queued_provider_follow_up_persists_while_active_slot_is_unprepared() {
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
retry_status: None,
},
);
controller
@@ -480,6 +493,7 @@ fn queued_provider_follow_up_persists_while_active_slot_is_unprepared() {
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
retry_status: None,
},
base_provider_config: crate::ai::provider::ProviderConfig::None,
cli_provider_config: crate::ai::provider::ProviderConfig::None,
@@ -1029,6 +1043,7 @@ fn cancelling_provider_startup_keeps_slot_and_durable_cancellation() {
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
retry_status: None,
},
);
@@ -1127,6 +1142,7 @@ fn same_conversation_follow_up_waits_for_cancelled_provider_generation_cleanup()
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
retry_status: None,
},
);
controller
@@ -1158,6 +1174,7 @@ fn same_conversation_follow_up_waits_for_cancelled_provider_generation_cleanup()
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
retry_status: None,
},
base_provider_config: crate::ai::provider::ProviderConfig::None,
cli_provider_config: crate::ai::provider::ProviderConfig::None,
@@ -1267,6 +1284,7 @@ fn non_follow_up_provider_cancellation_does_not_admit_an_overlapping_generation(
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
retry_status: None,
},
);
@@ -2033,18 +2051,9 @@ fn restored_evidence_is_ignored_without_a_command_monitor() {
#[test]
fn restored_projection_accepts_empty_or_complete_and_rejects_partial_state() {
assert_eq!(
super::restored_projection_was_initialized(false, false, false).unwrap(),
false
);
assert_eq!(
super::restored_projection_was_initialized(true, true, false).unwrap(),
true
);
assert_eq!(
super::restored_projection_was_initialized(true, true, true).unwrap(),
true
);
assert!(!super::restored_projection_was_initialized(false, false, false).unwrap());
assert!(super::restored_projection_was_initialized(true, true, false).unwrap());
assert!(super::restored_projection_was_initialized(true, true, true).unwrap());
for state in [
(false, false, true),
(false, true, false),