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: grafik 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(), + }) + } } //