From ec85e42d12ef3addffa68766037971d4a4e132d7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=20Houl=C3=A9?=
<13155277+tomhoule@users.noreply.github.com>
Date: Wed, 27 May 2026 15:05:55 +0200
Subject: [PATCH] agent_ui: Put fast mode behind confirmation popover (#57482)
It look like this:
The idea is to avoid people turning on fast mode without understanding
the financial implications. It also clarifiers (in the BYOK case) why
they might not see a difference between fast mode enabled and disabled.
Release Notes:
- N/A
---------
Co-authored-by: Danilo Leal
Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
---
crates/agent_ui/src/agent_panel.rs | 9 +-
crates/agent_ui/src/agent_ui.rs | 2 +
crates/agent_ui/src/conversation_view.rs | 3 +-
.../src/conversation_view/thread_view.rs | 206 ++++++++++++++++--
crates/language_model/src/language_model.rs | 14 ++
.../language_models/src/provider/anthropic.rs | 20 +-
crates/language_models/src/provider/cloud.rs | 16 +-
.../language_models/src/provider/open_ai.rs | 20 +-
.../src/provider/openai_subscribed.rs | 19 +-
9 files changed, 274 insertions(+), 35 deletions(-)
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 8472d96e7d..567cab36c1 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -41,10 +41,10 @@ use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadata
use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow,
LoadThreadFromClipboard, NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown,
- OpenAgentDiff, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata,
- ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu,
+ OpenAgentDiff, ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell,
+ ShowAllSidebarThreadMetadata, ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
- conversation_view::{AcpThreadViewEvent, ThreadView},
+ conversation_view::{AcpThreadViewEvent, ThreadView, reset_fast_mode_warnings},
ui::{AgentNotification, AgentNotificationEvent, EndTrialUpsell},
};
use crate::{
@@ -381,6 +381,9 @@ pub fn init(cx: &mut App) {
.register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
TrialEndUpsell::set_dismissed(false, cx);
})
+ .register_action(|_workspace, _: &ResetFastModeWarnings, _window, cx| {
+ reset_fast_mode_warnings(cx);
+ })
.register_action(|workspace, _: &ResetAgentZoom, window, cx| {
if let Some(panel) = workspace.panel::(cx) {
panel.update(cx, |panel, cx| {
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 2ccd27ce08..acc45fa0bb 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -245,6 +245,8 @@ actions!(
ResetTrialUpsell,
/// Resets the trial end upsell notification.
ResetTrialEndUpsell,
+ /// Re-enables the fast mode warning for every provider and model.
+ ResetFastModeWarnings,
/// Opens the "Add Context" menu in the message editor.
OpenAddContextMenu,
/// Interrupts the current generation and sends the message immediately.
diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs
index c164617948..568b94c124 100644
--- a/crates/agent_ui/src/conversation_view.rs
+++ b/crates/agent_ui/src/conversation_view.rs
@@ -70,9 +70,8 @@ use util::{
size::format_file_size,
time::duration_alt_display,
};
-use workspace::PathList;
use workspace::{
- CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
+ CollaboratorId, MultiWorkspace, NewTerminal, PathList, Toast, Workspace,
path_link::sanitize_path_text,
};
use zed_actions::agent::{Chat, ToggleModelSelector};
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 857ef88dbc..01bef136c0 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -16,13 +16,18 @@ use feature_flags::AcpBetaFeatureFlag;
use crate::completion_provider::AvailableSkill;
use crate::message_editor::SharedSessionCapabilities;
+use db::kvp::KeyValueStore;
use gpui::List;
use gpui::TaskExt;
use heapless::Vec as ArrayVec;
-use language_model::{LanguageModelEffortLevel, Speed};
+use language_model::{
+ FastModeConfirmation, LanguageModelEffortLevel, LanguageModelId, LanguageModelProviderId,
+ LanguageModelRegistry, Speed,
+};
use settings::update_settings_file;
use ui::{ButtonLike, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab};
use workspace::SERIALIZATION_THROTTLE_TIME;
+use workspace::notifications::NotificationId;
use super::*;
@@ -597,6 +602,7 @@ pub struct ThreadView {
pub message_editor: Entity,
pub add_context_menu_handle: PopoverMenuHandle,
pub thinking_effort_menu_handle: PopoverMenuHandle,
+ pub fast_mode_menu_handle: PopoverMenuHandle,
pub project: WeakEntity,
/// Cache + worktree snapshot for resolving paths in markdown code spans.
/// Cloned from the parent `ConversationView` so the cache is shared and the
@@ -910,6 +916,7 @@ impl ThreadView {
message_editor,
add_context_menu_handle: PopoverMenuHandle::default(),
thinking_effort_menu_handle: PopoverMenuHandle::default(),
+ fast_mode_menu_handle: PopoverMenuHandle::default(),
project,
code_span_resolver,
show_external_source_prompt_warning,
@@ -4197,33 +4204,139 @@ impl ThreadView {
}
let thread = self.as_native_thread(cx)?.read(cx);
+ let is_fast = matches!(thread.speed(), Some(Speed::Fast));
- let (tooltip_label, color, icon) = if matches!(thread.speed(), Some(Speed::Fast)) {
- ("Disable Fast Mode", Color::Accent, IconName::FastForward)
+ let model_identity = thread
+ .model()
+ .map(|model| (model.provider_id(), model.id()));
+
+ let (tooltip_label, color, icon, new_speed) = if is_fast {
+ (
+ "Disable Fast Mode",
+ Color::Accent,
+ IconName::FastForward,
+ Speed::Standard,
+ )
} else {
(
"Enable Fast Mode",
Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)),
IconName::FastForwardOff,
+ Speed::Fast,
)
};
let focus_handle = self.message_editor.focus_handle(cx);
+ let pending_confirmation = (!is_fast)
+ .then(|| self.pending_fast_mode_confirmation(cx))
+ .flatten();
+
+ let icon_button = IconButton::new("fast-mode", icon)
+ .icon_size(IconSize::Small)
+ .icon_color(color);
+
+ if let Some((provider_id, model_id, confirmation)) = pending_confirmation {
+ let weak_self = cx.entity().downgrade();
+ let tooltip_focus = focus_handle;
+
+ return Some(
+ PopoverMenu::new("fast-mode-warning")
+ .with_handle(self.fast_mode_menu_handle.clone())
+ .trigger_with_tooltip(icon_button, move |_, cx| {
+ Tooltip::for_action_in(tooltip_label, &ToggleFastMode, &tooltip_focus, cx)
+ })
+ .menu(move |window, cx| {
+ let weak_self = weak_self.clone();
+ let confirmation = confirmation.clone();
+ let provider_id = provider_id.clone();
+ let model_id = model_id.clone();
+
+ Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
+ let message = confirmation.message.clone();
+ menu.custom_row(move |_window, _cx| {
+ div()
+ .max_w_72()
+ .child(Label::new(confirmation.title.clone()))
+ .child(Label::new(message.clone()).color(Color::Muted))
+ .into_any_element()
+ })
+ .separator()
+ .item(ContextMenuEntry::new("Enable Now").handler({
+ let weak_self = weak_self.clone();
+ move |_window, cx| {
+ weak_self
+ .update(cx, |this, cx| {
+ this.apply_fast_mode_speed(Speed::Fast, cx);
+ })
+ .log_err();
+ }
+ }))
+ .item(
+ ContextMenuEntry::new("Enable and Don't Show Again").handler({
+ let weak_self = weak_self.clone();
+ let provider_id = provider_id.clone();
+ let model_id = model_id;
+ move |_window, cx| {
+ weak_self
+ .update(cx, |this, cx| {
+ this.apply_fast_mode_speed(Speed::Fast, cx);
+ })
+ .log_err();
+ set_fast_mode_warning_dismissed(
+ &provider_id,
+ &model_id,
+ cx,
+ );
+ }
+ }),
+ )
+ }))
+ })
+ .offset(gpui::Point {
+ x: px(0.0),
+ y: px(-2.0),
+ })
+ .anchor(gpui::Anchor::BottomLeft)
+ .into_any_element(),
+ );
+ }
+
+ let _ = model_identity;
+
Some(
- IconButton::new("fast-mode", icon)
- .icon_size(IconSize::Small)
- .icon_color(color)
+ icon_button
.tooltip(move |_, cx| {
Tooltip::for_action_in(tooltip_label, &ToggleFastMode, &focus_handle, cx)
})
.on_click(cx.listener(move |this, _, _window, cx| {
- this.toggle_fast_mode(cx);
+ this.apply_fast_mode_speed(new_speed, cx);
}))
.into_any_element(),
)
}
+ fn pending_fast_mode_confirmation(
+ &self,
+ cx: &App,
+ ) -> Option<(
+ LanguageModelProviderId,
+ LanguageModelId,
+ FastModeConfirmation,
+ )> {
+ let thread = self.as_native_thread(cx)?.read(cx);
+ let model = thread.model()?;
+ let provider_id = model.provider_id();
+ let model_id = model.id();
+ let confirmation = LanguageModelRegistry::read_global(cx)
+ .provider(&provider_id)
+ .and_then(|provider| provider.fast_mode_confirmation(cx))?;
+ if fast_mode_warning_dismissed(&provider_id, &model_id, cx) {
+ return None;
+ }
+ Some((provider_id, model_id, confirmation))
+ }
+
fn render_thinking_control(&self, cx: &mut Context) -> Option {
let thread = self.as_native_thread(cx)?.read(cx);
let model = thread.model()?;
@@ -9495,18 +9608,34 @@ impl ThreadView {
});
}
- fn toggle_fast_mode(&mut self, cx: &mut Context) {
+ fn toggle_fast_mode(&mut self, window: &mut Window, cx: &mut Context) {
if !self.fast_mode_available(cx) {
return;
}
+
+ let Some(thread) = self.as_native_thread(cx) else {
+ return;
+ };
+
+ let current_speed = thread.read(cx).speed().unwrap_or_default();
+ let new_speed = current_speed.toggle();
+
+ if new_speed == Speed::Fast && self.pending_fast_mode_confirmation(cx).is_some() {
+ let menu_handle = self.fast_mode_menu_handle.clone();
+ window.defer(cx, move |window, cx| {
+ menu_handle.toggle(window, cx);
+ });
+ return;
+ }
+
+ self.apply_fast_mode_speed(new_speed, cx);
+ }
+
+ fn apply_fast_mode_speed(&mut self, new_speed: Speed, cx: &mut Context) {
let Some(thread) = self.as_native_thread(cx) else {
return;
};
thread.update(cx, |thread, cx| {
- let new_speed = thread
- .speed()
- .map(|speed| speed.toggle())
- .unwrap_or(Speed::Fast);
thread.set_speed(new_speed, cx);
let favorite_key = thread
@@ -9652,8 +9781,8 @@ impl Render for ThreadView {
.on_action(cx.listener(Self::scroll_output_to_bottom))
.on_action(cx.listener(Self::scroll_output_to_previous_message))
.on_action(cx.listener(Self::scroll_output_to_next_message))
- .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| {
- this.toggle_fast_mode(cx);
+ .on_action(cx.listener(|this, _: &ToggleFastMode, window, cx| {
+ this.toggle_fast_mode(window, cx);
}))
.on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| {
if this.thread.read(cx).status() != ThreadStatus::Idle {
@@ -9924,3 +10053,52 @@ pub(crate) fn open_link(
cx.open_url(&url);
}
}
+
+const FAST_MODE_WARNING_NAMESPACE: &str = "fast-mode-warning-dismissed";
+
+fn fast_mode_warning_id(
+ provider_id: &LanguageModelProviderId,
+ model_id: &LanguageModelId,
+) -> String {
+ format!("{}:{}", provider_id.0, model_id.0)
+}
+
+fn fast_mode_warning_dismissed(
+ provider_id: &LanguageModelProviderId,
+ model_id: &LanguageModelId,
+ cx: &App,
+) -> bool {
+ KeyValueStore::global(cx)
+ .scoped(FAST_MODE_WARNING_NAMESPACE)
+ .read(&fast_mode_warning_id(provider_id, model_id))
+ .log_err()
+ .flatten()
+ .is_some()
+}
+
+fn set_fast_mode_warning_dismissed(
+ provider_id: &LanguageModelProviderId,
+ model_id: &LanguageModelId,
+ cx: &mut App,
+) {
+ let key = fast_mode_warning_id(provider_id, model_id);
+ let kvp = KeyValueStore::global(cx);
+ cx.background_spawn(async move {
+ kvp.scoped(FAST_MODE_WARNING_NAMESPACE)
+ .write(key, "1".to_string())
+ .await
+ .log_err();
+ })
+ .detach();
+}
+
+pub(crate) fn reset_fast_mode_warnings(cx: &mut App) {
+ let kvp = KeyValueStore::global(cx);
+ cx.background_spawn(async move {
+ kvp.scoped(FAST_MODE_WARNING_NAMESPACE)
+ .delete_all()
+ .await
+ .log_err();
+ })
+ .detach();
+}
diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs
index 3c8ca94d4b..7dc237a65d 100644
--- a/crates/language_model/src/language_model.rs
+++ b/crates/language_model/src/language_model.rs
@@ -289,6 +289,20 @@ pub trait LanguageModelProvider: 'static {
cx: &mut App,
) -> AnyView;
fn reset_credentials(&self, cx: &mut App) -> Task>;
+
+ /// Copy shown the first time a user enables fast mode for a model from
+ /// this provider. Returning `None` skips the confirmation prompt and lets
+ /// the toggle apply silently.
+ fn fast_mode_confirmation(&self, _cx: &App) -> Option {
+ None
+ }
+}
+
+/// Provider-specific copy shown the first time a user enables fast mode.
+#[derive(Debug, Clone)]
+pub struct FastModeConfirmation {
+ pub title: SharedString,
+ pub message: SharedString,
}
#[derive(Default, Clone, PartialEq, Eq)]
diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs
index dbea9bbf33..b919e2b610 100644
--- a/crates/language_models/src/provider/anthropic.rs
+++ b/crates/language_models/src/provider/anthropic.rs
@@ -9,10 +9,11 @@ use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt};
use http_client::HttpClient;
use language_model::{
ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyState, AuthenticateError,
- ConfigurationViewTargetAgent, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
- LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
- LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
- LanguageModelRequest, LanguageModelToolChoice, RateLimiter, env_var,
+ ConfigurationViewTargetAgent, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel,
+ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
+ LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+ LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter,
+ env_var,
};
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
@@ -275,6 +276,17 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider {
self.state
.update(cx, |state, cx| state.set_api_key(None, cx))
}
+
+ fn fast_mode_confirmation(&self, _cx: &App) -> Option {
+ Some(FastModeConfirmation {
+ title: "Enable Fast Mode for Anthropic?".into(),
+ message: "Fast mode lets requests use your Anthropic Priority Tier capacity, which \
+ Anthropic prioritizes over standard requests during peak load. Requires a \
+ Priority Tier commitment with Anthropic; without one, requests behave the same \
+ as the standard tier."
+ .into(),
+ })
+ }
}
/// Pick the model from `models` whose id starts with the earliest matching
diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs
index 3cb9fcf1b8..78b4220eb0 100644
--- a/crates/language_models/src/provider/cloud.rs
+++ b/crates/language_models/src/provider/cloud.rs
@@ -9,9 +9,9 @@ use futures::StreamExt;
use futures::future::BoxFuture;
use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt};
use language_model::{
- AuthenticateError, IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelProviderId,
- LanguageModelProviderName, LanguageModelProviderState, ZED_CLOUD_PROVIDER_ID,
- ZED_CLOUD_PROVIDER_NAME,
+ AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+ ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME,
};
use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider};
use release_channel::AppVersion;
@@ -306,6 +306,16 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
fn reset_credentials(&self, _cx: &mut App) -> Task> {
Task::ready(Ok(()))
}
+
+ fn fast_mode_confirmation(&self, _cx: &App) -> Option {
+ Some(FastModeConfirmation {
+ title: "Enable Fast Mode for Zed?".into(),
+ message: "Fast mode routes requests through the upstream provider's fast mode or priority tier. The \
+ upstream provider's premium per-token pricing applies and is passed through to \
+ your Zed billing."
+ .into(),
+ })
+ }
}
#[derive(IntoElement, RegisterComponent)]
diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs
index 3d4826bbd2..1ef535f39a 100644
--- a/crates/language_models/src/provider/open_ai.rs
+++ b/crates/language_models/src/provider/open_ai.rs
@@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
use http_client::HttpClient;
use language_model::{
- ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
- LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
- LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
- LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID,
- OPEN_AI_PROVIDER_NAME, RateLimiter, env_var,
+ ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel,
+ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
+ LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+ LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, RateLimiter, env_var,
};
use menu;
use open_ai::{
@@ -215,6 +215,16 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider {
self.state
.update(cx, |state, cx| state.set_api_key(None, cx))
}
+
+ fn fast_mode_confirmation(&self, _cx: &App) -> Option {
+ Some(FastModeConfirmation {
+ title: "Enable Fast Mode for OpenAI?".into(),
+ message: "Fast mode sends requests using OpenAI's Priority processing tier, which \
+ targets significantly lower latency than the standard tier and is billed at a \
+ premium per-token rate."
+ .into(),
+ })
+ }
}
fn default_thinking_reasoning_effort(model: &open_ai::Model) -> Option {
diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs
index 4658a376eb..66716ebdad 100644
--- a/crates/language_models/src/provider/openai_subscribed.rs
+++ b/crates/language_models/src/provider/openai_subscribed.rs
@@ -6,10 +6,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
use language_model::{
- AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError,
- LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
- LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
- LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter,
+ AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel,
+ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
+ LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+ LanguageModelToolChoice, RateLimiter,
};
use open_ai::{ReasoningEffort, responses::stream_response};
use rand::RngCore as _;
@@ -251,6 +252,16 @@ impl LanguageModelProvider for OpenAiSubscribedProvider {
fn reset_credentials(&self, cx: &mut App) -> Task> {
self.sign_out(cx)
}
+
+ fn fast_mode_confirmation(&self, _cx: &App) -> Option {
+ Some(FastModeConfirmation {
+ title: "Enable Fast Mode for OpenAI?".into(),
+ message: "Fast mode sends requests using OpenAI's Priority processing tier, which \
+ targets significantly lower latency than the standard tier and is billed at a \
+ premium per-token rate."
+ .into(),
+ })
+ }
}
//