Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,543 @@
use std::sync::Arc;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
use warpui::{
elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Empty, Fill, Flex, Hoverable, MouseStateHandle, OffsetPositioning,
ParentAnchor, Radius, SavePosition, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO,
},
platform::Cursor,
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, TypedActionView,
View, ViewContext, ViewHandle,
};
use warpui::{
elements::{ParentElement, ParentOffsetBounds},
ui_components::components::UiComponent,
};
use warp_core::features::FeatureFlag;
use crate::{
ai::{
agent::{
icons::todo_list_icon,
todos::popup::{AgentTodosPopupEvent, AgentTodosPopupView},
},
blocklist::{BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent},
document::ai_document_model::{
AIDocumentId, AIDocumentModel, AIDocumentModelEvent, AIDocumentVersion,
},
},
terminal::input::{MenuPositioning, MenuPositioningProvider},
ui_components::blended_colors,
AIAgentTodoList, BlocklistAIHistoryModel,
};
use warpui::fonts::{Properties, Weight};
const TODO_BUTTON_SAVE_POSITION_ID: &str = "plan_and_todo_list::todo_button";
/// A context chip that shows the todo list and plan for the active conversation
pub struct PlanAndTodoListView {
context_model: ModelHandle<BlocklistAIContextModel>,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
terminal_view_id: EntityId,
todo_button_mouse_state: MouseStateHandle,
plan_button_mouse_state: MouseStateHandle,
agent_todos_popup: ViewHandle<AgentTodosPopupView>,
is_todo_popup_open: bool,
is_in_agent_view: bool,
}
pub enum PlanAndTodoListEvent {
OpenAIDocument {
document_id: AIDocumentId,
document_version: AIDocumentVersion,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanAndTodoListAction {
ToggleTodoPopup,
OpenAIDocument {
document_id: AIDocumentId,
document_version: AIDocumentVersion,
},
}
impl PlanAndTodoListView {
pub fn new(
context_model: ModelHandle<BlocklistAIContextModel>,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
terminal_view_id: EntityId,
is_in_agent_view: bool,
ctx: &mut ViewContext<Self>,
) -> Self {
let agent_todos_popup = ctx.add_typed_action_view(|ctx| {
AgentTodosPopupView::new(terminal_view_id, context_model.clone(), ctx)
});
ctx.subscribe_to_view(&agent_todos_popup, |me, _, event, ctx| match event {
AgentTodosPopupEvent::Close => {
me.is_todo_popup_open = false;
ctx.notify();
}
});
ctx.subscribe_to_model(
&AIDocumentModel::handle(ctx),
|me, _, event, ctx| match event {
AIDocumentModelEvent::DocumentUserEditStatusUpdated { document_id, .. } => {
if me.ai_document_id(ctx).is_some_and(|id| id == *document_id) {
ctx.notify();
}
}
AIDocumentModelEvent::DocumentSaveStatusUpdated { .. } => {}
AIDocumentModelEvent::DocumentUpdated { .. } => {}
AIDocumentModelEvent::StreamingDocumentsCleared(..) => {}
AIDocumentModelEvent::DocumentVisibilityChanged(_) => {}
},
);
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|me, _, event, ctx| {
if event
.terminal_view_id()
.is_some_and(|id| id != me.terminal_view_id)
{
return;
}
// Note: UpdatedStreamingExchange is not needed here because plan/todo
// chips only depend on conversation-level events and UpdatedTodoList,
// not on regular content streaming updates.
match event.clone() {
BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. }
| BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::UpdatedTodoList { .. } => {
ctx.notify();
}
_ => (),
}
},
);
// Subscribe to context model to detect when pending query state changes (e.g., new conversation)
ctx.subscribe_to_model(&context_model, |_, _, event, ctx| {
if let BlocklistAIContextEvent::PendingQueryStateUpdated = event {
ctx.notify();
}
});
Self {
context_model,
menu_positioning_provider,
terminal_view_id,
todo_button_mouse_state: Default::default(),
plan_button_mouse_state: Default::default(),
agent_todos_popup,
is_in_agent_view,
is_todo_popup_open: false,
}
}
pub fn should_render(&self, app: &AppContext) -> bool {
self.ai_document_id(app).is_some() || self.todo_list(app).is_some()
}
fn render_chip_button(
&self,
content: Box<dyn Element>,
mouse_state_handle: MouseStateHandle,
tool_tip_text: String,
corner_radius: CornerRadius,
appearance: &Appearance,
) -> Hoverable {
Hoverable::new(mouse_state_handle.clone(), move |state| {
let background = if state.is_hovered() {
internal_colors::fg_overlay_2(appearance.theme())
} else {
internal_colors::fg_overlay_1(appearance.theme())
};
let container = Container::new(content)
.with_background(background)
.with_padding_left(6.)
.with_padding_right(6.)
.with_corner_radius(corner_radius)
.with_border(
Border::all(1.0)
.with_border_fill(internal_colors::neutral_3(appearance.theme())),
)
.with_padding_top(2.)
.with_padding_bottom(2.)
.finish();
if state.is_hovered() {
let mut stack = Stack::new().with_child(container);
let tooltip_element = appearance
.ui_builder()
.tool_tip(tool_tip_text)
.build()
.finish();
stack.add_positioned_overlay_child(
tooltip_element,
OffsetPositioning::offset_from_parent(
vec2f(0., -8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
),
);
stack.finish()
} else {
container
}
})
.with_cursor(Cursor::PointingHand)
}
fn render_plan_button(
&self,
ai_document_id: AIDocumentId,
has_todo_list: bool,
icon_size: f32,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let icon_element = Container::new(
ConstrainedBox::new(
Icon::Compass
.to_warpui_icon(if self.is_in_agent_view {
theme.sub_text_color(blended_colors::neutral_1(theme).into())
} else {
internal_colors::fg_overlay_7(appearance.theme())
})
.finish(),
)
.with_height(icon_size)
.with_width(icon_size)
.finish(),
)
.finish();
// Set height to match other UDI elements
let udi_font_size = appearance.monospace_font_size() - 1.;
let content_line_height = app
.font_cache()
.line_height(udi_font_size, appearance.line_height_ratio());
let chip_content = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon_element)
.finish();
let chip_content = ConstrainedBox::new(chip_content).with_height(content_line_height);
let corner_radius = if has_todo_list {
CornerRadius::with_left(Radius::Pixels(4.))
} else {
CornerRadius::with_all(Radius::Pixels(4.))
};
let conversation_is_streaming = self
.context_model
.as_ref(app)
.selected_conversation(app)
.is_some_and(|conversation| conversation.status().is_in_progress());
let is_document_dirty = AIDocumentModel::as_ref(app)
.get_current_document(&ai_document_id)
.map(|doc| doc.user_edit_status.is_dirty())
.unwrap_or(false);
let is_agent_unaware_of_plan_edits = conversation_is_streaming && is_document_dirty;
let plan_button = self
.render_chip_button(
chip_content.finish(),
self.plan_button_mouse_state.clone(),
if is_agent_unaware_of_plan_edits {
"Agent is unaware of recent plan edits".to_string()
} else {
"View plan".to_string()
},
corner_radius,
appearance,
)
.on_click(move |ctx, app, _| {
let Some(document_version) = AIDocumentModel::as_ref(app)
.get_current_document(&ai_document_id)
.map(|doc| doc.version)
else {
log::warn!("No current document found for AI document ID: {ai_document_id}");
return;
};
ctx.dispatch_typed_action(PlanAndTodoListAction::OpenAIDocument {
document_id: ai_document_id,
document_version,
});
})
.finish();
if is_agent_unaware_of_plan_edits {
// Show a circle indicator to the top right of the plan button
let circle_diameter = 6.;
let circle_element = Container::new(
ConstrainedBox::new(Empty::new().finish())
.with_height(circle_diameter)
.with_width(circle_diameter)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_background(Fill::Solid(
internal_colors::fg_overlay_7(appearance.theme()).into(),
))
.finish();
let mut stack = Stack::new().with_child(plan_button);
stack.add_positioned_child(
circle_element,
OffsetPositioning::offset_from_parent(
vec2f(circle_diameter / 2., -(circle_diameter / 2.)),
ParentOffsetBounds::Unbounded,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
stack.finish()
} else {
plan_button
}
}
fn todo_list(&self, app: &AppContext) -> Option<AIAgentTodoList> {
let todo_list = self
.context_model
.as_ref(app)
.selected_conversation_todolist(app);
let should_show_todo_button = todo_list.is_some();
if !should_show_todo_button {
return None;
}
if let Some(todo_list) = todo_list {
if !todo_list.is_empty() {
return Some(todo_list.clone());
}
}
None
}
fn ai_document_id(&self, app: &AppContext) -> Option<AIDocumentId> {
self.context_model
.as_ref(app)
.selected_conversation_id(app)
.and_then(|conversation_id| {
AIDocumentModel::as_ref(app).get_document_id_by_conversation_id(conversation_id)
})
}
fn render_todo_button(
&self,
todo_list: &AIAgentTodoList,
has_planning_document: bool,
icon_size: f32,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let num_todo_items = todo_list.len();
let num_completed_todo_items = todo_list.completed_items().len();
let primary_color = appearance.theme().surface_1();
let todo_icon = Container::new(
ConstrainedBox::new(
todo_list_icon(appearance)
.with_color(appearance.theme().sub_text_color(primary_color))
.finish(),
)
.with_height(icon_size)
.with_width(icon_size)
.finish(),
)
.finish();
// Use the same font sizing conventions as other UDI chips so text height never exceeds icon height
let chip_font_size = appearance.monospace_font_size() - 1.0;
let line_height_ratio = appearance.line_height_ratio();
let completed_text = Text::new_inline(
format!("{}", num_completed_todo_items + 1),
appearance.ui_font_family(),
chip_font_size,
)
.with_color(blended_colors::text_main(appearance.theme(), primary_color))
.with_line_height_ratio(line_height_ratio)
.with_style(Properties::default().weight(Weight::Semibold))
.finish();
// Separate the "slash" so we can apply a small margin between the slash and the numbers
let slash_text = Text::new_inline("/", appearance.ui_font_family(), chip_font_size)
.with_color(appearance.theme().sub_text_color(primary_color).into())
.with_line_height_ratio(line_height_ratio)
.with_style(Properties::default().weight(Weight::Semibold))
.finish();
let total_text = Text::new_inline(
format!("{num_todo_items}"),
appearance.ui_font_family(),
chip_font_size,
)
.with_color(appearance.theme().sub_text_color(primary_color).into())
.with_line_height_ratio(line_height_ratio)
.with_style(Properties::default().weight(Weight::Semibold))
.finish();
let content = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(todo_icon)
.with_child(Container::new(completed_text).with_margin_left(4.).finish())
.with_child(Container::new(slash_text).with_margin_left(2.).finish())
.with_child(Container::new(total_text).with_margin_left(2.).finish())
.finish();
let corner_radius = if has_planning_document {
CornerRadius::with_right(Radius::Pixels(4.))
} else {
CornerRadius::with_all(Radius::Pixels(4.))
};
let todo_button = self
.render_chip_button(
content,
self.todo_button_mouse_state.clone(),
"View todo list".to_string(),
corner_radius,
appearance,
)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(PlanAndTodoListAction::ToggleTodoPopup);
})
.finish();
let todo_button = SavePosition::new(todo_button, TODO_BUTTON_SAVE_POSITION_ID).finish();
// Todo popup overlay
let mut todo_button = Stack::new().with_child(todo_button);
if self.is_todo_popup_open {
let positioning = match self.menu_positioning_provider.menu_position(app) {
MenuPositioning::BelowInputBox => {
OffsetPositioning::offset_from_save_position_element(
TODO_BUTTON_SAVE_POSITION_ID,
vec2f(0., 4.),
warpui::elements::PositionedElementOffsetBounds::WindowByPosition,
warpui::elements::PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
)
}
MenuPositioning::AboveInputBox => {
OffsetPositioning::offset_from_save_position_element(
TODO_BUTTON_SAVE_POSITION_ID,
vec2f(0., -4.),
warpui::elements::PositionedElementOffsetBounds::WindowByPosition,
warpui::elements::PositionedElementAnchor::TopLeft,
ChildAnchor::BottomLeft,
)
}
};
todo_button.add_positioned_overlay_child(
ChildView::new(&self.agent_todos_popup).finish(),
positioning,
);
}
todo_button.finish()
}
}
impl Entity for PlanAndTodoListView {
type Event = PlanAndTodoListEvent;
}
impl View for PlanAndTodoListView {
fn ui_name() -> &'static str {
"PlanAndTodoListView"
}
fn render(&self, app: &AppContext) -> Box<dyn warpui::Element> {
let appearance = Appearance::as_ref(app);
// Calculate icon size
let base_icon_size = app.font_cache().line_height(
appearance.monospace_font_size(),
DEFAULT_UI_LINE_HEIGHT_RATIO / 1.4,
);
let text_line_height = app.font_cache().line_height(
appearance.monospace_font_size() - 1.0,
appearance.line_height_ratio(),
);
let icon_size = (base_icon_size * 1.1).min(text_line_height);
let todo_list = self.todo_list(app);
let ai_document_id = self.ai_document_id(app);
let mut row = Flex::row();
// Only show plan chip when AgentView is not enabled
if !FeatureFlag::AgentView.is_enabled() {
if let Some(ai_document_id) = ai_document_id {
row.add_child(self.render_plan_button(
ai_document_id,
todo_list.is_some(),
icon_size,
appearance,
app,
));
}
}
if let Some(todo_list) = todo_list {
row.add_child(self.render_todo_button(
&todo_list,
ai_document_id.is_some() && !FeatureFlag::AgentView.is_enabled(),
icon_size,
appearance,
app,
));
}
row.finish()
}
}
impl TypedActionView for PlanAndTodoListView {
type Action = PlanAndTodoListAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
PlanAndTodoListAction::ToggleTodoPopup => {
self.is_todo_popup_open = !self.is_todo_popup_open;
// If we just opened the popup, request initial scroll to the in-progress item
if self.is_todo_popup_open {
self.agent_todos_popup
.update(ctx, |popup, _ctx| popup.scroll_to_in_progress_item());
ctx.focus(&self.agent_todos_popup);
}
ctx.notify();
}
PlanAndTodoListAction::OpenAIDocument {
document_id,
document_version,
} => {
ctx.emit(PlanAndTodoListEvent::OpenAIDocument {
document_id: *document_id,
document_version: *document_version,
});
}
}
}
}
+540
View File
@@ -0,0 +1,540 @@
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, FormattedTextElement,
HighlightedHyperlink, HyperlinkLens, MainAxisAlignment, MainAxisSize, ParentElement,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::{
ai::{blocklist::error_color, AIRequestUsageModel},
auth::AuthStateProvider,
network::NetworkStatus,
server::ids::ServerId,
settings::PrivacySettings,
settings_view::SettingsSection,
ui_components::icons::Icon,
workspace::WorkspaceAction,
workspaces::user_workspaces::UserWorkspaces,
};
use ai::api_keys::ApiKeyManager;
const ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PERCENTAGE: f32 = 0.5;
const TELEMETRY_DISABLED_PRIMARY_TEXT: &str = "To use AI features,";
const ENABLE_ANALYTICS_ACTION_TEXT: &str = "enable analytics";
const UPGRADE_TO_BUILD_ACTION_TEXT: &str = "upgrade";
const NO_CONNECTION_PRIMARY_TEXT: &str = "No internet connection";
const ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PRIMARY_TEXT: &str = "";
const ANONYMOUS_USER_REQUEST_LIMIT_HARD_GATE_PRIMARY_TEXT: &str = "At Limit -";
const DELINQUENT_DUE_TO_PAYMENT_ISSUE_PRIMARY_TEXT: &str = "Restricted due to payment issue";
const OUT_OF_REQUESTS_PRIMARY_TEXT: &str = "Out of credits";
const ANONYMOUS_USER_REQUEST_LIMIT_ACTION_TEXT: &str = "Sign up for more AI credits";
const DELINQUENT_DUE_TO_PAYMENT_ISSUE_ACTION_TEXT: &str = "Manage billing";
const OVERAGES_TOGGLEABLE_BUT_NOT_ENABLED_ACTION_TEXT: &str = "Enable premium overages";
const MONTHLY_OVERAGES_SPEND_LIMIT_REACHED_ACTION_TEXT: &str = "Increase monthly spend limit";
const UPGRADE_TEXT: &str = "Upgrade";
const COMPARE_PLANS_TEXT: &str = "Compare plans";
const CONTACT_SUPPORT_TEXT: &str = "Contact support";
const NON_ADMIN_CONTACT_ADMIN_TEXT: &str = ", contact a team admin";
const NON_ADMIN_ASK_ADMIN_TO_ENABLE_OVERAGES_TEXT: &str = ", ask a team admin to enable overages";
const NON_ADMIN_ASK_ADMIN_TO_INCREASE_OVERAGES_TEXT: &str =
", ask a team admin to increase overages";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptAlertAction {
SignUpClickedForAnonymousUser,
OpenSettingsClicked,
OpenPrivacySettingsClicked,
ManageBillingClicked { team_uid: ServerId },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptAlertEvent {
SignupAnonymousUser,
OpenBillingAndUsagePage,
OpenPrivacyPage,
OpenBillingPortal { team_uid: ServerId },
}
/// The alert state of the chip that appears to the right of certain parts of the prompt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptAlertState {
/// The user is offline (no connection).
NoConnection,
/// Telemetry is disabled and the user is on a free tier.
/// Free tier users must enable telemetry or upgrade to use AI features.
TelemetryDisabledOnFreeTier,
/// An anonymous user has reached a certain percentage of requests used.
/// This doesn't use a primary text to avoid being too in-your-face.
AnonymousUserRequestLimitSoftGate,
/// An anonymous user has reached the request limit.
AnonymousUserRequestLimitHardGate,
/// The user is delinquent due to a payment issue.
DelinquentDueToPaymentIssue,
/// Overages could be turned on, but aren't enabled.
OveragesToggleableButNotEnabled,
/// Overages are on, but the spend limit is too low.
MonthlyOveragesSpendLimitReached,
/// The user has reached the request limit.
RequestLimitReached,
/// No alert should be displayed.
NoAlert,
}
pub struct PromptAlertView {
state: PromptAlertState,
action_hyperlink: HighlightedHyperlink,
}
impl PromptAlertView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let request_usage_model = AIRequestUsageModel::handle(ctx);
let user_workspaces = UserWorkspaces::handle(ctx);
let network_status = NetworkStatus::handle(ctx);
let privacy_settings = PrivacySettings::handle(ctx);
let api_key_manager = ApiKeyManager::handle(ctx);
ctx.subscribe_to_model(&request_usage_model, |me, _, _, ctx| {
me.state = Self::determine_state(ctx);
ctx.notify();
});
ctx.subscribe_to_model(&user_workspaces, |me, _, _, ctx| {
me.state = Self::determine_state(ctx);
ctx.notify();
});
ctx.subscribe_to_model(&network_status, |me, _, _, ctx| {
me.state = Self::determine_state(ctx);
ctx.notify();
});
ctx.subscribe_to_model(&privacy_settings, |me, _, _, ctx| {
me.state = Self::determine_state(ctx);
ctx.notify();
});
ctx.subscribe_to_model(&api_key_manager, |me, _, _, ctx| {
me.state = Self::determine_state(ctx);
ctx.notify();
});
Self {
state: Self::determine_state(ctx),
action_hyperlink: Default::default(),
}
}
pub fn determine_state(app: &AppContext) -> PromptAlertState {
// First, if the user is offline, no AI features will work.
if !NetworkStatus::as_ref(app).is_online() {
return PromptAlertState::NoConnection;
}
// Check if telemetry is disabled for free tier users.
// Free tier users must enable telemetry or upgrade to use AI features.
let privacy_settings = PrivacySettings::as_ref(app);
if !privacy_settings.is_telemetry_enabled {
// Fail safe: if billing status is unknown, assume paid to avoid showing confusing message to paying users
let is_on_paid_plan = UserWorkspaces::as_ref(app)
.current_workspace()
.map(|w| w.billing_metadata.is_user_on_paid_plan())
.unwrap_or(true);
if !is_on_paid_plan {
return PromptAlertState::TelemetryDisabledOnFreeTier;
}
}
let request_usage_model = AIRequestUsageModel::as_ref(app);
let has_requests_remaining = request_usage_model.has_requests_remaining();
let auth_state = AuthStateProvider::as_ref(app).get();
// Next, if the user is anonymous, we check if they have reached a certain percentage of requests used.
if auth_state
.is_anonymous_user_feature_gated()
.unwrap_or_default()
{
let percentage_used = request_usage_model.request_percentage_used();
if percentage_used >= ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PERCENTAGE {
if has_requests_remaining {
return PromptAlertState::AnonymousUserRequestLimitSoftGate;
} else {
return PromptAlertState::AnonymousUserRequestLimitHardGate;
}
}
}
// Next, make sure the user isn't delinquent in their plan.
let workspace = UserWorkspaces::as_ref(app).current_workspace();
if workspace.is_some_and(|w| w.billing_metadata.is_delinquent_due_to_payment_issue()) {
return PromptAlertState::DelinquentDueToPaymentIssue;
}
// If there is ever any ai remaining, no alert
if request_usage_model.has_any_ai_remaining(app) {
return PromptAlertState::NoAlert;
}
// Check if overages are available.
if let Some(workspace) = workspace {
let are_overages_toggleable = workspace.are_overages_toggleable();
let are_overages_enabled = workspace.are_overages_enabled();
if are_overages_toggleable {
if are_overages_enabled {
return PromptAlertState::MonthlyOveragesSpendLimitReached;
} else {
return PromptAlertState::OveragesToggleableButNotEnabled;
}
}
}
// If overages aren't available, and since we already checked that the user
// has no requests remaining, we can show the generic request limit reached alert.
PromptAlertState::RequestLimitReached
}
pub fn is_no_alert(&self) -> bool {
matches!(self.state, PromptAlertState::NoAlert)
}
pub fn state(&self) -> &PromptAlertState {
&self.state
}
pub fn does_alert_block_ai_requests(app: &AppContext) -> bool {
does_alert_block_ai_requests(&Self::determine_state(app))
}
fn primary_text(
&self,
state: &PromptAlertState,
text_fragments: &mut Vec<FormattedTextFragment>,
) {
// Add leading space to separate text from icon.
//
// Use this instead of hardcoded margin so it scales with font size and is consistent
// with the space between this primary fragment and the option hyperlink fragment.
text_fragments.push(FormattedTextFragment::plain_text(" "));
match state {
PromptAlertState::NoConnection => {
text_fragments.push(FormattedTextFragment::plain_text(
NO_CONNECTION_PRIMARY_TEXT,
));
}
PromptAlertState::TelemetryDisabledOnFreeTier => {
text_fragments.push(FormattedTextFragment::plain_text(
TELEMETRY_DISABLED_PRIMARY_TEXT,
));
}
PromptAlertState::AnonymousUserRequestLimitSoftGate => {
text_fragments.push(FormattedTextFragment::plain_text(
ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PRIMARY_TEXT,
));
}
PromptAlertState::AnonymousUserRequestLimitHardGate => {
text_fragments.push(FormattedTextFragment::plain_text(
ANONYMOUS_USER_REQUEST_LIMIT_HARD_GATE_PRIMARY_TEXT,
));
}
PromptAlertState::DelinquentDueToPaymentIssue => {
text_fragments.push(FormattedTextFragment::plain_text(
DELINQUENT_DUE_TO_PAYMENT_ISSUE_PRIMARY_TEXT,
));
}
PromptAlertState::OveragesToggleableButNotEnabled
| PromptAlertState::MonthlyOveragesSpendLimitReached
| PromptAlertState::RequestLimitReached => {
text_fragments.push(FormattedTextFragment::plain_text(
OUT_OF_REQUESTS_PRIMARY_TEXT,
));
}
PromptAlertState::NoAlert => {}
}
}
fn action_hyperlink(
&self,
state: &PromptAlertState,
text_fragments: &mut Vec<FormattedTextFragment>,
app: &AppContext,
) {
let auth_state = AuthStateProvider::as_ref(app).get();
let current_team = UserWorkspaces::as_ref(app).current_team();
let has_admin_permissions = current_team.is_some_and(|team| {
team.has_admin_permissions(&auth_state.user_email().unwrap_or_default())
});
match state {
PromptAlertState::NoConnection => {}
PromptAlertState::TelemetryDisabledOnFreeTier => {
// Show "enable analytics" action link
text_fragments.push(FormattedTextFragment::plain_text(" "));
text_fragments.push(FormattedTextFragment::hyperlink_action(
ENABLE_ANALYTICS_ACTION_TEXT,
PromptAlertAction::OpenPrivacySettingsClicked,
));
// Show "or upgrade to Build" link
text_fragments.push(FormattedTextFragment::plain_text(" or "));
let upgrade_url = if let Some(team) = UserWorkspaces::as_ref(app).current_team() {
UserWorkspaces::upgrade_link_for_team(team.uid)
} else {
let user_id = auth_state.user_id().unwrap_or_default();
UserWorkspaces::upgrade_link(user_id)
};
text_fragments.push(FormattedTextFragment::hyperlink(
UPGRADE_TO_BUILD_ACTION_TEXT,
upgrade_url,
));
text_fragments.push(FormattedTextFragment::plain_text("."));
}
PromptAlertState::AnonymousUserRequestLimitSoftGate
| PromptAlertState::AnonymousUserRequestLimitHardGate => {
text_fragments.push(FormattedTextFragment::plain_text(" "));
text_fragments.push(FormattedTextFragment::hyperlink_action(
ANONYMOUS_USER_REQUEST_LIMIT_ACTION_TEXT,
PromptAlertAction::SignUpClickedForAnonymousUser,
));
}
PromptAlertState::DelinquentDueToPaymentIssue => {
// Check if user is team admin with billing history
let has_billing_history = current_team
.map(|team| team.has_billing_history)
.unwrap_or_default();
if has_admin_permissions && has_billing_history {
text_fragments.push(FormattedTextFragment::plain_text(" "));
text_fragments.push(FormattedTextFragment::hyperlink_action(
DELINQUENT_DUE_TO_PAYMENT_ISSUE_ACTION_TEXT,
PromptAlertAction::ManageBillingClicked {
team_uid: current_team.map(|team| team.uid).unwrap_or_default(),
},
));
} else {
text_fragments.push(FormattedTextFragment::plain_text(
NON_ADMIN_CONTACT_ADMIN_TEXT,
));
}
}
PromptAlertState::OveragesToggleableButNotEnabled => {
if has_admin_permissions {
text_fragments.push(FormattedTextFragment::plain_text(" "));
text_fragments.push(FormattedTextFragment::hyperlink_action(
OVERAGES_TOGGLEABLE_BUT_NOT_ENABLED_ACTION_TEXT,
PromptAlertAction::OpenSettingsClicked,
));
} else {
text_fragments.push(FormattedTextFragment::plain_text(
NON_ADMIN_ASK_ADMIN_TO_ENABLE_OVERAGES_TEXT,
));
}
}
PromptAlertState::MonthlyOveragesSpendLimitReached => {
if has_admin_permissions {
text_fragments.push(FormattedTextFragment::plain_text(" "));
text_fragments.push(FormattedTextFragment::hyperlink_action(
MONTHLY_OVERAGES_SPEND_LIMIT_REACHED_ACTION_TEXT,
PromptAlertAction::OpenSettingsClicked,
));
} else {
text_fragments.push(FormattedTextFragment::plain_text(
NON_ADMIN_ASK_ADMIN_TO_INCREASE_OVERAGES_TEXT,
));
}
}
PromptAlertState::RequestLimitReached => {
text_fragments.push(FormattedTextFragment::plain_text(" "));
if let Some(team) = UserWorkspaces::as_ref(app).current_team() {
if team.billing_metadata.can_upgrade_to_higher_tier_plan() {
let upgrade_url = UserWorkspaces::upgrade_link_for_team(team.uid);
let upgrade_text = if !has_admin_permissions {
COMPARE_PLANS_TEXT
} else if team.billing_metadata.can_upgrade_to_build_plan() {
"Upgrade to Build"
} else {
UPGRADE_TEXT
};
text_fragments
.push(FormattedTextFragment::hyperlink(upgrade_text, upgrade_url));
} else {
text_fragments.push(FormattedTextFragment::hyperlink(
CONTACT_SUPPORT_TEXT,
"mailto:support@warp.dev".to_owned(),
));
}
} else {
let user_id = auth_state.user_id().unwrap_or_default();
let upgrade_url = UserWorkspaces::upgrade_link(user_id);
let label =
if let Some(workspace) = UserWorkspaces::as_ref(app).current_workspace() {
if workspace.billing_metadata.can_upgrade_to_build_plan() {
"Upgrade to Build"
} else {
UPGRADE_TEXT
}
} else {
UPGRADE_TEXT
};
text_fragments.push(FormattedTextFragment::hyperlink(label, upgrade_url));
}
if UserWorkspaces::as_ref(app).is_byo_api_key_enabled() {
text_fragments.push(FormattedTextFragment::plain_text(" or "));
text_fragments.push(FormattedTextFragment::hyperlink_action(
"use your own API keys",
WorkspaceAction::ShowSettingsPageWithSearch {
search_query: "api".to_string(),
section: Some(SettingsSection::WarpAgent),
},
));
}
}
PromptAlertState::NoAlert => {}
}
}
}
fn does_alert_block_ai_requests(state: &PromptAlertState) -> bool {
match state {
PromptAlertState::AnonymousUserRequestLimitSoftGate | PromptAlertState::NoAlert => false,
PromptAlertState::NoConnection
| PromptAlertState::TelemetryDisabledOnFreeTier
| PromptAlertState::AnonymousUserRequestLimitHardGate
| PromptAlertState::DelinquentDueToPaymentIssue
| PromptAlertState::OveragesToggleableButNotEnabled
| PromptAlertState::MonthlyOveragesSpendLimitReached
| PromptAlertState::RequestLimitReached => true,
}
}
impl Entity for PromptAlertView {
type Event = PromptAlertEvent;
}
impl View for PromptAlertView {
fn ui_name() -> &'static str {
"PromptAlertView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let state = Self::determine_state(app);
let mut text_fragments = vec![];
self.primary_text(&state, &mut text_fragments);
let auth_state = AuthStateProvider::as_ref(app).get();
let current_team = UserWorkspaces::as_ref(app).current_team();
let has_admin_permissions = auth_state
.user_email()
.zip(current_team)
.is_some_and(|(email, team)| team.has_admin_permissions(&email));
let can_purchase_addon_credits = current_team
.and_then(|team| team.billing_metadata.tier.purchase_add_on_credits_policy)
.is_some_and(|policy| policy.enabled);
let suggest_buy_credits = can_purchase_addon_credits
&& has_admin_permissions
&& matches!(
state,
PromptAlertState::RequestLimitReached
| PromptAlertState::OveragesToggleableButNotEnabled
| PromptAlertState::MonthlyOveragesSpendLimitReached
);
if suggest_buy_credits {
text_fragments.push(FormattedTextFragment::plain_text(" "));
text_fragments.push(FormattedTextFragment::hyperlink_action(
"Add credits",
WorkspaceAction::ShowSettingsPage(SettingsSection::BillingAndUsage),
));
} else {
self.action_hyperlink(&state, &mut text_fragments, app);
}
let formatted_text_element = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(text_fragments)]),
appearance.ui_font_size(),
appearance.ui_font_family(),
appearance.ui_font_family(),
error_color(appearance.theme()),
self.action_hyperlink.clone(),
)
.with_line_height_ratio(1.)
.with_hyperlink_font_color(appearance.theme().ansi_fg_blue())
.with_no_text_wrapping()
.register_default_click_handlers_with_action_support(|hyperlink_lens, event, ctx| {
match hyperlink_lens {
HyperlinkLens::Url(url) => {
ctx.open_url(url);
}
HyperlinkLens::Action(action_ref) => {
if let Some(action) = action_ref.as_any().downcast_ref::<PromptAlertAction>() {
event.dispatch_typed_action(action.clone());
} else if let Some(action) =
action_ref.as_any().downcast_ref::<WorkspaceAction>()
{
event.dispatch_typed_action(action.clone());
}
}
}
})
.finish();
let icon_size = appearance.ui_font_size();
let mut chip_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::End);
if does_alert_block_ai_requests(&self.state) {
chip_row.add_child(
ConstrainedBox::new(
Icon::AlertTriangle
.to_warpui_icon(error_color(appearance.theme()).into())
.finish(),
)
.with_width(icon_size)
.with_height(icon_size)
.finish(),
)
}
chip_row.add_child(formatted_text_element);
Container::new(chip_row.finish())
.with_margin_right(16.)
.finish()
}
}
impl TypedActionView for PromptAlertView {
type Action = PromptAlertAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
PromptAlertAction::SignUpClickedForAnonymousUser => {
ctx.emit(PromptAlertEvent::SignupAnonymousUser);
}
PromptAlertAction::OpenSettingsClicked => {
ctx.emit(PromptAlertEvent::OpenBillingAndUsagePage);
}
PromptAlertAction::OpenPrivacySettingsClicked => {
ctx.emit(PromptAlertEvent::OpenPrivacyPage);
}
PromptAlertAction::ManageBillingClicked { team_uid } => {
ctx.emit(PromptAlertEvent::OpenBillingPortal {
team_uid: *team_uid,
});
}
}
}
}