diff --git a/Cargo.lock b/Cargo.lock index 9f5999d3..5d47e8d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14767,7 +14767,7 @@ dependencies = [ [[package]] name = "warp_multi_agent_api" version = "0.0.0" -source = "git+https://github.com/warpdotdev/warp-proto-apis.git?rev=78a78f21a75432bf0141e396fb318bf1694e47f0#78a78f21a75432bf0141e396fb318bf1694e47f0" +source = "git+https://github.com/warpdotdev/warp-proto-apis.git?rev=aa2f9cde164a5b48ac01087d417d1188771f9b6d#aa2f9cde164a5b48ac01087d417d1188771f9b6d" dependencies = [ "prost 0.14.3", "prost-reflect", diff --git a/Cargo.toml b/Cargo.toml index f16d2e4c..da794548 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -304,7 +304,7 @@ version-compare = "0.1" vte = { git = "https://github.com/warpdotdev/vte.git", rev = "4b399c87b63ba88f45709edaa6383fc519f6c900", default-features = false } walkdir = "2" warp-workflows = { git = "https://github.com/warpdotdev/workflows", rev = "793a98ddda6ef19682aed66364faebd2829f0e01" } -warp_multi_agent_api = { git = "https://github.com/warpdotdev/warp-proto-apis.git", rev = "78a78f21a75432bf0141e396fb318bf1694e47f0" } +warp_multi_agent_api = { git = "https://github.com/warpdotdev/warp-proto-apis.git", rev = "aa2f9cde164a5b48ac01087d417d1188771f9b6d" } wasm-bindgen = "0.2.89" wasm-bindgen-futures = "0.4.42" web-sys = { version = "0.3.69", features = [ diff --git a/app/Cargo.toml b/app/Cargo.toml index 937f0360..2643f3fd 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -927,6 +927,7 @@ hoa_remote_control = [] codex_notifications = [] cloud_mode_setup_v2 = ["cloud_mode"] cloud_mode_input_v2 = ["cloud_mode"] +configurable_context_window = [] [package.metadata.bundle.bin.warp-oss] category = "public.app-category.developer-tools" diff --git a/app/src/ai/agent/api.rs b/app/src/ai/agent/api.rs index 2cb27725..821dab75 100644 --- a/app/src/ai/agent/api.rs +++ b/app/src/ai/agent/api.rs @@ -30,6 +30,7 @@ use crate::{ use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions}; use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput}; +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::mcp::templatable_manager::TemplatableMCPServerInfo; use crate::ai::mcp::TemplatableMCPServerManager; use crate::settings::AISettings; @@ -109,6 +110,7 @@ pub struct RequestParams { pub computer_use_model: LLMId, pub is_memory_enabled: bool, pub warp_drive_context_enabled: bool, + pub context_window_limit: Option, pub mcp_context: Option, pub planning_enabled: bool, should_redact_secrets: bool, @@ -279,6 +281,11 @@ impl RequestParams { .as_ref() .is_none_or(|t| matches!(t, crate::terminal::model::session::SessionType::Local)); + let context_window_limit = AIExecutionProfilesModel::as_ref(app) + .active_profile(terminal_view_id, app) + .data() + .context_window_limit; + Self { input: request_input.all_inputs().cloned().collect(), conversation_token: conversation.server_conversation_token, @@ -286,6 +293,7 @@ impl RequestParams { ambient_agent_task_id: conversation.ambient_agent_task_id, tasks: conversation.tasks, existing_suggestions: conversation.existing_suggestions, + context_window_limit, metadata, session_context, model: request_input.model_id.clone(), diff --git a/app/src/ai/agent/api/impl.rs b/app/src/ai/agent/api/impl.rs index d5390b8b..83c769c3 100644 --- a/app/src/ai/agent/api/impl.rs +++ b/app/src/ai/agent/api/impl.rs @@ -66,6 +66,13 @@ pub async fn generate_multi_agent_output( base: params.model.into(), cli_agent: params.cli_agent_model.into(), computer_use_agent: params.computer_use_model.into(), + base_model_context_window_limit: if FeatureFlag::ConfigurableContextWindow + .is_enabled() + { + params.context_window_limit.unwrap_or(0) + } else { + 0 + }, ..Default::default() }), rules_enabled: params.is_memory_enabled, diff --git a/app/src/ai/agent/api/impl_tests.rs b/app/src/ai/agent/api/impl_tests.rs index c219ff5c..e8305f62 100644 --- a/app/src/ai/agent/api/impl_tests.rs +++ b/app/src/ai/agent/api/impl_tests.rs @@ -24,6 +24,7 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool computer_use_model: model, is_memory_enabled: false, warp_drive_context_enabled: false, + context_window_limit: None, mcp_context: None, planning_enabled: true, should_redact_secrets: false, diff --git a/app/src/ai/blocklist/permissions.rs b/app/src/ai/blocklist/permissions.rs index 40649462..ec9c45d3 100644 --- a/app/src/ai/blocklist/permissions.rs +++ b/app/src/ai/blocklist/permissions.rs @@ -206,6 +206,7 @@ impl BlocklistAIPermissions { coding_model: profile_data.coding_model.clone(), cli_agent_model: profile_data.cli_agent_model.clone(), computer_use_model: profile_data.computer_use_model.clone(), + context_window_limit: profile_data.context_window_limit, autosync_plans_to_warp_drive: profile_data.autosync_plans_to_warp_drive, web_search_enabled: profile_data.web_search_enabled, } diff --git a/app/src/ai/execution_profiles/editor/mod.rs b/app/src/ai/execution_profiles/editor/mod.rs index 7863a369..ec637700 100644 --- a/app/src/ai/execution_profiles/editor/mod.rs +++ b/app/src/ai/execution_profiles/editor/mod.rs @@ -4,12 +4,14 @@ use crate::ai::execution_profiles::{ profiles::{AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId}, AIExecutionProfile, ActionPermission, WriteToPtyPermission, }; -use crate::ai::llms::{DisableReason, LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; +use crate::ai::llms::{ + DisableReason, LLMContextWindow, LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent, +}; use crate::ai::paths::host_native_absolute_path; use crate::editor::InteractionState; -use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions}; +use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions}; use crate::pane_group::focus_state::PaneFocusHandle; -use crate::settings::{AISettings, AgentModeCommandExecutionPredicate}; +use crate::settings::{AISettings, AISettingsChangedEvent, AgentModeCommandExecutionPredicate}; use crate::ui_components::icons::Icon; use crate::view_components::{ action_button::{ActionButton, DangerSecondaryTheme}, @@ -29,6 +31,7 @@ use regex::Regex; use warp_core::ui::theme::color::internal_colors; use warpui::fonts::Properties; use warpui::platform::Cursor; +use warpui::ui_components::slider::SliderStateHandle; use warpui::ui_components::switch::SwitchStateHandle; use std::path::{Path, PathBuf}; @@ -145,6 +148,15 @@ pub enum ExecutionProfileEditorViewAction { SetBaseModel { id: LLMId, }, + /// Fired continuously while the user drags the context window slider. + ContextWindowSliderDragged { + value: u32, + }, + /// Fired when the user commits a new context window value (slider drop, + /// track click, or input box commit). + SetContextWindowSize { + value: u32, + }, SetCodingModel { id: LLMId, }, @@ -222,6 +234,9 @@ pub struct ExecutionProfileEditorView { focus_handle: Option, clipped_scroll_state: ClippedScrollStateHandle, base_model_dropdown: ViewHandle>, + context_window_slider_state: SliderStateHandle, + context_window_editor: ViewHandle, + last_synced_context_window_editor_value: Option, coding_model_dropdown: ViewHandle>, full_terminal_use_model_dropdown: ViewHandle>, @@ -483,6 +498,27 @@ impl ExecutionProfileEditorView { dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx); dropdown }); + + // Initialize the context window editor buffer with the profile's + // persisted limit (or the active model's max as a sensible default). + // The slider's current position is derived from the profile on each + // render, so no local Cell is needed. + let initial_context_window_value = initial_context_window_display_value(&profile_data, ctx); + let context_window_slider_state = SliderStateHandle::default(); + let context_window_editor = ctx.add_typed_action_view(|ctx| { + let options = SingleLineEditorOptions { + text: TextOptions { + font_size_override: Some(Appearance::as_ref(ctx).ui_font_size()), + ..Default::default() + }, + ..Default::default() + }; + let mut editor = EditorView::single_line(options, ctx); + editor.set_buffer_text(&initial_context_window_value.to_string(), ctx); + editor + }); + let last_synced_context_window_editor_value = Some(initial_context_window_value); + let coding_model_dropdown = ctx.add_typed_action_view(|ctx| { let mut dropdown = Dropdown::new(ctx); dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx); @@ -574,6 +610,9 @@ impl ExecutionProfileEditorView { focus_handle: None, clipped_scroll_state: Default::default(), base_model_dropdown, + context_window_slider_state, + context_window_editor, + last_synced_context_window_editor_value, coding_model_dropdown, full_terminal_use_model_dropdown, computer_use_model_dropdown, @@ -608,6 +647,10 @@ impl ExecutionProfileEditorView { } }); + ctx.subscribe_to_view(&view.context_window_editor, |view, _, event, ctx| { + view.handle_context_window_editor_event(event, ctx); + }); + ctx.subscribe_to_view(&view.command_allowlist_editor, |view, _, event, ctx| { if let SubmittableTextInputEvent::Submit(s) = event { let predicate = match AgentModeCommandExecutionPredicate::new_regex(s) { @@ -696,6 +739,7 @@ impl ExecutionProfileEditorView { &me.upgrade_footer_mouse_state, ctx, ); + me.sync_context_window_editor(ctx, false); } LLMPreferencesEvent::UpdatedActiveAgentModeLLM => { Self::refresh_filterable_model_dropdown( @@ -707,6 +751,7 @@ impl ExecutionProfileEditorView { &me.upgrade_footer_mouse_state, ctx, ); + me.sync_context_window_editor(ctx, false); } LLMPreferencesEvent::UpdatedActiveCodingLLM => { Self::refresh_coding_model_dropdown( @@ -739,6 +784,7 @@ impl ExecutionProfileEditorView { current_permissions.coding_model.clone(), ctx, ); + me.sync_context_window_editor(ctx, false); ctx.notify(); }, ); @@ -760,6 +806,14 @@ impl ExecutionProfileEditorView { ctx.notify(); } }); + ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| { + if let AISettingsChangedEvent::IsAnyAIEnabled { .. } = event { + let workspace = UserWorkspaces::handle(ctx); + Self::update_all_editor_interaction_states(me, workspace, ctx); + me.sync_context_window_editor(ctx, true); + ctx.notify(); + } + }); Self::update_all_editor_interaction_states(&view, workspace, ctx); @@ -915,6 +969,7 @@ impl ExecutionProfileEditorView { ); Self::update_profile_name_editor(&self.profile_name_editor, ¤t_permissions, ctx); + self.sync_context_window_editor(ctx, false); } fn refresh_execution_profile_dropdown_menu( @@ -1252,10 +1307,111 @@ impl ExecutionProfileEditorView { } }); } + + fn configurable_context_window(&self, app: &AppContext) -> Option { + let profile = + BlocklistAIPermissions::as_ref(app).permissions_profile_for_id(app, self.profile_id); + profile.configurable_context_window(app) + } + + fn current_context_window_display_value(&self, app: &AppContext) -> Option { + let profile = + BlocklistAIPermissions::as_ref(app).permissions_profile_for_id(app, self.profile_id); + profile.context_window_display_value(app) + } + + fn handle_context_window_editor_event( + &mut self, + event: &EditorEvent, + ctx: &mut ViewContext, + ) { + match event { + EditorEvent::Blurred | EditorEvent::Enter => { + if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { + self.sync_context_window_editor(ctx, true); + return; + } + let Some(cw) = self.configurable_context_window(ctx) else { + return; + }; + let buffer_text = self.context_window_editor.as_ref(ctx).buffer_text(ctx); + let cleaned: String = buffer_text + .chars() + .filter(|c| !c.is_whitespace() && *c != ',') + .collect(); + if let Ok(parsed) = cleaned.parse::() { + let clamped = parsed.clamp(cw.min, cw.max); + if Some(clamped) != self.current_context_window_display_value(ctx) { + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| { + profiles_model.set_context_window_limit( + self.profile_id, + Some(clamped), + ctx, + ); + }); + } + } + self.sync_context_window_editor(ctx, true); + ctx.notify(); + } + _ => {} + } + } + + fn sync_context_window_editor(&mut self, ctx: &mut ViewContext, force: bool) { + let Some(value) = self.current_context_window_display_value(ctx) else { + self.last_synced_context_window_editor_value = None; + self.context_window_slider_state.reset_offset(); + ctx.notify(); + return; + }; + + let formatted = value.to_string(); + let should_update = if force { + true + } else { + match self.last_synced_context_window_editor_value { + Some(last_value) => { + self.context_window_editor.as_ref(ctx).buffer_text(ctx) + == last_value.to_string() + } + None => true, + } + }; + + if should_update { + self.context_window_editor.update(ctx, |editor, ctx| { + if editor.buffer_text(ctx) != formatted { + editor.system_reset_buffer_text(&formatted, ctx); + } + }); + self.last_synced_context_window_editor_value = Some(value); + self.context_window_slider_state.reset_offset(); + ctx.notify(); + } + } +} + +fn initial_context_window_display_value( + profile_data: &AIExecutionProfile, + app: &AppContext, +) -> u32 { + profile_data + .context_window_display_value(app) + .unwrap_or_else(|| { + LLMPreferences::as_ref(app) + .get_default_base_model() + .context_window + .default_max + }) } mod ui_helpers; +#[cfg(test)] +#[path = "mod_test.rs"] +mod tests; + impl View for ExecutionProfileEditorView { fn ui_name() -> &'static str { "ExecutionProfileEditorView" @@ -1274,7 +1430,7 @@ impl View for ExecutionProfileEditorView { &self.profile_name_editor, profile_data.is_default_profile, )) - .with_child(render_models_section(appearance, self)) + .with_child(render_models_section(appearance, self, app)) .with_child(render_permissions_section( appearance, self, @@ -1319,9 +1475,46 @@ impl TypedActionView for ExecutionProfileEditorView { ctx.emit(ExecutionProfileEditorViewEvent::Pane(PaneEvent::Close)); } ExecutionProfileEditorViewAction::SetBaseModel { id } => { + // Changing the base model resets any persisted context window + // override — the new model may have a different range (or not + // be configurable at all). The user can pick a new value for + // the new model if they want one. AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| { profiles_model.set_base_model(self.profile_id, Some(id.clone()), ctx); + profiles_model.set_context_window_limit(self.profile_id, None, ctx); + }); + self.sync_context_window_editor(ctx, true); + ctx.notify(); + } + ExecutionProfileEditorViewAction::ContextWindowSliderDragged { value } => { + if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { + self.sync_context_window_editor(ctx, true); + return; + } + // Transient drag update: reflect the current slider position + // in the input box without persisting to the profile yet. + // Persistence happens on SetContextWindowSize (drop / commit). + if self.configurable_context_window(ctx).is_some() { + let formatted = value.to_string(); + self.context_window_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&formatted, ctx); + }); + ctx.notify(); + } + } + ExecutionProfileEditorViewAction::SetContextWindowSize { value } => { + if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { + self.sync_context_window_editor(ctx, true); + return; + } + let Some(cw) = self.configurable_context_window(ctx) else { + return; + }; + let clamped = (*value).clamp(cw.min, cw.max); + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| { + profiles_model.set_context_window_limit(self.profile_id, Some(clamped), ctx); }); + self.sync_context_window_editor(ctx, true); ctx.notify(); } ExecutionProfileEditorViewAction::SetCodingModel { id } => { diff --git a/app/src/ai/execution_profiles/editor/mod_test.rs b/app/src/ai/execution_profiles/editor/mod_test.rs new file mode 100644 index 00000000..88b2b636 --- /dev/null +++ b/app/src/ai/execution_profiles/editor/mod_test.rs @@ -0,0 +1,84 @@ +use super::ui_helpers::context_window_snap_values; + +/// Helper: round-trip f32 → u32 for readable assertions and absorb the +/// negligible f64→f32 drift the snap helper picks up on large ranges. +fn rounded(values: &[f32]) -> Vec { + values.iter().map(|v| v.round() as u32).collect() +} + +#[test] +fn snap_values_for_min_eq_max_returns_single_point() { + assert_eq!( + rounded(&context_window_snap_values(50_000, 50_000)), + vec![50_000] + ); +} + +#[test] +fn snap_values_for_min_gt_max_collapses_to_min() { + // Defensive: invalid bounds shouldn't panic, just degrade gracefully. + assert_eq!(rounded(&context_window_snap_values(100, 50)), vec![100]); +} + +#[test] +fn snap_values_always_include_endpoints() { + let values = rounded(&context_window_snap_values(1_000, 200_000)); + assert_eq!(values.first(), Some(&1_000)); + assert_eq!(values.last(), Some(&200_000)); +} + +#[test] +fn snap_values_for_classic_200k_range_match_legacy_layout() { + // Mirrors the old hardcoded list, except `1_000` replaces the missing + // round multiple at the start. + let values = rounded(&context_window_snap_values(1_000, 200_000)); + assert_eq!( + values, + vec![1_000, 25_000, 50_000, 75_000, 100_000, 125_000, 150_000, 175_000, 200_000] + ); +} + +#[test] +fn snap_values_for_claude_1m_range_pick_100k_steps() { + let values = rounded(&context_window_snap_values(200_000, 1_000_000)); + assert_eq!( + values, + vec![200_000, 300_000, 400_000, 500_000, 600_000, 700_000, 800_000, 900_000, 1_000_000] + ); +} + +#[test] +fn snap_values_for_min_zero_skips_duplicate_zero() { + let values = rounded(&context_window_snap_values(0, 100)); + // First entry is min (0), then nice multiples up to and including max. + assert_eq!(values.first(), Some(&0)); + assert_eq!(values.last(), Some(&100)); + assert!(values.iter().filter(|&&v| v == 0).count() == 1); +} + +#[test] +fn snap_values_for_offset_min_align_to_nice_grid() { + // min=26_000 doesn't sit on a 25k boundary; first nice value is 50_000. + let values = rounded(&context_window_snap_values(26_000, 200_000)); + assert_eq!(values.first(), Some(&26_000)); + assert_eq!(values.last(), Some(&200_000)); + // Ensure the second point lands on a nice multiple, not on min+step. + assert_eq!(values.get(1), Some(&50_000)); +} + +#[test] +fn snap_values_keep_count_reasonable_for_huge_range() { + // 1B span should still produce a small (~9) snap-point list, not + // millions of entries. + let values = context_window_snap_values(0, 1_000_000_000); + assert!( + values.len() <= 12, + "expected at most 12 snap points, got {}", + values.len() + ); + assert!( + values.len() >= 5, + "expected at least 5 snap points, got {}", + values.len() + ); +} diff --git a/app/src/ai/execution_profiles/editor/ui_helpers.rs b/app/src/ai/execution_profiles/editor/ui_helpers.rs index d43351f1..4e16e478 100644 --- a/app/src/ai/execution_profiles/editor/ui_helpers.rs +++ b/app/src/ai/execution_profiles/editor/ui_helpers.rs @@ -9,6 +9,7 @@ use crate::TemplatableMCPServerManager; use pathfinder_geometry::vector::vec2f; use uuid::Uuid; use warp_core::features::FeatureFlag; +use warpui::elements::Dismiss; use warpui::elements::Hoverable; use warpui::elements::MouseStateHandle; use warpui::elements::{ @@ -17,13 +18,52 @@ use warpui::elements::{ Stack, Text, }; use warpui::fonts::{Properties, Weight}; -use warpui::ui_components::components::UiComponent; +use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use warpui::AppContext; use warpui::{Element, SingletonEntity, ViewHandle}; use super::ExecutionProfileEditorView; use super::ExecutionProfileEditorViewAction; +const CONTEXT_WINDOW_SLIDER_WIDTH: f32 = 220.; +const CONTEXT_WINDOW_INPUT_BOX_WIDTH: f32 = 120.; + +pub(super) fn context_window_snap_values(min: u32, max: u32) -> Vec { + if min >= max { + return vec![min as f32]; + } + let range = (max - min) as f64; + let step = nice_step(range / 8.0); + + let mut values = vec![min as f32]; + let mut v = (min as f64 / step).ceil() * step; + while v < max as f64 { + if v > min as f64 { + values.push(v as f32); + } + v += step; + } + if values.last().copied() != Some(max as f32) { + values.push(max as f32); + } + values +} + +fn nice_step(raw: f64) -> f64 { + let magnitude = 10f64.powf(raw.log10().floor()); + let normalized = raw / magnitude; + let nice = if normalized < 1.5 { + 1.0 + } else if normalized < 3.5 { + 2.5 + } else if normalized < 7.5 { + 5.0 + } else { + 10.0 + }; + nice * magnitude +} + use crate::settings_view::{render_input_list, render_separator, InputListItem}; pub const WORKSPACE_OVERRIDE_TOOLTIP_MESSAGE: &str = @@ -212,6 +252,7 @@ fn render_permission_row( pub fn render_models_section( appearance: &Appearance, view: &ExecutionProfileEditorView, + app: &AppContext, ) -> Box { let mut column = Flex::column() .with_child(render_separator(appearance)) @@ -221,14 +262,19 @@ pub fn render_models_section( "Base model", "This model serves as the primary engine behind the agent. It powers most interactions and invokes other models for tasks like planning or code generation when necessary. Warp may automatically switch to alternate models based on model availability or for auxiliary tasks such as conversation summarization.", &view.base_model_dropdown, - )) - .with_child(render_filterable_dropdown_row( - appearance, - "Full terminal use model", - "The model used when the agent operates inside interactive terminal applications like database shells, debuggers, REPLs, or dev servers—reading live output and writing commands to the PTY.", - &view.full_terminal_use_model_dropdown, )); + if let Some(row) = render_context_window_row(appearance, view, app) { + column.add_child(row); + } + + column = column.with_child(render_filterable_dropdown_row( + appearance, + "Full terminal use model", + "The model used when the agent operates inside interactive terminal applications like database shells, debuggers, REPLs, or dev servers—reading live output and writing commands to the PTY.", + &view.full_terminal_use_model_dropdown, + )); + if FeatureFlag::LocalComputerUse.is_enabled() { column.add_child(render_filterable_dropdown_row( appearance, @@ -243,6 +289,149 @@ pub fn render_models_section( .finish() } +/// Renders a `[min — slider — max] [input]` row beneath the base model +/// dropdown. Returns `None` if the active base model doesn't advertise a +/// configurable context window, global AI is disabled, or the +/// [`FeatureFlag::ConfigurableContextWindow`] flag is disabled. +fn render_context_window_row( + appearance: &Appearance, + view: &ExecutionProfileEditorView, + app: &AppContext, +) -> Option> { + if !FeatureFlag::ConfigurableContextWindow.is_enabled() { + return None; + } + if !AISettings::as_ref(app).is_any_ai_enabled(app) { + return None; + } + let cw = view.configurable_context_window(app)?; + let min = cw.min; + let max = cw.max; + + let label = Text::new( + "Context window".to_string(), + appearance.ui_font_family(), + 13., + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .finish(); + let min_label_text = min.to_string(); + let max_label_text = max.to_string(); + let desc = Text::new( + "The base model's working memory — how many tokens of your conversation, code, and documents it can consider at once. Larger windows enable longer conversations and more coherent responses over bigger codebases, at the cost of higher latency and compute usage.".to_string(), + appearance.ui_font_family(), + 11., + ) + .with_color( + appearance + .theme() + .sub_text_color(appearance.theme().surface_1()) + .into(), + ) + .finish(); + let label_desc = Flex::column().with_child(label).with_child(desc).finish(); + + let min_label = Text::new(min_label_text.clone(), appearance.ui_font_family(), 11.) + .with_color( + appearance + .theme() + .sub_text_color(appearance.theme().surface_1()) + .into(), + ) + .finish(); + let max_label = Text::new(max_label_text.clone(), appearance.ui_font_family(), 11.) + .with_color( + appearance + .theme() + .sub_text_color(appearance.theme().surface_1()) + .into(), + ) + .finish(); + + let current_value = view + .current_context_window_display_value(app) + .unwrap_or(cw.default_max) + .clamp(min, max); + let slider = appearance + .ui_builder() + .slider(view.context_window_slider_state.clone()) + .with_range(min as f32..max as f32) + .with_snap_values(context_window_snap_values(min, max)) + .with_default_value(current_value as f32) + .with_style(UiComponentStyles { + width: Some(CONTEXT_WINDOW_SLIDER_WIDTH), + margin: Some(Coords::default().left(8.).right(8.)), + ..Default::default() + }) + .on_drag(|ctx, _, val| { + ctx.dispatch_typed_action( + ExecutionProfileEditorViewAction::ContextWindowSliderDragged { + value: val.round() as u32, + }, + ); + }) + .on_change(|ctx, _, val| { + ctx.dispatch_typed_action(ExecutionProfileEditorViewAction::SetContextWindowSize { + value: val.round() as u32, + }); + }) + .build() + .finish(); + + let context_window_editor = view.context_window_editor.clone(); + let input_box = Dismiss::new( + appearance + .ui_builder() + .text_input(view.context_window_editor.clone()) + .with_style(UiComponentStyles { + width: Some(CONTEXT_WINDOW_INPUT_BOX_WIDTH), + padding: Some(Coords { + top: 6., + bottom: 6., + left: 10., + right: 10., + }), + margin: Some(Coords::default().left(12.)), + background: Some(appearance.theme().surface_2().into()), + ..Default::default() + }) + .build() + .finish(), + ) + .on_dismiss(move |ctx, app| { + let buffer_text = context_window_editor.as_ref(app).buffer_text(app); + let cleaned: String = buffer_text + .chars() + .filter(|c| !c.is_whitespace() && *c != ',') + .collect(); + if let Ok(parsed) = cleaned.parse::() { + ctx.dispatch_typed_action(ExecutionProfileEditorViewAction::SetContextWindowSize { + value: parsed, + }); + } + }) + .finish(); + + let slider_row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(min_label) + .with_child(slider) + .with_child(max_label) + .with_child(input_box) + .finish(); + + Some( + Container::new( + Flex::column() + .with_child(Container::new(label_desc).with_margin_bottom(4.).finish()) + .with_child(slider_row) + .finish(), + ) + .with_margin_bottom(12.) + .finish(), + ) +} + pub fn render_permissions_section( appearance: &Appearance, view: &ExecutionProfileEditorView, diff --git a/app/src/ai/execution_profiles/mod.rs b/app/src/ai/execution_profiles/mod.rs index ceea985d..be7c66a2 100644 --- a/app/src/ai/execution_profiles/mod.rs +++ b/app/src/ai/execution_profiles/mod.rs @@ -23,7 +23,7 @@ use warp_core::channel::ChannelState; use warp_core::features::FeatureFlag; use warpui::{AppContext, SingletonEntity}; -use super::llms::LLMId; +use super::llms::{LLMContextWindow, LLMId, LLMPreferences}; pub const PROFILE_NAME_MAX_LENGTH: usize = 50; @@ -248,6 +248,8 @@ pub struct AIExecutionProfile { pub cli_agent_model: Option, pub computer_use_model: Option, + pub context_window_limit: Option, + /// Whether plans created by the agent should be automatically synced to Warp Drive pub autosync_plans_to_warp_drive: bool, @@ -276,6 +278,7 @@ impl Default for AIExecutionProfile { coding_model: None, cli_agent_model: None, computer_use_model: None, + context_window_limit: None, autosync_plans_to_warp_drive: true, web_search_enabled: true, } @@ -327,6 +330,7 @@ impl AIExecutionProfile { coding_model: None, cli_agent_model: None, computer_use_model: None, + context_window_limit: None, autosync_plans_to_warp_drive: false, web_search_enabled: true, } @@ -381,12 +385,35 @@ impl AIExecutionProfile { coding_model: None, cli_agent_model: None, computer_use_model: None, + context_window_limit: None, autosync_plans_to_warp_drive: FeatureFlag::SyncAmbientPlans.is_enabled(), web_search_enabled: true, } } } +impl AIExecutionProfile { + pub fn configurable_context_window(&self, app: &AppContext) -> Option { + let prefs = LLMPreferences::as_ref(app); + let cw = self + .base_model + .as_ref() + .and_then(|id| prefs.get_llm_info(id)) + .map(|info| info.context_window.clone()) + .unwrap_or_else(|| prefs.get_default_base_model().context_window.clone()); + if cw.is_configurable && cw.max > 0 { + Some(cw) + } else { + None + } + } + + pub fn context_window_display_value(&self, app: &AppContext) -> Option { + let cw = self.configurable_context_window(app)?; + Some(self.context_window_limit.unwrap_or(cw.default_max)) + } +} + pub type CloudAIExecutionProfile = GenericCloudObject; pub type CloudAIExecutionProfileModel = GenericStringModel; diff --git a/app/src/ai/execution_profiles/profiles.rs b/app/src/ai/execution_profiles/profiles.rs index 093d3e18..ed9678ab 100644 --- a/app/src/ai/execution_profiles/profiles.rs +++ b/app/src/ai/execution_profiles/profiles.rs @@ -600,6 +600,32 @@ impl AIExecutionProfilesModel { } } + pub fn set_context_window_limit( + &mut self, + profile_id: ClientProfileId, + limit: Option, + ctx: &mut ModelContext, + ) { + let changed = self.edit_profile_internal( + profile_id, + |profile| { + if profile.context_window_limit != limit { + profile.context_window_limit = limit; + return true; + } + false + }, + ctx, + ); + + if changed { + send_telemetry_from_ctx!( + TelemetryEvent::AIExecutionProfileContextWindowSelected { tokens: limit }, + ctx + ); + } + } + pub fn set_apply_code_diffs( &mut self, profile_id: ClientProfileId, @@ -1152,17 +1178,21 @@ impl AIExecutionProfilesModel { /// * `profile_id`: The id of the profile to edit /// * `edit_fn`: a closure that safely modifies the AIExecutionProfile. It should return `true` if the profile was changed, `false` otherwise. When `true`, it syncs the changes to the cloud, and otherwise exits early to prevent excessive cloud operations if no changes occured. /// * `ctx`: The model context + /// + /// Returns `true` if the profile was actually changed (and synced), + /// `false` otherwise. Callers can use this to gate side effects such as + /// telemetry on real changes. fn edit_profile_internal( &mut self, profile_id: ClientProfileId, edit_fn: impl FnOnce(&mut AIExecutionProfile) -> bool, ctx: &mut ModelContext, - ) { + ) -> bool { // We don't yet support editing the default profile for the CLI. if let DefaultProfileState::Cli { id, .. } = &self.default_profile_state { if *id == profile_id { log::warn!("Attempted to edit CLI default profile, which is not yet supported."); - return; + return false; } } @@ -1174,7 +1204,7 @@ impl AIExecutionProfilesModel { // If the edit function didn't make any changes to the profile, it's still the default profile, so we don't need to sync it let value_changed = edit_fn(&mut new_profile); if !value_changed { - return; + return false; } if let Some(owner) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) { @@ -1215,10 +1245,11 @@ impl AIExecutionProfilesModel { ); } ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(profile_id)); - return; + return true; } } + let mut value_changed = false; if let Some(sync_id) = self.profile_id_to_sync_id.get(&profile_id) { let cloud_model = CloudModel::as_ref(ctx); if let Some(object) = cloud_model @@ -1226,9 +1257,9 @@ impl AIExecutionProfilesModel { { let mut data = object.model().string_model.clone(); // If the edit function didn't make any changes to the profile, we should exit early - let value_changed = edit_fn(&mut data); + value_changed = edit_fn(&mut data); if !value_changed { - return; + return false; } let update_manager = UpdateManager::handle(ctx); update_manager.update(ctx, |update_manager, ctx| { @@ -1241,6 +1272,7 @@ impl AIExecutionProfilesModel { } } ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(profile_id)); + value_changed } /// Handle CloudModel events to keep the profile_id_to_sync_id map and default profile state up to date. diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index d2c22096..e4cd9e3e 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -121,6 +121,18 @@ pub struct RoutingHostConfig { pub model_routing_host: LLMModelHost, } +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LLMContextWindow { + #[serde(default)] + pub is_configurable: bool, + #[serde(default)] + pub min: u32, + #[serde(default)] + pub max: u32, + #[serde(default)] + pub default_max: u32, +} + /// Metadata about an LLM. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct LLMInfo { @@ -136,6 +148,7 @@ pub struct LLMInfo { pub provider: LLMProvider, pub host_configs: HashMap, pub discount_percentage: Option, + pub context_window: LLMContextWindow, } impl<'de> Deserialize<'de> for LLMInfo { @@ -181,6 +194,8 @@ impl<'de> Deserialize<'de> for LLMInfo { host_configs: HostConfigsWire, #[serde(default)] discount_percentage: Option, + #[serde(default)] + context_window: LLMContextWindow, } let wire = WireLLMInfo::deserialize(deserializer)?; @@ -215,6 +230,7 @@ impl<'de> Deserialize<'de> for LLMInfo { spec: wire.spec, host_configs, discount_percentage: wire.discount_percentage, + context_window: wire.context_window, }) } } @@ -281,6 +297,7 @@ impl LLMInfo { provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, + context_window: LLMContextWindow::default(), } } } @@ -406,6 +423,7 @@ fn default_computer_use_llms() -> AvailableLLMs { provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, + context_window: LLMContextWindow::default(), }], preferred_codex_model_id: None, } @@ -432,6 +450,7 @@ impl Default for ModelsByFeature { provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, + context_window: LLMContextWindow::default(), }], preferred_codex_model_id: None, }, @@ -453,6 +472,7 @@ impl Default for ModelsByFeature { provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, + context_window: LLMContextWindow::default(), }], preferred_codex_model_id: None, }, @@ -474,6 +494,7 @@ impl Default for ModelsByFeature { provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, + context_window: LLMContextWindow::default(), }], preferred_codex_model_id: None, }), @@ -924,20 +945,33 @@ impl LLMPreferences { } } - // Clear any model selections where the model is no longer supported. + // Clear any model selections where the model is no longer supported, + // and clear orphaned context window limits for non-configurable models. let profiles_model = AIExecutionProfilesModel::handle(ctx); profiles_model.update(ctx, |profiles, ctx| { for profile_id in profiles.get_all_profile_ids() { if let Some(profile) = profiles.get_profile_by_id(profile_id, ctx) { - if let Some(preferred_llm_id) = &profile.data().base_model { - if self - .models_by_feature - .agent_mode - .info_for_id(preferred_llm_id) - .is_none() - { - profiles.set_base_model(profile_id, None, ctx); - } + let profile_data = profile.data(); + let preferred_base_model = profile_data.base_model.clone(); + let effective_base_model_id = preferred_base_model + .as_ref() + .unwrap_or(&self.models_by_feature.agent_mode.default_id); + let effective_base_model_info = self + .models_by_feature + .agent_mode + .info_for_id(effective_base_model_id); + let effective_base_model_missing = effective_base_model_info.is_none(); + let effective_base_model_is_configurable = effective_base_model_info + .is_some_and(|info| info.context_window.is_configurable); + let has_context_window_limit = profile_data.context_window_limit.is_some(); + + if preferred_base_model.is_some() && effective_base_model_missing { + profiles.set_base_model(profile_id, None, ctx); + } + if has_context_window_limit + && (effective_base_model_missing || !effective_base_model_is_configurable) + { + profiles.set_context_window_limit(profile_id, None, ctx); } if let Some(preferred_llm_id) = &profile.data().coding_model { if self diff --git a/app/src/lib.rs b/app/src/lib.rs index b65346d9..9c61c308 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2772,6 +2772,8 @@ pub fn enabled_features() -> HashSet { FeatureFlag::CloudModeSetupV2, #[cfg(feature = "cloud_mode_input_v2")] FeatureFlag::CloudModeInputV2, + #[cfg(feature = "configurable_context_window")] + FeatureFlag::ConfigurableContextWindow, ]); flags diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index 6e859d36..60d4054e 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -42,8 +42,8 @@ use crate::{ use crate::{ ai::{ llms::{ - AvailableLLMs, DisableReason, LLMInfo, LLMModelHost, LLMProvider, LLMSpec, - LLMUsageMetadata, ModelsByFeature, RoutingHostConfig, + AvailableLLMs, DisableReason, LLMContextWindow, LLMInfo, LLMModelHost, LLMProvider, + LLMSpec, LLMUsageMetadata, ModelsByFeature, RoutingHostConfig, }, RequestUsageInfo, }, @@ -2066,6 +2066,12 @@ impl From for LLMInfo provider: value.provider.into(), host_configs, discount_percentage: value.pricing.discount_percentage.map(|v| v as f32), + context_window: LLMContextWindow { + is_configurable: value.context_window.is_configurable, + min: value.context_window.min.into(), + max: value.context_window.max.into(), + default_max: value.context_window.default.into(), + }, } } } @@ -2099,6 +2105,12 @@ impl From for LLMInfo { provider: value.provider.into(), host_configs, discount_percentage: value.pricing.discount_percentage.map(|v| v as f32), + context_window: LLMContextWindow { + is_configurable: value.context_window.is_configurable, + min: value.context_window.min.into(), + max: value.context_window.max.into(), + default_max: value.context_window.default.into(), + }, } } } diff --git a/app/src/server/telemetry/events.rs b/app/src/server/telemetry/events.rs index 1dda5604..5f007eed 100644 --- a/app/src/server/telemetry/events.rs +++ b/app/src/server/telemetry/events.rs @@ -2492,6 +2492,9 @@ pub enum TelemetryEvent { model_type: String, model_value: String, }, + AIExecutionProfileContextWindowSelected { + tokens: Option, + }, /// The AI input was not sent because there was already an in-flight request. AIInputNotSent { entrypoint: Option, @@ -4223,6 +4226,9 @@ impl TelemetryEvent { "model_type": model_type, "model_value": model_value, })), + TelemetryEvent::AIExecutionProfileContextWindowSelected { tokens } => Some(json!({ + "tokens": tokens, + })), TelemetryEvent::AIInputNotSent { entrypoint, inputs, @@ -4917,6 +4923,7 @@ impl TelemetryEvent { | TelemetryEvent::AIExecutionProfileRemovedFromAllowlist { .. } | TelemetryEvent::AIExecutionProfileRemovedFromDenylist { .. } | TelemetryEvent::AIExecutionProfileModelSelected { .. } + | TelemetryEvent::AIExecutionProfileContextWindowSelected { .. } | TelemetryEvent::OpenSlashMenu { .. } | TelemetryEvent::SlashCommandAccepted { .. } | TelemetryEvent::AgentModeSetupBannerAccepted @@ -5476,7 +5483,8 @@ impl TelemetryEventDesc for TelemetryEventDiscriminants { | Self::AIExecutionProfileAddedToDenylist { .. } | Self::AIExecutionProfileRemovedFromAllowlist { .. } | Self::AIExecutionProfileRemovedFromDenylist { .. } - | Self::AIExecutionProfileModelSelected { .. } => { + | Self::AIExecutionProfileModelSelected { .. } + | Self::AIExecutionProfileContextWindowSelected { .. } => { EnablementState::Flag(FeatureFlag::MultiProfile) } Self::AIInputNotSent { .. } => EnablementState::Always, @@ -6028,6 +6036,9 @@ impl TelemetryEventDesc for TelemetryEventDiscriminants { "AI Execution Profile: Removed From Denylist" } Self::AIExecutionProfileModelSelected { .. } => "AI Execution Profile: Model Selected", + Self::AIExecutionProfileContextWindowSelected { .. } => { + "AI Execution Profile: Context Window Selected" + } Self::AIInputNotSent { .. } => "AI Input Not Sent", Self::OpenSlashMenu { .. } => "Open Slash Menu", Self::SlashCommandAccepted { .. } => "Slash Command Accepted", @@ -6103,6 +6114,9 @@ impl TelemetryEventDesc for TelemetryEventDiscriminants { fn description(&self) -> &'static str { match self { + Self::AIExecutionProfileContextWindowSelected => { + "Selected a context window limit for an execution profile's base model" + } Self::AISuggestedAgentModeWorkflowAdded => { "User created an AI suggested Agent Mode workflow" } diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index b00e5cca..84813351 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -8,8 +8,8 @@ use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; use crate::ai::execution_profiles::profiles::{ AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId, }; -use crate::ai::execution_profiles::{ActionPermission, WriteToPtyPermission}; -use crate::ai::llms::{LLMId, LLMPreferences, LLMPreferencesEvent}; +use crate::ai::execution_profiles::{AIExecutionProfile, ActionPermission, WriteToPtyPermission}; +use crate::ai::llms::{LLMContextWindow, LLMId, LLMPreferences, LLMPreferencesEvent}; use crate::ai::mcp::TemplatableMCPServerManager; use crate::ai::paths::host_native_absolute_path; use crate::auth::auth_manager::{AuthManager, LoginGatedFeature}; @@ -53,12 +53,13 @@ use warp_core::context_flag::ContextFlag; use warp_core::features::FeatureFlag; use warp_core::ui::theme::color::internal_colors; use warpui::elements::{ - Border, ChildView, ConstrainedBox, CornerRadius, CrossAxisAlignment, Expanded, Fill, + Border, ChildView, ConstrainedBox, CornerRadius, CrossAxisAlignment, Dismiss, Expanded, Fill, HyperlinkLens, MainAxisAlignment, MainAxisSize, MouseStateHandle, Radius, Shrinkable, Text, }; use warpui::fonts::{Properties, Weight}; use warpui::id; use warpui::keymap::ContextPredicate; +use warpui::ui_components::slider::SliderStateHandle; use warpui::{ elements::{ Container, Flex, FormattedTextElement, HighlightedHyperlink, HyperlinkUrl, ParentElement, @@ -146,6 +147,9 @@ const PRIMARY_HEADER_FONT_SIZE: f32 = 24.; const AI_SETTINGS_DROPDOWN_WIDTH: f32 = 250.; const AI_SETTINGS_DROPDOWN_MAX_HEIGHT: f32 = 250.; +const CONTEXT_WINDOW_SLIDER_WIDTH: f32 = 220.; +const CONTEXT_WINDOW_INPUT_BOX_WIDTH: f32 = 120.; + const NEXT_COMMAND_DESCRIPTION: &str = "Let AI suggest the next command to run based on your command history, outputs, and common workflows."; const PROMPT_SUGGESTIONS_DESCRIPTION: &str = "Let AI suggest natural language prompts, as inline banners in the input, based on recent commands and their outputs."; const SUGGESTED_CODE_BANNERS_DESCRIPTION: &str = @@ -440,6 +444,10 @@ pub struct AISettingsPageView { base_model_dropdown: ViewHandle>, coding_model_dropdown: ViewHandle>, + context_window_slider_state: SliderStateHandle, + context_window_editor: ViewHandle, + last_synced_context_window_editor_value: Option, + thinking_display_mode_dropdown: ViewHandle>, #[cfg(feature = "local_fs")] conversation_layout_dropdown: ViewHandle>, @@ -543,6 +551,29 @@ impl AISettingsPageView { }); Self::refresh_base_model_menu(&base_model_dropdown, ctx); + let initial_context_window_value = Self::initial_context_window_value(ctx); + let clamped_initial = Self::configurable_context_window(ctx) + .map(|cw| initial_context_window_value.clamp(cw.min, cw.max)) + .unwrap_or(initial_context_window_value); + let context_window_slider_state = SliderStateHandle::default(); + + let context_window_editor = ctx.add_typed_action_view(|ctx| { + let options = SingleLineEditorOptions { + text: TextOptions { + font_size_override: Some(Appearance::as_ref(ctx).ui_font_size()), + ..Default::default() + }, + ..Default::default() + }; + let mut editor = EditorView::single_line(options, ctx); + editor.set_buffer_text(&clamped_initial.to_string(), ctx); + editor + }); + ctx.subscribe_to_view(&context_window_editor, |me, _, event, ctx| { + me.handle_context_window_editor_event(event, ctx); + }); + let last_synced_context_window_editor_value = Some(clamped_initial); + let thinking_display_mode_dropdown = OtherAIWidget::create_thinking_display_mode_dropdown(ctx); // Set initial selection based on current setting value. @@ -751,6 +782,7 @@ impl AISettingsPageView { AIExecutionProfilesModelEvent::ProfileUpdated(_) => { me.refresh_all_execution_profile_ui(ctx); me.reset_execution_profile_mouse_state_handles(ctx); + me.sync_context_window_editor(ctx, false); } AIExecutionProfilesModelEvent::UpdatedActiveProfile { .. } => (), } @@ -789,9 +821,11 @@ impl AISettingsPageView { LLMPreferencesEvent::UpdatedAvailableLLMs => { Self::refresh_base_model_menu(&me.base_model_dropdown, ctx); Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx); + me.sync_context_window_editor(ctx, false); } LLMPreferencesEvent::UpdatedActiveAgentModeLLM => { Self::refresh_base_model_menu(&me.base_model_dropdown, ctx); + me.sync_context_window_editor(ctx, false); } LLMPreferencesEvent::UpdatedActiveCodingLLM => { Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx); @@ -803,6 +837,7 @@ impl AISettingsPageView { ctx.subscribe_to_model(&ApiKeyManager::handle(ctx), |me, _model, _event, ctx| { Self::refresh_base_model_menu(&me.base_model_dropdown, ctx); Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx); + me.sync_context_window_editor(ctx, false); ctx.notify(); }); @@ -881,6 +916,7 @@ impl AISettingsPageView { Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx); Self::refresh_mcp_allowlist_dropdown(&me.mcp_allowlist_dropdown, ctx); Self::refresh_mcp_denylist_dropdown(&me.mcp_denylist_dropdown, ctx); + me.sync_context_window_editor(ctx, true); } AISettingsChangedEvent::VoiceInputEnabled { .. } => { me.update_voice_input_dropdown_enablement(ctx); @@ -1368,6 +1404,9 @@ impl AISettingsPageView { cli_agent_toolbar_inline_editor, base_model_dropdown, coding_model_dropdown, + context_window_slider_state, + context_window_editor, + last_synced_context_window_editor_value, autonomy_dropdown_menu, code_read_allowlist_editor, code_read_autonomy_dropdown_menu, @@ -1536,6 +1575,108 @@ impl AISettingsPageView { PageType::new_uncategorized(widgets, title) } + fn handle_context_window_editor_event( + &mut self, + event: &EditorEvent, + ctx: &mut ViewContext, + ) { + match event { + EditorEvent::Blurred | EditorEvent::Enter => { + if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { + self.sync_context_window_editor(ctx, true); + return; + } + if let Some(cw) = Self::configurable_context_window(ctx) { + let buffer_text = self.context_window_editor.as_ref(ctx).buffer_text(ctx); + let cleaned: String = buffer_text + .chars() + .filter(|c| !c.is_whitespace() && *c != ',') + .collect(); + if let Ok(parsed) = cleaned.parse::() { + let clamped = parsed.clamp(cw.min, cw.max); + if Some(clamped) != Self::current_context_window_display_value(ctx) { + AIExecutionProfilesModel::handle(ctx).update( + ctx, + |profiles_model, ctx| { + let profile_id = *profiles_model.active_profile(None, ctx).id(); + profiles_model.set_context_window_limit( + profile_id, + Some(clamped), + ctx, + ); + }, + ); + } + } + } + self.sync_context_window_editor(ctx, true); + if let EditorEvent::Enter = event { + ctx.emit(AISettingsPageEvent::FocusModal); + } + ctx.notify(); + } + EditorEvent::Escape => ctx.emit(AISettingsPageEvent::FocusModal), + _ => {} + } + } + + fn active_profile_data(app: &AppContext) -> AIExecutionProfile { + AIExecutionProfilesModel::as_ref(app) + .active_profile(None, app) + .data() + .clone() + } + + fn configurable_context_window(app: &AppContext) -> Option { + Self::active_profile_data(app).configurable_context_window(app) + } + + fn current_context_window_display_value(app: &AppContext) -> Option { + Self::active_profile_data(app).context_window_display_value(app) + } + + fn initial_context_window_value(app: &AppContext) -> u32 { + Self::current_context_window_display_value(app).unwrap_or_else(|| { + LLMPreferences::as_ref(app) + .get_active_base_model(app, None) + .context_window + .default_max + }) + } + + fn sync_context_window_editor(&mut self, ctx: &mut ViewContext, force: bool) { + let Some(value) = Self::current_context_window_display_value(ctx) else { + self.last_synced_context_window_editor_value = None; + self.context_window_slider_state.reset_offset(); + ctx.notify(); + return; + }; + + let formatted = value.to_string(); + let should_update = if force { + true + } else { + match self.last_synced_context_window_editor_value { + Some(last_value) => { + self.context_window_editor.as_ref(ctx).buffer_text(ctx) + == last_value.to_string() + } + None => true, + } + }; + + if should_update { + self.context_window_editor.update(ctx, |editor, ctx| { + if editor.buffer_text(ctx) != formatted { + editor.system_reset_buffer_text(&formatted, ctx); + } + }); + self.last_synced_context_window_editor_value = Some(value); + self.context_window_slider_state.reset_offset(); + ctx.notify(); + } + } + fn handle_detection_denylist_editor_event( &mut self, event: &EditorEvent, @@ -2084,6 +2225,11 @@ pub enum AISettingsPageAction { OpenExecutionProfileEditor(ClientProfileId), SetBaseModel(LLMId), SetCodingModel(LLMId), + /// Called while the user is actively dragging the context window slider. + ContextWindowSliderDragged(u32), + /// Called when the user commits a new context window value (slider drop or + /// input box commit). + SetContextWindowSize(u32), SetAutonomyReadonlyCommandsSetting, SetAutonomySupervisedSetting, SetCodingPermission(AgentModeCodingPermissionsType), @@ -2548,9 +2694,11 @@ impl TypedActionView for AISettingsPageView { } AISettingsPageAction::SetBaseModel(id) => { AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| { - let profile = profiles_model.default_profile(ctx); - profiles_model.set_base_model(*profile.id(), Some(id.clone()), ctx); + let profile_id = *profiles_model.active_profile(None, ctx).id(); + profiles_model.set_base_model(profile_id, Some(id.clone()), ctx); + profiles_model.set_context_window_limit(profile_id, None, ctx); }); + self.sync_context_window_editor(ctx, true); ctx.notify(); } AISettingsPageAction::SetCodingModel(id) => { @@ -2558,6 +2706,35 @@ impl TypedActionView for AISettingsPageView { prefs.update_preferred_coding_llm(id, None, ctx); }); } + AISettingsPageAction::ContextWindowSliderDragged(value) => { + if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { + self.sync_context_window_editor(ctx, true); + return; + } + if Self::configurable_context_window(ctx).is_some() { + let formatted = value.to_string(); + self.context_window_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&formatted, ctx); + }); + ctx.notify(); + } + } + AISettingsPageAction::SetContextWindowSize(value) => { + if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { + self.sync_context_window_editor(ctx, true); + return; + } + let Some(cw) = Self::configurable_context_window(ctx) else { + return; + }; + let clamped = (*value).clamp(cw.min, cw.max); + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| { + let profile_id = *profiles_model.active_profile(None, ctx).id(); + profiles_model.set_context_window_limit(profile_id, Some(clamped), ctx); + }); + self.sync_context_window_editor(ctx, true); + ctx.notify(); + } AISettingsPageAction::SetAutonomyReadonlyCommandsSetting | AISettingsPageAction::SetAutonomySupervisedSetting => { let readonly_cmd_execution_enabled = matches!( @@ -3933,9 +4110,139 @@ impl AgentsWidget { .with_margin_bottom(8.0) .finish(); - Flex::column() - .with_children([model_subheader, base_model_setting]) - .finish() + let mut children = vec![model_subheader, base_model_setting]; + if let Some(context_window_setting) = + self.render_context_window_setting(view, ai_settings, appearance, app) + { + children.push( + Container::new(context_window_setting) + .with_margin_bottom(8.0) + .finish(), + ); + } + + Flex::column().with_children(children).finish() + } + + /// Renders the context window slider + numeric input row shown below the + /// base model dropdown. Returns `None` if the active base model does not + /// advertise a configurable context window, global AI is disabled, or the + /// [`FeatureFlag::ConfigurableContextWindow`] flag is disabled. + fn render_context_window_setting( + &self, + view: &AISettingsPageView, + ai_settings: &AISettings, + appearance: &Appearance, + app: &AppContext, + ) -> Option> { + if !FeatureFlag::ConfigurableContextWindow.is_enabled() { + return None; + } + if !ai_settings.is_any_ai_enabled(app) { + return None; + } + let cw = AISettingsPageView::configurable_context_window(app)?; + let min = cw.min; + let max = cw.max; + + let label = Container::new(render_body_item_label::( + "Context window (tokens)".to_string(), + None, + None, + LocalOnlyIconState::Hidden, + ToggleState::Enabled, + appearance, + )) + .with_margin_bottom(4.0) + .finish(); + + let min_label = appearance + .ui_builder() + .span(format!("{min}")) + .with_style(UiComponentStyles { + font_size: Some(CONTENT_FONT_SIZE), + ..Default::default() + }) + .build() + .finish(); + + let max_label = appearance + .ui_builder() + .span(format!("{max}")) + .with_style(UiComponentStyles { + font_size: Some(CONTENT_FONT_SIZE), + ..Default::default() + }) + .build() + .finish(); + + let current_value = AISettingsPageView::current_context_window_display_value(app) + .unwrap_or(cw.default_max) + .clamp(min, max); + let slider = appearance + .ui_builder() + .slider(view.context_window_slider_state.clone()) + .with_range(min as f32..max as f32) + .with_default_value(current_value as f32) + .with_style(UiComponentStyles { + width: Some(CONTEXT_WINDOW_SLIDER_WIDTH), + margin: Some(Coords::default().left(8.).right(8.)), + ..Default::default() + }) + .on_drag(|ctx, _, val| { + ctx.dispatch_typed_action(AISettingsPageAction::ContextWindowSliderDragged( + val.round() as u32, + )); + }) + .on_change(|ctx, _, val| { + ctx.dispatch_typed_action(AISettingsPageAction::SetContextWindowSize( + val.round() as u32 + )); + }) + .build() + .finish(); + + let context_window_editor = view.context_window_editor.clone(); + let input_box = Dismiss::new( + appearance + .ui_builder() + .text_input(view.context_window_editor.clone()) + .with_style(UiComponentStyles { + width: Some(CONTEXT_WINDOW_INPUT_BOX_WIDTH), + padding: Some(Coords { + top: 6., + bottom: 6., + left: 10., + right: 10., + }), + margin: Some(Coords::default().left(12.)), + background: Some(appearance.theme().surface_2().into()), + ..Default::default() + }) + .build() + .finish(), + ) + .on_dismiss(move |ctx, app| { + let buffer_text = context_window_editor.as_ref(app).buffer_text(app); + let cleaned: String = buffer_text + .chars() + .filter(|c| !c.is_whitespace() && *c != ',') + .collect(); + if let Ok(parsed) = cleaned.parse::() { + ctx.dispatch_typed_action(AISettingsPageAction::SetContextWindowSize(parsed)); + } + }) + .finish(); + + let row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(min_label) + .with_child(slider) + .with_child(max_label) + .with_child(input_box) + .finish(); + + Some(Flex::column().with_child(label).with_child(row).finish()) } fn render_permissions_section( diff --git a/crates/graphql/src/api/mod.rs b/crates/graphql/src/api/mod.rs index 3b986453..b4b716ba 100644 --- a/crates/graphql/src/api/mod.rs +++ b/crates/graphql/src/api/mod.rs @@ -24,3 +24,4 @@ pub use warp_graphql_schema::schema; use cynic::impl_scalar; impl_scalar!(crate::scalars::Time, schema::Time); +impl_scalar!(crate::scalars::Uint32, schema::Uint); diff --git a/crates/graphql/src/api/queries/get_feature_model_choices.rs b/crates/graphql/src/api/queries/get_feature_model_choices.rs index 49028972..46932983 100644 --- a/crates/graphql/src/api/queries/get_feature_model_choices.rs +++ b/crates/graphql/src/api/queries/get_feature_model_choices.rs @@ -80,6 +80,14 @@ pub struct RoutingHostConfig { pub model_routing_host: LlmModelHost, } +#[derive(cynic::QueryFragment, Debug)] +pub struct LlmContextWindow { + pub is_configurable: bool, + pub min: crate::scalars::Uint32, + pub max: crate::scalars::Uint32, + pub default: crate::scalars::Uint32, +} + #[derive(cynic::QueryFragment, Debug)] pub struct LlmInfo { pub display_name: String, @@ -94,6 +102,7 @@ pub struct LlmInfo { pub provider: LlmProvider, pub host_configs: Vec, pub pricing: LlmPricing, + pub context_window: LlmContextWindow, } #[derive(cynic::QueryFragment, Debug)] diff --git a/crates/graphql/src/api/workspace.rs b/crates/graphql/src/api/workspace.rs index 7eb28c68..2770f4b6 100644 --- a/crates/graphql/src/api/workspace.rs +++ b/crates/graphql/src/api/workspace.rs @@ -43,6 +43,14 @@ pub struct RoutingHostConfig { pub model_routing_host: LlmModelHost, } +#[derive(cynic::QueryFragment, Debug, Clone)] +pub struct LlmContextWindow { + pub is_configurable: bool, + pub min: crate::scalars::Uint32, + pub max: crate::scalars::Uint32, + pub default: crate::scalars::Uint32, +} + #[derive(cynic::QueryFragment, Debug, Clone)] pub struct LlmInfo { pub display_name: String, @@ -57,6 +65,7 @@ pub struct LlmInfo { pub provider: LlmProvider, pub host_configs: Vec, pub pricing: LlmPricing, + pub context_window: LlmContextWindow, } #[derive(cynic::QueryFragment, Debug, Clone)] diff --git a/crates/graphql/src/scalars/mod.rs b/crates/graphql/src/scalars/mod.rs index 4e5f70ec..a6d7989f 100644 --- a/crates/graphql/src/scalars/mod.rs +++ b/crates/graphql/src/scalars/mod.rs @@ -1,2 +1,5 @@ pub mod time; +pub mod uint32; + pub type Time = time::ServerTimestamp; +pub use uint32::Uint32; diff --git a/crates/graphql/src/scalars/uint32.rs b/crates/graphql/src/scalars/uint32.rs new file mode 100644 index 00000000..ebe6ccae --- /dev/null +++ b/crates/graphql/src/scalars/uint32.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +/// Wrapper around `u32` for use with the `Uint` GraphQL scalar. +/// +/// Cynic's `impl_scalar!` macro can't target a primitive type directly (the +/// orphan rule forbids implementing a foreign trait on a foreign type), so we +/// expose this local newtype instead. +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +#[serde(transparent)] +pub struct Uint32(pub u32); + +impl From for Uint32 { + fn from(value: u32) -> Self { + Self(value) + } +} + +impl From for u32 { + fn from(value: Uint32) -> Self { + value.0 + } +} diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index e47c9e29..cff0e747 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -835,6 +835,12 @@ pub enum FeatureFlag { VerticalTabsSummaryMode, CloudModeInputV2, + + /// Gates the user-configurable context window slider in AI settings and + /// the execution profile editor. When disabled, the slider is hidden and + /// `base_model_context_window_limit` is not sent on outbound requests, so + /// the server falls back to its default. + ConfigurableContextWindow, } static FLAG_STATES: [AtomicBool; cardinality::()] = @@ -911,6 +917,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::LocalDockerSandbox, FeatureFlag::VerticalTabsSummaryMode, FeatureFlag::CloudModeSetupV2, + FeatureFlag::ConfigurableContextWindow, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). diff --git a/crates/warp_graphql_schema/api/schema.graphql b/crates/warp_graphql_schema/api/schema.graphql index cba06030..0b109471 100644 --- a/crates/warp_graphql_schema/api/schema.graphql +++ b/crates/warp_graphql_schema/api/schema.graphql @@ -1783,6 +1783,32 @@ type ListedSimpleIntegrationConfig { workerHost: String } +type LlmContextWindow { + """ + Default context window size used when the client + does not send an explicit base_model_context_window_limit. For non-configurable + models this equals max. + """ + default: Uint! + + """ + When true, the client can pick a context window size in the range [min, max]. + """ + isConfigurable: Boolean! + + """ + Maximum context window size. The upper bound for any user override + and the effective ceiling the server enforces on the request. + """ + max: Uint! + + """ + Minimum context window size the user may select. For non-configurable + models this equals max. + """ + min: Uint! +} + type LlmHostSettings { enabled: Boolean! enablementSetting: HostEnablementSetting @@ -1810,6 +1836,9 @@ input LlmHostSettingsInputEntry { type LlmInfo { baseModelName: String! + + """The model's context window configuration. Always non-null.""" + contextWindow: LlmContextWindow! description: String disableReason: DisableReason displayName: String! @@ -3400,6 +3429,12 @@ type UgcDataCollectionPolicy { toggleable: Boolean! } +""" +scalar Uint is an unsigned integer. Maps to uint/uint32/uint64 on the Go +side and a non-negative number on the client side. +""" +scalar Uint + enum UniquePer { User } diff --git a/crates/warpui_core/src/ui_components/slider.rs b/crates/warpui_core/src/ui_components/slider.rs index c40f9389..2d142765 100644 --- a/crates/warpui_core/src/ui_components/slider.rs +++ b/crates/warpui_core/src/ui_components/slider.rs @@ -61,15 +61,6 @@ impl SliderStateHandle { self.inner.lock().thumb_offset_x } - // Returns the 'value' represented by the slider's current position along the track. The - // returned value is normalized to the given value_range. - fn get_value(&self, draggable_width: f32, value_range: &Range) -> f32 { - let state = self.inner.lock(); - let thumb_offset_x = state.thumb_offset_x.unwrap_or(0.); - let canonical_value = thumb_offset_x / draggable_width; - canonical_value * (value_range.end - value_range.start) + value_range.start - } - /// Sets the inner [`SliderState`] to `new_state`. fn store(&self, new_state: SliderState) { let mut guard = self.inner.lock(); @@ -89,6 +80,18 @@ impl SliderStateHandle { /// value has changed. type OnValueChangedFn = dyn Fn(&mut EventContext, &AppContext, f32) + 'static; +/// Shared track geometry and snapping configuration passed to every +/// slider callback registration function. +#[derive(Clone)] +struct SliderTrackConfig { + track_position_id: String, + thumb_size: f32, + value_range: Range, + step: Option, + snap_values: Option>>, + state_handle: SliderStateHandle, +} + /// Slider UiComponent for modulating a value between given bounds. /// /// Builder methods allow the caller to configure the styling of the slider, as well as set a @@ -106,6 +109,8 @@ pub struct Slider { styles: UiComponentStyles, value_range: Range, default_value: Option, + step: Option, + snap_values: Option>>, } impl Slider { @@ -121,12 +126,33 @@ impl Slider { thumb_fill: *DEFAULT_THUMB_FILL, value_range: 0.0..1., default_value: None, + step: None, + snap_values: None, styles: UiComponentStyles { ..Default::default() }, } } + /// Sets a step size so that both the thumb and emitted value snap to + /// discrete increments of `step` from `value_range.start`. `value_range.end` + /// is always reachable even if it isn't step-aligned. + pub fn with_step(mut self, step: f32) -> Self { + self.step = Some(step); + self + } + + /// Sets an explicit list of discrete values that the slider snaps to. + /// Drag/drop/click events snap to the nearest value in the list by + /// absolute distance, and the thumb is positioned **linearly** based on + /// the value (`(value - start) / (end - start)`) — this keeps + /// non-step-aligned inputs from looking logarithmic. Takes precedence + /// over [`Self::with_step`] when set. + pub fn with_snap_values(mut self, values: Vec) -> Self { + self.snap_values = Some(Arc::new(values)); + self + } + pub fn with_thumb_size(mut self, thumb_size: f32) -> Self { self.thumb_size = thumb_size; self @@ -184,21 +210,33 @@ impl Slider { /// thumb. /// /// This callback stores the thumb's x-axis offset from the start of the track in the - /// given `SliderStateHandle`. - fn register_on_drag_start_callback( - thumb_draggable: &mut Draggable, - track_position_id: String, - state_handle: SliderStateHandle, - ) { + /// given `SliderStateHandle`, snapping if configured. + fn register_on_drag_start_callback(thumb_draggable: &mut Draggable, config: SliderTrackConfig) { thumb_draggable.set_on_drag_start(move |event_ctx, _app, thumb_position| { let track_position = event_ctx - .element_position_by_id(track_position_id.as_str()) + .element_position_by_id(config.track_position_id.as_str()) .expect("Track should be laid out by the time the slider is dragged."); - // Save the position along the x-axis of the thumb when the drag started. - state_handle.store(SliderState { - thumb_offset_x: Some(thumb_position.origin_x() - track_position.origin_x()), + let raw_offset_x = thumb_position.origin_x() - track_position.origin_x(); + let draggable_width = draggable_width(track_position, config.thumb_size); + let (snapped_offset_x, _) = snap_offset_and_value( + raw_offset_x, + draggable_width, + &config.value_range, + config.step, + config.snap_values.as_deref().map(Vec::as_slice), + ); + + config.state_handle.store(SliderState { + thumb_offset_x: Some(snapped_offset_x), }); + let delta = snapped_offset_x - raw_offset_x; + if delta.abs() > f32::EPSILON { + config + .state_handle + .thumb_draggable_state + .adjust_mouse_position(vec2f(delta, 0.)); + } }); } @@ -211,31 +249,39 @@ impl Slider { /// state. fn register_on_drag_callback( thumb_draggable: &mut Draggable, - track_position_id: String, - thumb_size: f32, - value_range: Range, - state_handle: SliderStateHandle, + config: SliderTrackConfig, on_drag_callback: Option>, ) { thumb_draggable.set_on_drag(move |event_ctx, app, thumb_position, _| { let track_position = event_ctx - .element_position_by_id(track_position_id.as_str()) + .element_position_by_id(config.track_position_id.as_str()) .expect("Track should be laid out by the time the slider is dragged."); - let current_thumb_offset_x = thumb_position.origin_x() - track_position.origin_x(); + let raw_offset_x = thumb_position.origin_x() - track_position.origin_x(); + let draggable_width = draggable_width(track_position, config.thumb_size); + let (snapped_offset_x, snapped_value) = snap_offset_and_value( + raw_offset_x, + draggable_width, + &config.value_range, + config.step, + config.snap_values.as_deref().map(Vec::as_slice), + ); + + let delta = snapped_offset_x - raw_offset_x; + if delta.abs() > f32::EPSILON { + config + .state_handle + .thumb_draggable_state + .adjust_mouse_position(vec2f(delta, 0.)); + } - // The on_drag callback is called even if the draggable element's position - // hasn't changed -- only call the on_change callback if the slider's - // position has changed. - if Some(current_thumb_offset_x) != state_handle.thumb_offset_x() { - state_handle.store(SliderState { - thumb_offset_x: Some(current_thumb_offset_x), + if Some(snapped_offset_x) != config.state_handle.thumb_offset_x() { + config.state_handle.store(SliderState { + thumb_offset_x: Some(snapped_offset_x), }); if let Some(callback) = &on_drag_callback { - let draggable_width = draggable_width(track_position, thumb_size); - let updated_value = state_handle.get_value(draggable_width, &value_range); - callback(event_ctx, app, updated_value); + callback(event_ctx, app, snapped_value); } } }); @@ -249,24 +295,28 @@ impl Slider { /// `thumb_offset_x` in the slider's state. fn register_on_drop_callback( thumb_draggable: &mut Draggable, - track_position_id: String, - thumb_size: f32, - value_range: Range, - state_handle: SliderStateHandle, + config: SliderTrackConfig, on_change_callback: Option>, ) { thumb_draggable.set_on_drop(move |event_ctx, app, thumb_position, _| { let track_position = event_ctx - .element_position_by_id(track_position_id.as_str()) + .element_position_by_id(config.track_position_id.as_str()) .expect("Track should be laid out by the time the slider is dropped."); - state_handle.store(SliderState { - thumb_offset_x: Some(thumb_position.origin_x() - track_position.origin_x()), + let raw_offset_x = thumb_position.origin_x() - track_position.origin_x(); + let draggable_width = draggable_width(track_position, config.thumb_size); + let (snapped_offset_x, snapped_value) = snap_offset_and_value( + raw_offset_x, + draggable_width, + &config.value_range, + config.step, + config.snap_values.as_deref().map(Vec::as_slice), + ); + config.state_handle.store(SliderState { + thumb_offset_x: Some(snapped_offset_x), }); if let Some(callback) = &on_change_callback { - let draggable_width = draggable_width(track_position, thumb_size); - let updated_value = state_handle.get_value(draggable_width, &value_range); - callback(event_ctx, app, updated_value); + callback(event_ctx, app, snapped_value); } }); } @@ -278,42 +328,113 @@ impl Slider { /// dragged the thumb to that location, without all the intermediate on_drag calls. fn register_on_click_callback( track_hoverable: Hoverable, - track_position_id: String, - thumb_size: f32, - value_range: Range, - state_handle: SliderStateHandle, + config: SliderTrackConfig, on_change_callback: Option>, ) -> Hoverable { track_hoverable.on_click(move |event_ctx, app, click_position| { - let Some(track_position) = event_ctx.element_position_by_id(track_position_id.as_str()) + let Some(track_position) = + event_ctx.element_position_by_id(config.track_position_id.as_str()) else { return; }; let click_position_x = click_position.x(); - let padding = thumb_size / 2.; + let padding = config.thumb_size / 2.; let min_x = track_position.min_x() + padding; let max_x = track_position.max_x() - padding; - // If the user clicks outside of the actual visible portion of the track, - // we do not proceed. if min_x > click_position_x || max_x < click_position_x { return; } - state_handle.store(SliderState { - thumb_offset_x: Some(click_position_x - min_x), + let raw_offset_x = click_position_x - min_x; + let draggable_width = draggable_width(track_position, config.thumb_size); + let (snapped_offset_x, snapped_value) = snap_offset_and_value( + raw_offset_x, + draggable_width, + &config.value_range, + config.step, + config.snap_values.as_deref().map(Vec::as_slice), + ); + + config.state_handle.store(SliderState { + thumb_offset_x: Some(snapped_offset_x), }); if let Some(callback) = &on_change_callback { - let draggable_width = draggable_width(track_position, thumb_size); - let updated_value = state_handle.get_value(draggable_width, &value_range); - callback(event_ctx, app, updated_value); + callback(event_ctx, app, snapped_value); } }) } } +/// Snaps `raw_offset_x` (a pixel offset along the slider track) to the +/// nearest discrete position, returning both the snapped pixel offset and +/// the corresponding value. +/// +/// If `snap_values` is provided it takes precedence: the raw value (linearly +/// derived from the pixel position and `value_range`) is snapped to the +/// nearest entry in the list by absolute distance, and the returned pixel +/// offset is positioned **linearly** by the snapped value — so positions +/// along the slider always match the value scale. Otherwise `step` (if any) +/// is used for linear stepping from `value_range.start`. +fn snap_offset_and_value( + raw_offset_x: f32, + draggable_width: f32, + value_range: &Range, + step: Option, + snap_values: Option<&[f32]>, +) -> (f32, f32) { + if draggable_width <= 0. { + return (raw_offset_x, value_range.start); + } + let canonical = (raw_offset_x / draggable_width).clamp(0., 1.); + let raw_value = canonical * (value_range.end - value_range.start) + value_range.start; + + if let Some(values) = snap_values { + if !values.is_empty() { + // Snap to nearest value by absolute distance. + let snapped_value = values.iter().copied().fold(values[0], |best, v| { + if (v - raw_value).abs() < (best - raw_value).abs() { + v + } else { + best + } + }); + let snapped_canonical = value_to_canonical_linear(snapped_value, value_range); + return (snapped_canonical * draggable_width, snapped_value); + } + } + + let Some(step) = step.filter(|s| *s > 0.) else { + return (raw_offset_x, raw_value); + }; + + // Snap to nearest step from `range.start`, with `range.end` always reachable. + let snapped_value = if value_range.end - raw_value < step / 2. { + value_range.end + } else { + let offset_from_start = raw_value - value_range.start; + let steps = (offset_from_start / step).round(); + (value_range.start + steps * step).clamp(value_range.start, value_range.end) + }; + + let snapped_canonical = value_to_canonical_linear(snapped_value, value_range); + (snapped_canonical * draggable_width, snapped_value) +} + +/// Linearly maps `value` to a canonical 0..1 position along the slider +/// track. Used both for snap positioning and for rendering `default_value`, +/// so non-snap values (e.g. typed into a freeform input box) render at a +/// position proportional to their actual magnitude. +fn value_to_canonical_linear(value: f32, value_range: &Range) -> f32 { + let span = value_range.end - value_range.start; + if span <= 0. { + return 0.; + } + ((value - value_range.start) / span).clamp(0., 1.) +} + impl UiComponent for Slider { type ElementType = Container; @@ -330,6 +451,8 @@ impl UiComponent for Slider { styles, value_range, default_value, + step, + snap_values, } = self; let track_position_id = slider_track_position_id.clone(); @@ -354,25 +477,20 @@ impl UiComponent for Slider { }) }); - Self::register_on_drag_start_callback( - &mut slider_thumb, - slider_track_position_id.clone(), - state_handle.clone(), - ); - Self::register_on_drag_callback( - &mut slider_thumb, - slider_track_position_id.clone(), + let config = SliderTrackConfig { + track_position_id: slider_track_position_id.clone(), thumb_size, - value_range.clone(), - state_handle.clone(), - on_drag_callback, - ); + value_range: value_range.clone(), + step, + snap_values, + state_handle: state_handle.clone(), + }; + + Self::register_on_drag_start_callback(&mut slider_thumb, config.clone()); + Self::register_on_drag_callback(&mut slider_thumb, config.clone(), on_drag_callback); Self::register_on_drop_callback( &mut slider_thumb, - slider_track_position_id.clone(), - thumb_size, - value_range.clone(), - state_handle.clone(), + config.clone(), on_change_callback.clone(), ); @@ -380,14 +498,7 @@ impl UiComponent for Slider { render_track(thumb_size, styles.width, track_height, track_fill) }); - let track = Self::register_on_click_callback( - track, - slider_track_position_id.clone(), - thumb_size, - value_range.clone(), - state_handle.clone(), - on_change_callback.clone(), - ); + let track = Self::register_on_click_callback(track, config, on_change_callback.clone()); let mut slider = Stack::new(); @@ -399,10 +510,7 @@ impl UiComponent for Slider { Some(offset_x) => OffsetType::Pixel(offset_x), None => OffsetType::Percentage( default_value - .map(|value| { - ((value - value_range.start) / (value_range.end - value_range.start)) - .clamp(0., 1.) - }) + .map(|value| value_to_canonical_linear(value, &value_range)) .unwrap_or(0.), ), }; @@ -448,6 +556,8 @@ impl UiComponent for Slider { thumb_fill: self.thumb_fill, value_range: self.value_range, default_value: self.default_value, + step: self.step, + snap_values: self.snap_values, styles: self.styles.merge(styles), } }