agent_ui: Put fast mode behind confirmation popover (#57482)

It look like this:

<img width="1698" height="688" alt="grafik"
src="https://github.com/user-attachments/assets/02a37271-63d3-42da-887f-e17b31e8d9ca"
/>

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 <daniloleal09@gmail.com>
Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
Tom Houlé 2026-05-27 15:05:55 +02:00 committed by GitHub
parent fe48ef424c
commit b328711bbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 274 additions and 35 deletions

View file

@ -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::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {

View file

@ -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.

View file

@ -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};

View file

@ -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<MessageEditor>,
pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
pub fast_mode_menu_handle: PopoverMenuHandle<ContextMenu>,
pub project: WeakEntity<Project>,
/// 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<Self>) -> Option<AnyElement> {
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<Self>) {
fn toggle_fast_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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();
}

View file

@ -289,6 +289,20 @@ pub trait LanguageModelProvider: 'static {
cx: &mut App,
) -> AnyView;
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>>;
/// 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<FastModeConfirmation> {
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)]

View file

@ -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<FastModeConfirmation> {
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

View file

@ -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<Result<()>> {
Task::ready(Ok(()))
}
fn fast_mode_confirmation(&self, _cx: &App) -> Option<FastModeConfirmation> {
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)]

View file

@ -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<FastModeConfirmation> {
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<open_ai::ReasoningEffort> {

View file

@ -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<Result<()>> {
self.sign_out(cx)
}
fn fast_mode_confirmation(&self, _cx: &App) -> Option<FastModeConfirmation> {
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(),
})
}
}
//