From 8c9507d871dc6e36e5dc537a2b3da5ae73fcd8fb Mon Sep 17 00:00:00 2001 From: Abhishek Pandya Date: Tue, 28 Apr 2026 16:37:26 -0400 Subject: [PATCH 1/6] Slash command menu roughly working --- app/src/terminal/input.rs | 71 +- app/src/terminal/input/agent.rs | 35 +- .../slash_commands/cloud_mode_v2_view.rs | 1103 +++++++++++++++++ .../input/slash_commands/data_source/mod.rs | 31 + .../slash_commands/data_source/zero_state.rs | 86 +- app/src/terminal/input/slash_commands/mod.rs | 34 +- app/src/terminal/input/slash_commands/view.rs | 5 +- 7 files changed, 1343 insertions(+), 22 deletions(-) create mode 100644 app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 5e5d1af8..dec6f227 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -75,7 +75,8 @@ use crate::terminal::input::rewind::{RewindMenuEvent, RewindMenuView}; use crate::terminal::input::skills::{InlineSkillSelectorEvent, InlineSkillSelectorView}; use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashCommandModel}; use crate::terminal::input::slash_commands::{ - InlineSlashCommandView, SlashCommandDataSource, SlashCommandTrigger, + CloudModeV2SlashCommandView, InlineSlashCommandView, SlashCommandDataSource, + SlashCommandTrigger, }; use crate::terminal::input::suggestions_mode_model::{ InputSuggestionsModeEvent, InputSuggestionsModeModel, @@ -1612,6 +1613,10 @@ pub struct Input { prompt_suggestions_view: ViewHandle, inline_slash_commands_view: ViewHandle, + /// V2 cloud-mode slash command menu, lazily constructed only when + /// `FeatureFlag::CloudModeInputV2` is on. Sibling of + /// `inline_slash_commands_view`; the legacy view is unaffected. + cloud_mode_v2_slash_commands_view: Option>, slash_command_data_source: ModelHandle, /// Inline conversation menu for selecting AI conversations. @@ -3111,6 +3116,27 @@ impl Input { me.handle_slash_commands_menu_event(event, ctx); }); + // The V2 menu shares `SlashCommandsEvent` with the legacy view so + // `handle_slash_commands_menu_event` handles both. Only constructed + // when V2 is enabled at runtime. + let cloud_mode_v2_slash_commands_view = if FeatureFlag::CloudModeInputV2.is_enabled() { + let view = ctx.add_typed_action_view(|ctx| { + CloudModeV2SlashCommandView::new( + &slash_command_model, + slash_command_data_source.clone(), + suggestions_mode_model.clone(), + buffer_model.clone(), + ctx, + ) + }); + ctx.subscribe_to_view(&view, |me, _, event, ctx| { + me.handle_slash_commands_menu_event(event, ctx); + }); + Some(view) + } else { + None + }; + ctx.subscribe_to_model(&ai_input_model, move |me, _, event, ctx| { match event { BlocklistAIInputEvent::InputTypeChanged { .. } @@ -3247,6 +3273,7 @@ impl Input { prompt_suggestions_view, slash_command_model, inline_slash_commands_view, + cloud_mode_v2_slash_commands_view, inline_conversation_menu_view, inline_plan_menu_view, inline_repos_menu_view, @@ -7526,9 +7553,17 @@ impl Input { true } InputSuggestionsMode::SlashCommands => { - self.inline_slash_commands_view.update(ctx, |view, ctx| { - view.select_up(ctx); - }); + if self.is_cloud_mode_input_v2_composing(ctx) { + if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() { + view.update(ctx, |view, ctx| { + view.select_up(ctx); + }); + } + } else { + self.inline_slash_commands_view.update(ctx, |view, ctx| { + view.select_up(ctx); + }); + } true } InputSuggestionsMode::ConversationMenu => { @@ -7879,9 +7914,17 @@ impl Input { true } InputSuggestionsMode::SlashCommands => { - self.inline_slash_commands_view.update(ctx, |view, ctx| { - view.select_down(ctx); - }); + if self.is_cloud_mode_input_v2_composing(ctx) { + if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() { + view.update(ctx, |view, ctx| { + view.select_down(ctx); + }); + } + } else { + self.inline_slash_commands_view.update(ctx, |view, ctx| { + view.select_down(ctx); + }); + } true } InputSuggestionsMode::ConversationMenu => { @@ -11729,9 +11772,17 @@ impl Input { .update(ctx, |view, ctx| view.accept_selected_item(ctx)); return; } else if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() { - self.inline_slash_commands_view.update(ctx, |view, ctx| { - view.accept_selected_item(false, ctx); - }); + if self.is_cloud_mode_input_v2_composing(ctx) { + if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() { + view.update(ctx, |view, ctx| { + view.accept_selected_item(false, ctx); + }); + } + } else { + self.inline_slash_commands_view.update(ctx, |view, ctx| { + view.accept_selected_item(false, ctx); + }); + } return; } else if self.maybe_queue_input_for_in_progress_conversation(ctx) || self.maybe_handle_enter_for_slash_command(ctx) diff --git a/app/src/terminal/input/agent.rs b/app/src/terminal/input/agent.rs index f8f64238..f3b7342e 100644 --- a/app/src/terminal/input/agent.rs +++ b/app/src/terminal/input/agent.rs @@ -17,6 +17,7 @@ use crate::{ }, appearance::Appearance, context_chips::spacing::{self}, + editor::position_id_for_cursor, features::FeatureFlag, settings::InputModeSettings, terminal::{settings::TerminalSettings, view::TerminalAction}, @@ -247,7 +248,11 @@ impl Input { .is_profile_selector() { column.add_child(ChildView::new(&self.inline_profile_selector_view).finish()); - } else if self.suggestions_mode_model.as_ref(app).is_slash_commands() { + } else if self.suggestions_mode_model.as_ref(app).is_slash_commands() + && !self.is_cloud_mode_input_v2_composing(app) + { + // V2 composing renders its own cursor-anchored menu; the legacy + // inline-above-input slash menu must not also appear. column.add_child(ChildView::new(&self.inline_slash_commands_view).finish()); } else if self.suggestions_mode_model.as_ref(app).is_prompts_menu() { column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish()); @@ -389,6 +394,34 @@ impl Input { ); } + // Cursor-anchored slash command menu for V2 composing. Mirrors the + // overlay pattern used by `render_ai_context_menu`. The legacy menu is + // gated out of the V1 column path above when V2 is composing, so the + // two cannot render simultaneously. Anchor parent's bottom-left to the + // child's top-left so the menu drops *below* the cursor. + if self.suggestions_mode_model.as_ref(app).is_slash_commands() { + if let Some(view) = self.cloud_mode_v2_slash_commands_view.as_ref() { + let cursor_position = position_id_for_cursor(self.editor.id()); + stack.add_positioned_overlay_child( + ChildView::new(view).finish(), + OffsetPositioning::from_axes( + PositioningAxis::relative_to_stack_child( + &cursor_position, + PositionedElementOffsetBounds::WindowByPosition, + OffsetType::Pixel(0.), + AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left), + ), + PositioningAxis::relative_to_stack_child( + &cursor_position, + PositionedElementOffsetBounds::Unbounded, + OffsetType::Pixel(4.), + AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top), + ), + ), + ); + } + } + if let Some(selected_workflow_state) = self.workflows_state.selected_workflow_state.as_ref() { if selected_workflow_state.should_show_more_info_view { diff --git a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs new file mode 100644 index 00000000..151e416c --- /dev/null +++ b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs @@ -0,0 +1,1103 @@ +//! Cloud-mode V2 slash command menu. +//! +//! A floating, cursor-anchored alternative to `InlineSlashCommandView` that is +//! gated behind `FeatureFlag::CloudModeInputV2`. The legacy view is left +//! untouched everywhere else. +//! +//! Rendering is driven by a single `MenuState` enum so that the two visible +//! shapes (`NoSearchActive` sectioned, `SearchActive` flat) are mutually +//! exclusive and never coexist. + +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +use pathfinder_color::ColorU; +use warp_core::ui::appearance::Appearance; +use warp_core::ui::theme::Fill; +use warpui::elements::{ + Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, + CrossAxisAlignment, DispatchEventResult, DropShadow, EventHandler, Flex, Hoverable, + MainAxisSize, MouseInBehavior, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text, +}; +use warpui::platform::Cursor; +use warpui::{ + AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, + WeakViewHandle, +}; + +use crate::search::data_source::QueryFilter; +use crate::search::mixer::{AddAsyncSourceOptions, SearchMixer, SearchMixerEvent}; +use crate::search::result_renderer::{QueryResultRenderer, QueryResultRendererStyles}; +use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent}; +use crate::terminal::input::inline_menu::styles as inline_styles; +use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashCommandModel}; +use crate::terminal::input::slash_commands::view::{slash_command_query, CloseReason}; +use crate::terminal::input::slash_commands::{ + saved_prompts_data_source, AcceptSlashCommandOrSavedPrompt, SlashCommandDataSource, + SlashCommandsEvent, UpdatedActiveCommands, ZeroStateDataSource, +}; +use crate::terminal::input::suggestions_mode_model::{ + InputSuggestionsModeEvent, InputSuggestionsModeModel, +}; + +const MENU_WIDTH: f32 = 320.; + +/// Figma frame is 380px tall; 400px leaves a few pixels of breathing room and +/// the `Scrollable` clamps to whatever vertical space the parent grants when +/// the window is narrow. +const MENU_MAX_HEIGHT: f32 = 400.; + +/// Number of items shown per section in the `NoSearchActive` state before the +/// "Show N more" affordance appears. +const ITEMS_PER_SECTION_COLLAPSED: usize = 3; + +const SECTION_HEADER_FONT_SIZE: f32 = 12.; + +const ITEM_FONT_SIZE: f32 = 14.; + +const MENU_HORIZONTAL_PADDING: f32 = 16.; + +const MENU_VERTICAL_PADDING: f32 = 4.; + +const MENU_CORNER_RADIUS: f32 = 6.; + +const ROW_VERTICAL_PADDING: f32 = 4.; + +const ICON_SIZE: f32 = 16.; + +const ICON_TO_TEXT_GAP: f32 = 8.; + +const DIVIDER_HEIGHT: f32 = 1.; + +const DIVIDER_VERTICAL_PADDING: f32 = 4.; + +/// Drop shadow color: Figma `rgba(0, 0, 0, 0.3)`. Sourced once here rather than +/// inline so the magic alpha is greppable. +const DROP_SHADOW_COLOR: ColorU = ColorU { + r: 0, + g: 0, + b: 0, + a: 77, // 0.3 * 255 = 76.5 +}; + +const DROP_SHADOW_OFFSET_Y: f32 = 7.; + +const DROP_SHADOW_BLUR_RADIUS: f32 = 7.; + +/// Shared renderer styles for the V2 menu rows. Mirrors the subset of +/// `InlineMenuView::QUERY_RESULT_RENDERER_STYLES` we need; we don't reuse that +/// constant because it is private to `inline_menu::view`. +static QUERY_RESULT_RENDERER_STYLES: LazyLock = + LazyLock::new(|| QueryResultRendererStyles { + result_item_height_fn: |appearance| appearance.monospace_font_size() + 8., + panel_corner_radius: CornerRadius::with_all(Radius::Pixels(0.)), + result_vertical_padding: ROW_VERTICAL_PADDING, + ..Default::default() + }); + +/// Section identifier. The mapping from `AcceptSlashCommandOrSavedPrompt` +/// variant to section is deterministic; see `Section::for_action`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Section { + Commands, + Skills, + Prompts, +} + +impl Section { + /// Order in which sections render in the `NoSearchActive` state. + const RENDER_ORDER: [Self; 3] = [Self::Commands, Self::Skills, Self::Prompts]; + + fn header(self) -> &'static str { + match self { + Self::Commands => "Commands", + Self::Skills => "Skills", + Self::Prompts => "Prompts", + } + } + + fn for_action(action: &AcceptSlashCommandOrSavedPrompt) -> Self { + match action { + AcceptSlashCommandOrSavedPrompt::SlashCommand { .. } => Self::Commands, + AcceptSlashCommandOrSavedPrompt::Skill { .. } => Self::Skills, + AcceptSlashCommandOrSavedPrompt::SavedPrompt { .. } => Self::Prompts, + } + } +} + +/// Result renderers grouped under a section. Items keep score order within the +/// section (mixer returns ascending priority). +struct RenderedSection { + section: Section, + items: Vec>, +} + +/// Visible-row representation used only by the `NoSearchActive` state. +/// Computed on demand from `(sections, expanded_sections)` for both rendering +/// and keyboard navigation; not stored on the struct. +#[derive(Clone, Copy)] +enum NoSearchActiveRow { + SectionHeader(Section), + Item { + section: Section, + item_idx: usize, + }, + /// Show the remaining items in `section`. `hidden_count` is the number of + /// items currently hidden behind the truncation; used in the row label. + ShowMore { + section: Section, + hidden_count: usize, + }, + Divider, +} + +impl NoSearchActiveRow { + fn is_selectable(self) -> bool { + matches!( + self, + NoSearchActiveRow::Item { .. } | NoSearchActiveRow::ShowMore { .. } + ) + } +} + +/// Top-level state for the V2 menu. Exactly one variant is live at a time. +enum MenuState { + /// User has not entered a query (just typed `/`). Results are grouped into + /// the three sections, each truncated to `ITEMS_PER_SECTION_COLLAPSED` + /// until the user activates the section's `Show N more` row. + NoSearchActive { + sections: Vec, + expanded_sections: HashSet
, + /// Index into the visible-row sequence (`browsing_rows`). Headers and + /// dividers are skipped during navigation; items and `ShowMore` rows + /// are selectable. + selected_idx: Option, + /// Mouse-state handles for `ShowMore` rows, keyed by section. + show_more_mouse_states: HashMap, + }, + /// User has typed a query. Results are pulled from across all sections + /// into a single flat list sorted by match score, with fuzzy match indices + /// highlighted. + SearchActive { + results: Vec>, + /// Index directly into `results`. Disabled items are skipped during + /// navigation. + selected_idx: Option, + }, +} + +impl MenuState { + fn empty() -> Self { + MenuState::NoSearchActive { + sections: Vec::new(), + expanded_sections: HashSet::new(), + selected_idx: None, + show_more_mouse_states: HashMap::new(), + } + } +} + +/// Internal action used to wire mouse hover/click events back into the view. +#[derive(Debug, Clone)] +pub enum CloudModeV2SlashCommandAction { + /// Accept the given item (Enter or click). + Accept { + item: AcceptSlashCommandOrSavedPrompt, + cmd_or_ctrl_enter: bool, + }, + /// Move the keyboard selection to the result at `idx` (mouse hover). + HoverIdx(usize), + /// Toggle expansion for `section` (Show N more clicked). + ToggleSection(Section), + /// User dismissed the menu (clicked outside, escape). + Dismiss, +} + +pub struct CloudModeV2SlashCommandView { + mixer: ModelHandle>, + suggestions_mode_model: ModelHandle, + input_buffer_model: ModelHandle, + weak_handle: WeakViewHandle, + scroll_state: ClippedScrollStateHandle, + /// Mutually exclusive: at any moment the menu is either `NoSearchActive` + /// (no query, sectioned with expand controls) or `SearchActive` (query, + /// flat ranked list). Replacing the previous `sections` + `flat_rows` + /// field pair with this enum means we never carry a stale empty copy of + /// the unused shape. + menu_state: MenuState, +} + +impl CloudModeV2SlashCommandView { + pub fn new( + slash_command_model: &ModelHandle, + slash_commands_source: ModelHandle, + suggestions_mode_model: ModelHandle, + input_buffer_model: ModelHandle, + ctx: &mut ViewContext, + ) -> Self { + // Re-run the active query whenever the set of active commands changes + // (e.g. CWD update, AI toggle). Mirrors `InlineSlashCommandView::new`. + ctx.subscribe_to_model( + &slash_commands_source, + |me, _, _: &UpdatedActiveCommands, ctx| { + me.mixer.update(ctx, |mixer, ctx| { + if let Some(query) = mixer.current_query().cloned() { + mixer.run_query(query, ctx); + } + }); + }, + ); + + let zero_state_source = + ctx.add_model(|_| ZeroStateDataSource::for_cloud_mode_v2(&slash_commands_source)); + let saved_prompts_source = saved_prompts_data_source(); + + let mixer = ctx.add_model(|ctx| { + let mut mixer = SearchMixer::::new(); + mixer.add_sync_source( + slash_commands_source.clone(), + [QueryFilter::StaticSlashCommands], + ); + // V2 keeps the saved-prompts async source but configures it + // identically to the legacy view; saved prompts in zero state are + // sourced from the V2 zero-state extension instead so the legacy + // mixer config doesn't need to change. + mixer.add_async_source( + saved_prompts_source, + [QueryFilter::StaticSlashCommands], + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: false, + }, + ctx, + ); + mixer.add_sync_source( + zero_state_source.clone(), + [QueryFilter::StaticSlashCommands], + ); + mixer.run_query(slash_command_query(""), ctx); + mixer + }); + + ctx.subscribe_to_model(&mixer, |me, _, event, ctx| match event { + SearchMixerEvent::ResultsChanged => { + if me.mixer.as_ref(ctx).is_loading() { + // Keep stale results visible while async sources are + // pending to avoid flicker. Mirrors `InlineMenuView`. + return; + } + me.rebuild_from_results(ctx); + ctx.notify(); + } + }); + + // Re-run query when the slash command model state changes (the user + // typed after the leading `/`). Same gating as the legacy view: only + // re-run while the menu is open so we don't burn cycles on saved + // prompt searches after the menu has been closed. + ctx.subscribe_to_model(slash_command_model, |me, model, _, ctx| { + if !me.suggestions_mode_model.as_ref(ctx).is_slash_commands() { + return; + } + match model.as_ref(ctx).state().clone() { + SlashCommandEntryState::None + | SlashCommandEntryState::Composing { .. } + | SlashCommandEntryState::SlashCommand(_) => { + me.run_query_for_current_slash_filter(ctx); + } + _ => (), + } + }); + + // Buffer subscription so we transition between `NoSearchActive` and + // `SearchActive` immediately as the user adds/removes characters + // after the `/`, even if the slash command model didn't move state. + ctx.subscribe_to_model( + &input_buffer_model, + |me, _, _: &InputBufferUpdateEvent, ctx| { + if !me.suggestions_mode_model.as_ref(ctx).is_slash_commands() { + return; + } + me.run_query_for_current_slash_filter(ctx); + }, + ); + + ctx.subscribe_to_model(&suggestions_mode_model, |me, _, event, ctx| { + let InputSuggestionsModeEvent::ModeChanged { .. } = event; + if me.suggestions_mode_model.as_ref(ctx).is_closed() { + me.mixer.update(ctx, |mixer, ctx| { + mixer.reset_results(ctx); + }); + me.menu_state = MenuState::empty(); + return; + } + // If the menu reopened with a slash query already in the buffer, + // re-run the query so we don't show stale results. + if me.suggestions_mode_model.as_ref(ctx).is_slash_commands() { + me.run_query_for_current_slash_filter(ctx); + } + }); + + Self { + mixer, + suggestions_mode_model, + input_buffer_model, + weak_handle: ctx.handle(), + scroll_state: Default::default(), + menu_state: MenuState::empty(), + } + } + + pub fn select_up(&mut self, ctx: &mut ViewContext) { + self.move_selection(SelectionDirection::Up, ctx); + } + + pub fn select_down(&mut self, ctx: &mut ViewContext) { + self.move_selection(SelectionDirection::Down, ctx); + } + + pub fn accept_selected_item(&mut self, cmd_or_ctrl_enter: bool, ctx: &mut ViewContext) { + match &self.menu_state { + MenuState::NoSearchActive { + sections, + expanded_sections, + selected_idx, + .. + } => { + let Some(selected_idx) = *selected_idx else { + return; + }; + let rows = browsing_rows(sections, expanded_sections); + let Some(row) = rows.get(selected_idx) else { + return; + }; + match *row { + NoSearchActiveRow::Item { section, item_idx } => { + let Some(rendered) = sections.iter().find(|s| s.section == section) else { + return; + }; + let Some(item) = rendered.items.get(item_idx) else { + return; + }; + if item.search_result.is_disabled() { + return; + } + let action = item.search_result.accept_result(); + self.emit_selection(&action, cmd_or_ctrl_enter, ctx); + } + NoSearchActiveRow::ShowMore { section, .. } => { + self.toggle_section(section, ctx); + } + NoSearchActiveRow::SectionHeader(_) | NoSearchActiveRow::Divider => {} + } + } + MenuState::SearchActive { + results, + selected_idx, + } => { + let Some(idx) = *selected_idx else { + return; + }; + let Some(item) = results.get(idx) else { + return; + }; + if item.search_result.is_disabled() { + return; + } + let action = item.search_result.accept_result(); + self.emit_selection(&action, cmd_or_ctrl_enter, ctx); + } + } + } + + pub fn dismiss(&mut self, ctx: &mut ViewContext) { + ctx.emit(SlashCommandsEvent::Close(CloseReason::ManualDismissal)); + } + + /// Returns the number of currently visible result items (for callers that + /// gate on the count, e.g. `Input::handle_slash_command_model_event`). + pub fn result_count(&self, app: &AppContext) -> usize { + self.mixer.as_ref(app).results().len() + } + + fn current_query_text(&self, app: &AppContext) -> String { + self.input_buffer_model + .as_ref(app) + .current_value() + .strip_prefix('/') + .map(ToOwned::to_owned) + .unwrap_or_default() + } + + fn run_query_for_current_slash_filter(&mut self, ctx: &mut ViewContext) { + let filter = self.current_query_text(ctx); + self.mixer.update(ctx, move |mixer, ctx| { + if mixer.current_query().is_some_and(|q| q.text == filter) { + return; + } + mixer.run_query(slash_command_query(&filter), ctx); + }); + } + + fn rebuild_from_results(&mut self, ctx: &mut ViewContext) { + let weak_handle = self.weak_handle.clone(); + let on_click_fn = move |_idx: usize, + item: AcceptSlashCommandOrSavedPrompt, + evt_ctx: &mut warpui::EventContext| { + // Forward clicks through the typed action so the view receives + // them in `handle_action` regardless of which row dispatched. + evt_ctx.dispatch_typed_action(CloudModeV2SlashCommandAction::Accept { + item, + cmd_or_ctrl_enter: false, + }); + let _ = weak_handle; // keep the closure self-contained. + }; + + // The mixer sorts results ascending by `(priority_tier, score, + // source_order)`, so the highest-priority item is at the *end* of + // the vec. Our menu renders top-to-bottom, so we reverse here to put + // the best match (or, for zero state, the alphabetically-first item + // since data sources emit in name-descending order) at the top. + let renderers: Vec> = self + .mixer + .as_ref(ctx) + .results() + .iter() + .rev() + .enumerate() + .map(|(idx, result)| { + QueryResultRenderer::new( + result.clone(), + format!("v2_slash:{idx}"), + on_click_fn.clone(), + *QUERY_RESULT_RENDERER_STYLES, + ) + }) + .collect(); + + let query_is_empty = self.current_query_text(ctx).is_empty(); + + self.menu_state = if query_is_empty { + let mut by_section: HashMap>> = HashMap::new(); + for renderer in renderers { + let section = Section::for_action(&renderer.search_result.accept_result()); + by_section.entry(section).or_default().push(renderer); + } + let sections: Vec = Section::RENDER_ORDER + .into_iter() + .map(|s| RenderedSection { + section: s, + items: by_section.remove(&s).unwrap_or_default(), + }) + .collect(); + + // Carry expansion across rebuilds while staying in + // `NoSearchActive`; reset on transition from `SearchActive`. + let expanded_sections = match &self.menu_state { + MenuState::NoSearchActive { + expanded_sections, .. + } => expanded_sections.clone(), + MenuState::SearchActive { .. } => HashSet::new(), + }; + + // Allocate a stable mouse-state handle per section's `Show More` + // row so hover state survives rebuilds. + let mut show_more_mouse_states = match &self.menu_state { + MenuState::NoSearchActive { + show_more_mouse_states, + .. + } => show_more_mouse_states.clone(), + MenuState::SearchActive { .. } => HashMap::new(), + }; + for section in Section::RENDER_ORDER { + show_more_mouse_states + .entry(section) + .or_insert_with(MouseStateHandle::default); + } + + let mut state = MenuState::NoSearchActive { + sections, + expanded_sections, + selected_idx: None, + show_more_mouse_states, + }; + initialize_browsing_selection(&mut state); + state + } else { + let mut state = MenuState::SearchActive { + results: renderers, + selected_idx: None, + }; + initialize_search_selection(&mut state); + state + }; + } + + fn toggle_section(&mut self, section: Section, ctx: &mut ViewContext) { + if let MenuState::NoSearchActive { + expanded_sections, .. + } = &mut self.menu_state + { + if !expanded_sections.insert(section) { + expanded_sections.remove(§ion); + } + } + // Selection may now point past the end of the new visible row list. + clamp_browsing_selection(&mut self.menu_state); + ctx.notify(); + } + + fn emit_selection( + &self, + action: &AcceptSlashCommandOrSavedPrompt, + cmd_or_ctrl_enter: bool, + ctx: &mut ViewContext, + ) { + match action { + AcceptSlashCommandOrSavedPrompt::SlashCommand { id } => { + ctx.emit(SlashCommandsEvent::SelectedStaticCommand { + id: *id, + cmd_or_ctrl_enter, + }); + } + AcceptSlashCommandOrSavedPrompt::SavedPrompt { id } => { + ctx.emit(SlashCommandsEvent::SelectedSavedPrompt { id: *id }); + } + AcceptSlashCommandOrSavedPrompt::Skill { name, reference } => { + ctx.emit(SlashCommandsEvent::SelectedSkill { + reference: reference.clone(), + name: name.clone(), + }); + } + } + } + + fn move_selection(&mut self, direction: SelectionDirection, ctx: &mut ViewContext) { + match &mut self.menu_state { + MenuState::NoSearchActive { + sections, + expanded_sections, + selected_idx, + .. + } => { + let rows = browsing_rows(sections, expanded_sections); + if rows.is_empty() { + return; + } + let next = next_selectable_browsing_idx(&rows, *selected_idx, direction); + if let Some(next) = next { + *selected_idx = Some(next); + } + } + MenuState::SearchActive { + results, + selected_idx, + } => { + if results.is_empty() { + return; + } + let next = next_selectable_search_idx(results, *selected_idx, direction); + if let Some(next) = next { + *selected_idx = Some(next); + } + } + } + ctx.notify(); + } + + fn set_browsing_selection(&mut self, idx: usize, ctx: &mut ViewContext) { + if let MenuState::NoSearchActive { + sections, + expanded_sections, + selected_idx, + .. + } = &mut self.menu_state + { + let rows = browsing_rows(sections, expanded_sections); + if rows.get(idx).is_some_and(|r| r.is_selectable()) { + *selected_idx = Some(idx); + ctx.notify(); + } + } + } + + fn set_search_selection(&mut self, idx: usize, ctx: &mut ViewContext) { + if let MenuState::SearchActive { + results, + selected_idx, + } = &mut self.menu_state + { + if let Some(item) = results.get(idx) { + if !item.search_result.is_disabled() { + *selected_idx = Some(idx); + ctx.notify(); + } + } + } + } +} + +#[derive(Clone, Copy)] +enum SelectionDirection { + Up, + Down, +} + +/// Builds the visible-row sequence for the `NoSearchActive` state. +fn browsing_rows( + sections: &[RenderedSection], + expanded_sections: &HashSet
, +) -> Vec { + let mut rows = Vec::new(); + let non_empty_sections: Vec<&RenderedSection> = + sections.iter().filter(|s| !s.items.is_empty()).collect(); + + for (idx, rendered) in non_empty_sections.iter().enumerate() { + rows.push(NoSearchActiveRow::SectionHeader(rendered.section)); + let visible_count = if expanded_sections.contains(&rendered.section) { + rendered.items.len() + } else { + rendered.items.len().min(ITEMS_PER_SECTION_COLLAPSED) + }; + for item_idx in 0..visible_count { + rows.push(NoSearchActiveRow::Item { + section: rendered.section, + item_idx, + }); + } + let hidden_count = rendered.items.len().saturating_sub(visible_count); + if hidden_count > 0 { + rows.push(NoSearchActiveRow::ShowMore { + section: rendered.section, + hidden_count, + }); + } + let is_last = idx + 1 == non_empty_sections.len(); + if !is_last { + rows.push(NoSearchActiveRow::Divider); + } + } + rows +} + +fn initialize_browsing_selection(state: &mut MenuState) { + if let MenuState::NoSearchActive { + sections, + expanded_sections, + selected_idx, + .. + } = state + { + let rows = browsing_rows(sections, expanded_sections); + *selected_idx = rows.iter().position(|r| r.is_selectable()); + } +} + +fn initialize_search_selection(state: &mut MenuState) { + if let MenuState::SearchActive { + results, + selected_idx, + } = state + { + *selected_idx = results.iter().position(|r| !r.search_result.is_disabled()); + } +} + +fn clamp_browsing_selection(state: &mut MenuState) { + if let MenuState::NoSearchActive { + sections, + expanded_sections, + selected_idx, + .. + } = state + { + let rows = browsing_rows(sections, expanded_sections); + let in_range = selected_idx + .as_ref() + .is_some_and(|idx| rows.get(*idx).is_some_and(|r| r.is_selectable())); + if !in_range { + *selected_idx = rows.iter().position(|r| r.is_selectable()); + } + } +} + +fn next_selectable_browsing_idx( + rows: &[NoSearchActiveRow], + current: Option, + direction: SelectionDirection, +) -> Option { + if rows.is_empty() { + return None; + } + let count = rows.len(); + let start = match (current, direction) { + (Some(idx), SelectionDirection::Down) if idx + 1 < count => idx + 1, + (Some(_), SelectionDirection::Down) => 0, + (Some(idx), SelectionDirection::Up) if idx > 0 => idx - 1, + (Some(_), SelectionDirection::Up) => count - 1, + (None, SelectionDirection::Down) => 0, + (None, SelectionDirection::Up) => count - 1, + }; + for offset in 0..count { + let candidate = match direction { + SelectionDirection::Down => (start + offset) % count, + SelectionDirection::Up => (start + count - offset) % count, + }; + if rows[candidate].is_selectable() { + return Some(candidate); + } + } + None +} + +fn next_selectable_search_idx( + results: &[QueryResultRenderer], + current: Option, + direction: SelectionDirection, +) -> Option { + if results.is_empty() { + return None; + } + let count = results.len(); + let start = match (current, direction) { + (Some(idx), SelectionDirection::Down) if idx + 1 < count => idx + 1, + (Some(_), SelectionDirection::Down) => 0, + (Some(idx), SelectionDirection::Up) if idx > 0 => idx - 1, + (Some(_), SelectionDirection::Up) => count - 1, + (None, SelectionDirection::Down) => 0, + (None, SelectionDirection::Up) => count - 1, + }; + for offset in 0..count { + let candidate = match direction { + SelectionDirection::Down => (start + offset) % count, + SelectionDirection::Up => (start + count - offset) % count, + }; + if !results[candidate].search_result.is_disabled() { + return Some(candidate); + } + } + None +} + +impl CloudModeV2SlashCommandView { + fn render_no_search_active( + &self, + sections: &[RenderedSection], + expanded_sections: &HashSet
, + selected_idx: Option, + show_more_mouse_states: &HashMap, + app: &AppContext, + ) -> Box { + let rows = browsing_rows(sections, expanded_sections); + let mut column = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min); + + for (visible_idx, row) in rows.iter().enumerate() { + let is_selected = selected_idx == Some(visible_idx); + match *row { + NoSearchActiveRow::SectionHeader(section) => { + column.add_child(render_section_header(section, app)); + } + NoSearchActiveRow::Item { section, item_idx } => { + let Some(rendered) = sections.iter().find(|s| s.section == section) else { + continue; + }; + let Some(renderer) = rendered.items.get(item_idx) else { + continue; + }; + column.add_child(self.wrap_with_hover( + renderer.render_inline(visible_idx, is_selected, app), + visible_idx, + /*is_browsing=*/ true, + )); + } + NoSearchActiveRow::ShowMore { + section, + hidden_count, + } => { + let mouse_state = show_more_mouse_states + .get(§ion) + .cloned() + .unwrap_or_default(); + column.add_child(render_show_more_row( + section, + hidden_count, + is_selected, + mouse_state, + visible_idx, + app, + )); + } + NoSearchActiveRow::Divider => { + column.add_child(render_divider(app)); + } + } + } + + column.finish() + } + + fn render_search_active( + &self, + results: &[QueryResultRenderer], + selected_idx: Option, + app: &AppContext, + ) -> Box { + let mut column = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min); + for (idx, renderer) in results.iter().enumerate() { + let is_selected = selected_idx == Some(idx); + column.add_child(self.wrap_with_hover( + renderer.render_inline(idx, is_selected, app), + idx, + /*is_browsing=*/ false, + )); + } + column.finish() + } + + /// Wraps an item-row element with a `MouseInBehavior` so hover updates the + /// keyboard selection — same UX as `InlineMenuView` rows. + fn wrap_with_hover( + &self, + element: Box, + idx: usize, + _is_browsing: bool, + ) -> Box { + EventHandler::new(element) + .on_mouse_in( + move |ctx, _, _| { + ctx.dispatch_typed_action(CloudModeV2SlashCommandAction::HoverIdx(idx)); + DispatchEventResult::PropagateToParent + }, + Some(MouseInBehavior { + fire_on_synthetic_events: false, + fire_when_covered: true, + }), + ) + .finish() + } + + fn render_no_results_state(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let menu_bg = inline_styles::menu_background_color(app); + let label = if self.mixer.as_ref(app).is_loading() { + "Loading..." + } else { + "No results" + }; + Container::new( + Text::new( + label.to_owned(), + appearance.ui_font_family(), + ITEM_FONT_SIZE, + ) + .with_color(theme.disabled_text_color(Fill::Solid(menu_bg)).into_solid()) + .finish(), + ) + .with_horizontal_padding(MENU_HORIZONTAL_PADDING) + .with_vertical_padding(ROW_VERTICAL_PADDING) + .finish() + } +} + +impl Entity for CloudModeV2SlashCommandView { + type Event = SlashCommandsEvent; +} + +impl TypedActionView for CloudModeV2SlashCommandView { + type Action = CloudModeV2SlashCommandAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + CloudModeV2SlashCommandAction::Accept { + item, + cmd_or_ctrl_enter, + } => { + self.emit_selection(item, *cmd_or_ctrl_enter, ctx); + } + CloudModeV2SlashCommandAction::HoverIdx(idx) => { + let idx = *idx; + match &self.menu_state { + MenuState::NoSearchActive { .. } => self.set_browsing_selection(idx, ctx), + MenuState::SearchActive { .. } => self.set_search_selection(idx, ctx), + } + } + CloudModeV2SlashCommandAction::ToggleSection(section) => { + self.toggle_section(*section, ctx); + } + CloudModeV2SlashCommandAction::Dismiss => { + self.dismiss(ctx); + } + } + } +} + +impl View for CloudModeV2SlashCommandView { + fn ui_name() -> &'static str { + "CloudModeV2SlashCommandView" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let menu_bg = inline_styles::menu_background_color(app); + + let content: Box = match &self.menu_state { + MenuState::NoSearchActive { + sections, + expanded_sections, + selected_idx, + show_more_mouse_states, + } => { + let any_items = sections.iter().any(|s| !s.items.is_empty()); + if !any_items { + self.render_no_results_state(app) + } else { + self.render_no_search_active( + sections, + expanded_sections, + *selected_idx, + show_more_mouse_states, + app, + ) + } + } + MenuState::SearchActive { + results, + selected_idx, + } => { + if results.is_empty() { + self.render_no_results_state(app) + } else { + self.render_search_active(results, *selected_idx, app) + } + } + }; + + let scrollable = ClippedScrollable::vertical( + self.scroll_state.clone(), + content, + ScrollbarWidth::Auto, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + warpui::elements::Fill::None, + ) + .with_overlayed_scrollbar() + .finish(); + + Container::new( + ConstrainedBox::new(scrollable) + .with_max_height(MENU_MAX_HEIGHT) + .with_max_width(MENU_WIDTH) + .finish(), + ) + .with_background(Fill::Solid(menu_bg)) + .with_border(Border::all(1.).with_border_fill(Fill::Solid(theme.outline().into_solid()))) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS))) + .with_padding_top(MENU_VERTICAL_PADDING) + .with_padding_bottom(MENU_VERTICAL_PADDING) + .with_drop_shadow(DropShadow { + color: DROP_SHADOW_COLOR, + offset: pathfinder_geometry::vector::vec2f(0., DROP_SHADOW_OFFSET_Y), + blur_radius: DROP_SHADOW_BLUR_RADIUS, + spread_radius: 0., + }) + .finish() + } +} + +fn render_section_header(section: Section, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let menu_bg = inline_styles::menu_background_color(app); + let header_color = theme.sub_text_color(Fill::Solid(menu_bg)).into_solid(); + + Container::new( + Text::new( + section.header().to_owned(), + appearance.ui_font_family(), + SECTION_HEADER_FONT_SIZE, + ) + .with_color(header_color) + .finish(), + ) + .with_horizontal_padding(MENU_HORIZONTAL_PADDING) + .with_vertical_padding(ROW_VERTICAL_PADDING) + .finish() +} + +fn render_show_more_row( + section: Section, + hidden_count: usize, + is_selected: bool, + mouse_state: MouseStateHandle, + _row_idx: usize, + app: &AppContext, +) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let menu_bg = inline_styles::menu_background_color(app); + let secondary_color = theme.sub_text_color(Fill::Solid(menu_bg)).into_solid(); + + let label = format!("Show {hidden_count} more"); + + let row = Hoverable::new(mouse_state, move |mouse_state| { + let bg = if is_selected || mouse_state.is_hovered() { + Some(theme.surface_overlay_2()) + } else { + None + }; + let mut container = Container::new( + Text::new(label.clone(), appearance.ui_font_family(), ITEM_FONT_SIZE) + .with_color(secondary_color) + .finish(), + ) + .with_horizontal_padding(MENU_HORIZONTAL_PADDING) + .with_vertical_padding(ROW_VERTICAL_PADDING); + if let Some(bg) = bg { + container = container.with_background(bg); + } + container.finish() + }) + .with_cursor(Cursor::PointingHand) + .finish(); + + EventHandler::new(row) + .on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation) + .on_left_mouse_up(move |evt_ctx, _, _| { + evt_ctx.dispatch_typed_action(CloudModeV2SlashCommandAction::ToggleSection(section)); + DispatchEventResult::StopPropagation + }) + .finish() +} + +fn render_divider(app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + Container::new( + ConstrainedBox::new(warpui::elements::Empty::new().finish()) + .with_height(DIVIDER_HEIGHT) + .finish(), + ) + .with_background(theme.surface_overlay_2()) + .with_vertical_padding(DIVIDER_VERTICAL_PADDING) + .finish() +} + +// ICON_SIZE / ICON_TO_TEXT_GAP are referenced from the design specs; surface +// them via small helpers so the constants don't appear unused if the per-row +// element layout shifts. +#[allow(dead_code)] +fn _icon_size() -> f32 { + ICON_SIZE +} + +#[allow(dead_code)] +fn _icon_to_text_gap() -> f32 { + ICON_TO_TEXT_GAP +} diff --git a/app/src/terminal/input/slash_commands/data_source/mod.rs b/app/src/terminal/input/slash_commands/data_source/mod.rs index c712bfab..af6be3b2 100644 --- a/app/src/terminal/input/slash_commands/data_source/mod.rs +++ b/app/src/terminal/input/slash_commands/data_source/mod.rs @@ -255,6 +255,13 @@ impl SlashCommandDataSource { self.agent_view_controller.as_ref(ctx).is_active() } + /// Accessor used by `ZeroStateDataSource::for_cloud_mode_v2` so it can + /// resolve the current working directory for skill scoping without + /// duplicating the active-session model on the new data source. + pub fn active_session_for_v2_zero_state(&self) -> &ModelHandle { + &self.active_session + } + /// Returns `true` if the CLI agent rich input is currently open for this terminal. pub fn is_cli_agent_input_open(&self, ctx: &AppContext) -> bool { CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id) @@ -437,6 +444,30 @@ impl InlineItem { } } + /// Builds an inline item for a saved prompt (cloud workflow). Used by the + /// V2 zero-state path; the legacy menu reaches saved prompts through the + /// async `saved_prompts_data_source` instead. + pub(crate) fn from_saved_prompt( + saved_prompt: &crate::workflows::CloudWorkflow, + app: &AppContext, + ) -> Self { + let appearance = Appearance::as_ref(app); + Self { + action: AcceptSlashCommandOrSavedPrompt::SavedPrompt { + id: saved_prompt.id, + }, + // Matches the icon used by the legacy async `saved_prompts.rs` path. + icon_path: "bundled/svg/prompt.svg", + name: saved_prompt.model().data.name().to_owned(), + description: None, + // Saved prompts use the UI font family (matching the legacy async path). + font_family: appearance.ui_font_family(), + name_match_result: None, + description_match_result: None, + score: OrderedFloat(f64::MIN), + } + } + pub(super) fn from_skill(skill: &SkillDescriptor, app: &AppContext) -> Self { let appearance = Appearance::handle(app).as_ref(app); // Use icon_override if set (e.g. Figma skills), otherwise derive from provider. diff --git a/app/src/terminal/input/slash_commands/data_source/zero_state.rs b/app/src/terminal/input/slash_commands/data_source/zero_state.rs index 14243e9e..516ea5d0 100644 --- a/app/src/terminal/input/slash_commands/data_source/zero_state.rs +++ b/app/src/terminal/input/slash_commands/data_source/zero_state.rs @@ -1,22 +1,51 @@ use itertools::Itertools; -use warpui::{Entity, ModelHandle}; +use warp_core::features::FeatureFlag; +use warpui::{Entity, ModelHandle, SingletonEntity}; +use crate::ai::skills::SkillManager; +use crate::cloud_object::model::persistence::CloudModel; use crate::search::data_source::{Query, QueryResult}; use crate::search::mixer::DataSourceRunErrorWrapper; use crate::search::slash_command_menu::static_commands::commands; use crate::search::SyncDataSource; +use crate::settings::AISettings; use crate::terminal::input::slash_commands::{ AcceptSlashCommandOrSavedPrompt, InlineItem, SlashCommandDataSource, }; pub struct ZeroStateDataSource { slash_command_data_source: ModelHandle, + /// When true, surface skills (in addition to slash commands) when the query + /// is empty. Used by the cloud-mode V2 menu, which renders skills in their + /// own section. The legacy inline menu keeps this disabled. + include_skills: bool, + /// When true, surface saved prompts (in addition to slash commands) when + /// the query is empty. Used by the cloud-mode V2 menu, which renders + /// prompts in their own section. The legacy inline menu keeps this + /// disabled. + include_saved_prompts: bool, } impl ZeroStateDataSource { pub fn new(slash_command_data_source: &ModelHandle) -> Self { Self { slash_command_data_source: slash_command_data_source.clone(), + include_skills: false, + include_saved_prompts: false, + } + } + + /// Constructor for the cloud-mode V2 slash command menu. Surfaces skills + /// and saved prompts in zero state alongside slash commands so the V2 + /// menu can render all three sections (Commands / Skills / Prompts) before + /// the user types a query. + pub fn for_cloud_mode_v2( + slash_command_data_source: &ModelHandle, + ) -> Self { + Self { + slash_command_data_source: slash_command_data_source.clone(), + include_skills: true, + include_saved_prompts: true, } } } @@ -84,6 +113,61 @@ impl SyncDataSource for ZeroStateDataSource { } } + // Skills are gated by the `ListSkills` feature flag and the global AI + // setting (matching `SlashCommandDataSource::run_query` for non-empty + // queries). Items are emitted in name-descending order so the mixer's + // ascending priority sort lands on alphabetical order. + if self.include_skills + && FeatureFlag::ListSkills.is_enabled() + && AISettings::as_ref(app).is_any_ai_enabled(app) + { + let slash_command_data_source = self.slash_command_data_source.as_ref(app); + let cli_agent_providers = slash_command_data_source.active_cli_agent_providers(app); + let cwd = slash_command_data_source + .active_session_for_v2_zero_state() + .as_ref(app) + .current_working_directory(); + let cwd_path = cwd.as_ref().map(std::path::Path::new); + let skill_manager_handle = SkillManager::handle(app); + let skill_manager = skill_manager_handle.as_ref(app); + let skills = skill_manager.get_skills_for_working_directory(cwd_path, app); + + for mut skill in skills + .into_iter() + .sorted_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase())) + { + // Mirror the CLI-agent provider filtering applied to fuzzy search + // so zero state and search state stay consistent. + if let Some(providers) = &cli_agent_providers { + if !skill_manager.skill_exists_for_any_provider(&skill, providers) { + continue; + } + skill.provider = skill_manager.best_supported_provider(&skill, providers); + } + results.push(InlineItem::from_skill(&skill, app).into()); + } + } + + // Saved prompts are agent-mode workflows; only surface them when AI is + // globally enabled. Items are emitted in name-descending order so the + // mixer's ascending priority sort lands on alphabetical order. + if self.include_saved_prompts && AISettings::as_ref(app).is_any_ai_enabled(app) { + let saved_prompts: Vec<_> = CloudModel::as_ref(app) + .get_all_active_workflows() + .filter(|cw| cw.model().data.is_agent_mode_workflow()) + .sorted_by(|a, b| { + b.model() + .data + .name() + .to_lowercase() + .cmp(&a.model().data.name().to_lowercase()) + }) + .collect(); + for saved_prompt in saved_prompts { + results.push(InlineItem::from_saved_prompt(saved_prompt, app).into()); + } + } + Ok(results) } } diff --git a/app/src/terminal/input/slash_commands/mod.rs b/app/src/terminal/input/slash_commands/mod.rs index a30f34f9..d51ce919 100644 --- a/app/src/terminal/input/slash_commands/mod.rs +++ b/app/src/terminal/input/slash_commands/mod.rs @@ -1,9 +1,11 @@ +mod cloud_mode_v2_view; mod data_source; mod search_item; -mod view; +pub(super) mod view; +pub use cloud_mode_v2_view::CloudModeV2SlashCommandView; pub use data_source::*; -pub use view::*; +pub use view::{CloseReason, InlineSlashCommandView, SlashCommandsEvent}; use ai::skills::SkillReference; use warp_core::features::FeatureFlag; @@ -897,9 +899,17 @@ impl Input { self.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::SlashCommands ) { - self.inline_slash_commands_view.update(ctx, |view, ctx| { - view.accept_selected_item(true, ctx); - }); + if self.is_cloud_mode_input_v2_composing(ctx) { + if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() { + view.update(ctx, |view, ctx| { + view.accept_selected_item(true, ctx); + }); + } + } else { + self.inline_slash_commands_view.update(ctx, |view, ctx| { + view.accept_selected_item(true, ctx); + }); + } return true; } @@ -948,9 +958,17 @@ impl Input { self.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::SlashCommands ) { - self.inline_slash_commands_view.update(ctx, |view, ctx| { - view.accept_selected_item(false, ctx); - }); + if self.is_cloud_mode_input_v2_composing(ctx) { + if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() { + view.update(ctx, |view, ctx| { + view.accept_selected_item(false, ctx); + }); + } + } else { + self.inline_slash_commands_view.update(ctx, |view, ctx| { + view.accept_selected_item(false, ctx); + }); + } return true; } diff --git a/app/src/terminal/input/slash_commands/view.rs b/app/src/terminal/input/slash_commands/view.rs index 6eb7ee30..b853c677 100644 --- a/app/src/terminal/input/slash_commands/view.rs +++ b/app/src/terminal/input/slash_commands/view.rs @@ -274,8 +274,9 @@ impl View for InlineSlashCommandView { } /// Build a Query that includes the StaticSlashCommands filter so both sync and -/// async sources run. -fn slash_command_query(text: &str) -> Query { +/// async sources run. Reused by the V2 cloud-mode menu so the two views agree on +/// query shape. +pub(super) fn slash_command_query(text: &str) -> Query { Query { text: text.to_owned(), filters: SLASH_COMMAND_FILTERS.clone(), From 67142a3d9f5f1dabd0cf7016511936b3c320bf93 Mon Sep 17 00:00:00 2001 From: Abhishek Pandya Date: Tue, 28 Apr 2026 16:56:11 -0400 Subject: [PATCH 2/6] compact layout for description text --- app/src/terminal/input.rs | 24 +++++++----- .../slash_commands/cloud_mode_v2_view.rs | 2 +- .../input/slash_commands/data_source/mod.rs | 37 +++++++++++++++++++ .../data_source/saved_prompts.rs | 6 +++ .../slash_commands/data_source/zero_state.rs | 29 +++++++++++++-- .../input/slash_commands/search_item.rs | 30 +++++++++++---- 6 files changed, 107 insertions(+), 21 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index dec6f227..ab661452 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -2945,15 +2945,21 @@ impl Input { }); let slash_command_data_source = ctx.add_model(|ctx| { - SlashCommandDataSource::new( - slash_commands::DataSourceArgs { - active_session: active_session.clone(), - agent_view_controller: agent_view_controller.clone(), - cli_subagent_controller: cli_subagent_controller.clone(), - terminal_view_id, - }, - ctx, - ) + let args = slash_commands::DataSourceArgs { + active_session: active_session.clone(), + agent_view_controller: agent_view_controller.clone(), + cli_subagent_controller: cli_subagent_controller.clone(), + terminal_view_id, + }; + // The V2 menu renders in a narrow 320px floating panel where the + // legacy fixed-width name column would push descriptions + // offscreen. When V2 is active we use the compact layout for + // every emitted item; legacy menus keep the column rendering. + if FeatureFlag::CloudModeInputV2.is_enabled() { + SlashCommandDataSource::for_cloud_mode_v2(args, ctx) + } else { + SlashCommandDataSource::new(args, ctx) + } }); let slash_command_model = ctx.add_model(|ctx| { SlashCommandModel::new( diff --git a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs index 151e416c..e8b931de 100644 --- a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs +++ b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs @@ -470,7 +470,7 @@ impl CloudModeV2SlashCommandView { QueryResultRenderer::new( result.clone(), format!("v2_slash:{idx}"), - on_click_fn.clone(), + on_click_fn, *QUERY_RESULT_RENDERER_STYLES, ) }) diff --git a/app/src/terminal/input/slash_commands/data_source/mod.rs b/app/src/terminal/input/slash_commands/data_source/mod.rs index af6be3b2..f1b95ea1 100644 --- a/app/src/terminal/input/slash_commands/data_source/mod.rs +++ b/app/src/terminal/input/slash_commands/data_source/mod.rs @@ -59,10 +59,28 @@ pub struct SlashCommandDataSource { terminal_view_id: EntityId, active_commands_by_id: HashMap, active_repo_root: Option, + /// When true, items emitted from this source carry `compact_layout = + /// true` so the V2 cloud-mode menu renders them with a tight + /// `name → 8px → description` layout instead of the legacy fixed-width + /// name column. The legacy menu sets this to false to preserve column + /// alignment across rows. + compact_layout: bool, } impl SlashCommandDataSource { pub fn new(args: DataSourceArgs, ctx: &mut ModelContext) -> Self { + Self::build(args, false, ctx) + } + + /// Constructor used when the V2 cloud-mode menu is active. Items + /// emitted by `run_query` carry `compact_layout = true` so the narrow + /// floating menu (320px) renders descriptions close to the name + /// instead of pushing them offscreen behind a fixed name column. + pub fn for_cloud_mode_v2(args: DataSourceArgs, ctx: &mut ModelContext) -> Self { + Self::build(args, true, ctx) + } + + fn build(args: DataSourceArgs, compact_layout: bool, ctx: &mut ModelContext) -> Self { let DataSourceArgs { active_session, agent_view_controller, @@ -133,6 +151,7 @@ impl SlashCommandDataSource { terminal_view_id, active_commands_by_id: Default::default(), active_repo_root: None, + compact_layout, }; me.recompute_active_commands(ctx); me @@ -320,6 +339,7 @@ impl SyncDataSource for SlashCommandDataSource { InlineItem::from_slash_command(id, command, app) .with_name_match_result(fuzzy_result.name_match_result) .with_description_match_result(fuzzy_result.description_match_result) + .with_compact_layout(self.compact_layout) .with_score( OrderedFloat(score) * SCORE_MULTIPLIER + OrderedFloat(prefix_boost) * SCORE_MULTIPLIER @@ -373,6 +393,7 @@ impl SyncDataSource for SlashCommandDataSource { InlineItem::from_skill(&skill, app) .with_name_match_result(fuzzy_result.name_match_result) .with_description_match_result(fuzzy_result.description_match_result) + .with_compact_layout(self.compact_layout) .with_score( OrderedFloat(score) * SCORE_MULTIPLIER + OrderedFloat(prefix_boost) * SCORE_MULTIPLIER @@ -423,6 +444,11 @@ pub struct InlineItem { pub name_match_result: Option, pub description_match_result: Option, pub score: OrderedFloat, + /// When true, render with a tight `name → 8px → description` layout + /// instead of the legacy fixed-width name column. Set by V2 data + /// sources where the menu is narrow (320px) and a fixed name column + /// would push descriptions offscreen. + pub compact_layout: bool, } impl InlineItem { @@ -441,6 +467,7 @@ impl InlineItem { name_match_result: None, description_match_result: None, score: OrderedFloat(f64::MIN), + compact_layout: false, } } @@ -465,6 +492,7 @@ impl InlineItem { name_match_result: None, description_match_result: None, score: OrderedFloat(f64::MIN), + compact_layout: false, } } @@ -497,6 +525,7 @@ impl InlineItem { name_match_result: None, description_match_result: None, score: OrderedFloat(f64::MIN), + compact_layout: false, } } @@ -514,6 +543,14 @@ impl InlineItem { self.score = score; self } + + /// Toggles the compact layout flag used by `SearchItem::render_item` + /// to choose between the legacy fixed-width name column and the V2 + /// tight `name → 8px → description` layout. + pub(crate) fn with_compact_layout(mut self, compact: bool) -> Self { + self.compact_layout = compact; + self + } } #[cfg(test)] diff --git a/app/src/terminal/input/slash_commands/data_source/saved_prompts.rs b/app/src/terminal/input/slash_commands/data_source/saved_prompts.rs index 5ea9fd1a..8ab0cd8d 100644 --- a/app/src/terminal/input/slash_commands/data_source/saved_prompts.rs +++ b/app/src/terminal/input/slash_commands/data_source/saved_prompts.rs @@ -114,6 +114,9 @@ pub(crate) fn fuzzy_match_saved_prompts( name_match_result, description_match_result: None, score: OrderedFloat(100.0), + // Saved prompts have no description so the layout + // flag is unobservable; default to false. + compact_layout: false, }; results.push(QueryResult::from(item)); } @@ -143,6 +146,9 @@ pub(crate) fn fuzzy_match_saved_prompts( name_match_result: match_result.name_match_result, description_match_result: match_result.content_match_result, score, + // Saved prompts have no description so the layout + // flag is unobservable; default to false. + compact_layout: false, }; results.push(QueryResult::from(item)); } diff --git a/app/src/terminal/input/slash_commands/data_source/zero_state.rs b/app/src/terminal/input/slash_commands/data_source/zero_state.rs index 516ea5d0..87d0d1d4 100644 --- a/app/src/terminal/input/slash_commands/data_source/zero_state.rs +++ b/app/src/terminal/input/slash_commands/data_source/zero_state.rs @@ -24,6 +24,11 @@ pub struct ZeroStateDataSource { /// prompts in their own section. The legacy inline menu keeps this /// disabled. include_saved_prompts: bool, + /// When true, items emitted carry `compact_layout = true` so the V2 + /// menu renders them with a tight `name → 8px → description` layout. + /// Tracked separately from `include_*` so the layout decision is + /// explicit rather than coupled to which sections are surfaced. + compact_layout: bool, } impl ZeroStateDataSource { @@ -32,6 +37,7 @@ impl ZeroStateDataSource { slash_command_data_source: slash_command_data_source.clone(), include_skills: false, include_saved_prompts: false, + compact_layout: false, } } @@ -46,6 +52,7 @@ impl ZeroStateDataSource { slash_command_data_source: slash_command_data_source.clone(), include_skills: true, include_saved_prompts: true, + compact_layout: true, } } } @@ -99,7 +106,9 @@ impl SyncDataSource for ZeroStateDataSource { active_prioritized_commands.push((active_command_id, active_command)); } else { results.push( - InlineItem::from_slash_command(active_command_id, active_command, app).into(), + InlineItem::from_slash_command(active_command_id, active_command, app) + .with_compact_layout(self.compact_layout) + .into(), ); } } @@ -109,7 +118,11 @@ impl SyncDataSource for ZeroStateDataSource { .iter() .find(|(_, active_command)| active_command.name == prioritized_command.name) { - results.push(InlineItem::from_slash_command(id, command, app).into()); + results.push( + InlineItem::from_slash_command(id, command, app) + .with_compact_layout(self.compact_layout) + .into(), + ); } } @@ -144,7 +157,11 @@ impl SyncDataSource for ZeroStateDataSource { } skill.provider = skill_manager.best_supported_provider(&skill, providers); } - results.push(InlineItem::from_skill(&skill, app).into()); + results.push( + InlineItem::from_skill(&skill, app) + .with_compact_layout(self.compact_layout) + .into(), + ); } } @@ -164,7 +181,11 @@ impl SyncDataSource for ZeroStateDataSource { }) .collect(); for saved_prompt in saved_prompts { - results.push(InlineItem::from_saved_prompt(saved_prompt, app).into()); + results.push( + InlineItem::from_saved_prompt(saved_prompt, app) + .with_compact_layout(self.compact_layout) + .into(), + ); } } diff --git a/app/src/terminal/input/slash_commands/search_item.rs b/app/src/terminal/input/slash_commands/search_item.rs index c70d12df..d9452048 100644 --- a/app/src/terminal/input/slash_commands/search_item.rs +++ b/app/src/terminal/input/slash_commands/search_item.rs @@ -90,7 +90,7 @@ impl SearchItem for InlineItem { }; let name_element = if let Some(keystroke) = keystroke { - Flex::row() + let mut row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(name_text.finish()) .with_child( @@ -111,17 +111,33 @@ impl SearchItem for InlineItem { )) .with_margin_left(4.) .finish(), - ) - .with_child(Shrinkable::new(1., Empty::new().finish()).finish()) - .finish() + ); + // The trailing `Shrinkable` is filler that pushes the keystroke + // chip flush-left inside the legacy fixed-width name column. + // The compact V2 layout sizes the row to its content (no + // fixed-width parent), so adding a flexible child here would + // be measured against an infinite main-axis constraint and + // panic the flex layout. + if !self.compact_layout { + row = row.with_child(Shrinkable::new(1., Empty::new().finish()).finish()); + } + row.finish() } else { name_text.finish() }; row.add_child(if self.description.is_some() { - ConstrainedBox::new(name_element) - .with_width(inline_width_for_name_column(app)) - .finish() + if self.compact_layout { + // V2 narrow menu: tight 8px gap so descriptions stay + // onscreen in the 320px floating panel. + Container::new(name_element).with_margin_right(8.).finish() + } else { + // Legacy menu: align descriptions in a fixed column for + // visual continuity across rows. + ConstrainedBox::new(name_element) + .with_width(inline_width_for_name_column(app)) + .finish() + } } else { name_element }); From 9e5b8c63dba7c1faef0ae08c28a079f81908943a Mon Sep 17 00:00:00 2001 From: Abhishek Pandya Date: Tue, 28 Apr 2026 17:08:10 -0400 Subject: [PATCH 3/6] Fix minor UI nits --- .../slash_commands/cloud_mode_v2_view.rs | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs index e8b931de..50b51965 100644 --- a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs +++ b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs @@ -11,7 +11,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; -use pathfinder_color::ColorU; use warp_core::ui::appearance::Appearance; use warp_core::ui::theme::Fill; use warpui::elements::{ @@ -69,20 +68,11 @@ const ICON_TO_TEXT_GAP: f32 = 8.; const DIVIDER_HEIGHT: f32 = 1.; -const DIVIDER_VERTICAL_PADDING: f32 = 4.; - -/// Drop shadow color: Figma `rgba(0, 0, 0, 0.3)`. Sourced once here rather than -/// inline so the magic alpha is greppable. -const DROP_SHADOW_COLOR: ColorU = ColorU { - r: 0, - g: 0, - b: 0, - a: 77, // 0.3 * 255 = 76.5 -}; - -const DROP_SHADOW_OFFSET_Y: f32 = 7.; - -const DROP_SHADOW_BLUR_RADIUS: f32 = 7.; +/// No vertical padding on the divider itself: the show-more row above and the +/// section header below already contribute their own `ROW_VERTICAL_PADDING`, +/// so adding more here produced a visible gap below the "Show N more" +/// highlight before the next section. +const DIVIDER_VERTICAL_PADDING: f32 = 0.; /// Shared renderer styles for the V2 menu rows. Mirrors the subset of /// `InlineMenuView::QUERY_RESULT_RENDERER_STYLES` we need; we don't reuse that @@ -1001,12 +991,9 @@ impl View for CloudModeV2SlashCommandView { .with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS))) .with_padding_top(MENU_VERTICAL_PADDING) .with_padding_bottom(MENU_VERTICAL_PADDING) - .with_drop_shadow(DropShadow { - color: DROP_SHADOW_COLOR, - offset: pathfinder_geometry::vector::vec2f(0., DROP_SHADOW_OFFSET_Y), - blur_radius: DROP_SHADOW_BLUR_RADIUS, - spread_radius: 0., - }) + // Match the inline model/profile selector menus, which use the + // shared default shadow from `DropShadow::default()`. + .with_drop_shadow(DropShadow::default()) .finish() } } @@ -1079,12 +1066,19 @@ fn render_show_more_row( fn render_divider(app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); + // Paint the 1px constrained box itself; the outer container only + // contributes transparent vertical spacing. Putting the background + // on the outer container would also color its padding, producing a + // band of `padding + height + padding` pixels instead of 1px. Container::new( - ConstrainedBox::new(warpui::elements::Empty::new().finish()) - .with_height(DIVIDER_HEIGHT) - .finish(), + ConstrainedBox::new( + Container::new(warpui::elements::Empty::new().finish()) + .with_background(theme.surface_overlay_2()) + .finish(), + ) + .with_height(DIVIDER_HEIGHT) + .finish(), ) - .with_background(theme.surface_overlay_2()) .with_vertical_padding(DIVIDER_VERTICAL_PADDING) .finish() } From ed9dba4010345cdfdbbfbd2d35ebb4b40c4b0615 Mon Sep 17 00:00:00 2001 From: Abhishek Pandya Date: Tue, 28 Apr 2026 17:15:24 -0400 Subject: [PATCH 4/6] Scrolling by arrow key works --- .../slash_commands/cloud_mode_v2_view.rs | 58 ++++++++++++++++--- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs index 50b51965..b651e857 100644 --- a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs +++ b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs @@ -16,7 +16,8 @@ use warp_core::ui::theme::Fill; use warpui::elements::{ Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, DropShadow, EventHandler, Flex, Hoverable, - MainAxisSize, MouseInBehavior, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text, + MainAxisSize, MouseInBehavior, MouseStateHandle, ParentElement, Radius, SavePosition, + ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Text, }; use warpui::platform::Cursor; use warpui::{ @@ -74,6 +75,14 @@ const DIVIDER_HEIGHT: f32 = 1.; /// highlight before the next section. const DIVIDER_VERTICAL_PADDING: f32 = 0.; +/// Stable position id for the selectable row at `visible_idx`. Wrapping each +/// row in a `SavePosition` with this id lets `ClippedScrollStateHandle:: +/// scroll_to_position` find the row's painted bounds and scroll the viewport +/// just enough to bring it fully into view when keyboard selection moves. +fn row_position_id(visible_idx: usize) -> String { + format!("cloud_mode_v2_slash_row_{visible_idx}") +} + /// Shared renderer styles for the V2 menu rows. Mirrors the subset of /// `InlineMenuView::QUERY_RESULT_RENDERER_STYLES` we need; we don't reuse that /// constant because it is private to `inline_menu::view`. @@ -347,6 +356,16 @@ impl CloudModeV2SlashCommandView { self.move_selection(SelectionDirection::Down, ctx); } + /// Asks the outer `ClippedScrollable` to scroll the row at `visible_idx` + /// fully into view. Each row is wrapped in `SavePosition` with the + /// matching id so the position cache resolves it at paint time. + fn scroll_selected_into_view(&self, visible_idx: usize) { + self.scroll_state.scroll_to_position(ScrollTarget { + position_id: row_position_id(visible_idx), + mode: ScrollToPositionMode::FullyIntoView, + }); + } + pub fn accept_selected_item(&mut self, cmd_or_ctrl_enter: bool, ctx: &mut ViewContext) { match &self.menu_state { MenuState::NoSearchActive { @@ -564,7 +583,7 @@ impl CloudModeV2SlashCommandView { } fn move_selection(&mut self, direction: SelectionDirection, ctx: &mut ViewContext) { - match &mut self.menu_state { + let next_idx: Option = match &mut self.menu_state { MenuState::NoSearchActive { sections, expanded_sections, @@ -579,6 +598,7 @@ impl CloudModeV2SlashCommandView { if let Some(next) = next { *selected_idx = Some(next); } + next } MenuState::SearchActive { results, @@ -591,7 +611,11 @@ impl CloudModeV2SlashCommandView { if let Some(next) = next { *selected_idx = Some(next); } + next } + }; + if let Some(idx) = next_idx { + self.scroll_selected_into_view(idx); } ctx.notify(); } @@ -607,6 +631,10 @@ impl CloudModeV2SlashCommandView { let rows = browsing_rows(sections, expanded_sections); if rows.get(idx).is_some_and(|r| r.is_selectable()) { *selected_idx = Some(idx); + // Mouse-driven hover updates also benefit from re-anchoring + // the scroll position so keyboard navigation continues from + // a row that's actually visible. + self.scroll_selected_into_view(idx); ctx.notify(); } } @@ -621,6 +649,7 @@ impl CloudModeV2SlashCommandView { if let Some(item) = results.get(idx) { if !item.search_result.is_disabled() { *selected_idx = Some(idx); + self.scroll_selected_into_view(idx); ctx.notify(); } } @@ -797,11 +826,17 @@ impl CloudModeV2SlashCommandView { let Some(renderer) = rendered.items.get(item_idx) else { continue; }; - column.add_child(self.wrap_with_hover( + let row_element = self.wrap_with_hover( renderer.render_inline(visible_idx, is_selected, app), visible_idx, /*is_browsing=*/ true, - )); + ); + // Cache the row's painted bounds so the scrollable can + // scroll it into view when keyboard navigation lands on + // a row outside the viewport. + column.add_child( + SavePosition::new(row_element, &row_position_id(visible_idx)).finish(), + ); } NoSearchActiveRow::ShowMore { section, @@ -811,14 +846,17 @@ impl CloudModeV2SlashCommandView { .get(§ion) .cloned() .unwrap_or_default(); - column.add_child(render_show_more_row( + let row_element = render_show_more_row( section, hidden_count, is_selected, mouse_state, visible_idx, app, - )); + ); + column.add_child( + SavePosition::new(row_element, &row_position_id(visible_idx)).finish(), + ); } NoSearchActiveRow::Divider => { column.add_child(render_divider(app)); @@ -840,11 +878,15 @@ impl CloudModeV2SlashCommandView { .with_main_axis_size(MainAxisSize::Min); for (idx, renderer) in results.iter().enumerate() { let is_selected = selected_idx == Some(idx); - column.add_child(self.wrap_with_hover( + let row_element = self.wrap_with_hover( renderer.render_inline(idx, is_selected, app), idx, /*is_browsing=*/ false, - )); + ); + // Cache row bounds for `scroll_to_position` keyboard + // navigation; `idx` here is the same index `selected_idx` + // tracks in `MenuState::SearchActive`. + column.add_child(SavePosition::new(row_element, &row_position_id(idx)).finish()); } column.finish() } From 849517c9e2684be329b0c4cc9df91c748610e8be Mon Sep 17 00:00:00 2001 From: Abhishek Pandya Date: Tue, 28 Apr 2026 22:40:31 -0400 Subject: [PATCH 5/6] remove comments --- app/src/terminal/input.rs | 10 -- app/src/terminal/input/agent.rs | 7 - .../slash_commands/cloud_mode_v2_view.rs | 125 +----------------- .../input/slash_commands/data_source/mod.rs | 24 ---- .../data_source/saved_prompts.rs | 4 - .../slash_commands/data_source/zero_state.rs | 24 ---- .../input/slash_commands/search_item.rs | 10 -- app/src/terminal/input/slash_commands/view.rs | 3 - 8 files changed, 4 insertions(+), 203 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index ab661452..63b52a0b 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1613,9 +1613,6 @@ pub struct Input { prompt_suggestions_view: ViewHandle, inline_slash_commands_view: ViewHandle, - /// V2 cloud-mode slash command menu, lazily constructed only when - /// `FeatureFlag::CloudModeInputV2` is on. Sibling of - /// `inline_slash_commands_view`; the legacy view is unaffected. cloud_mode_v2_slash_commands_view: Option>, slash_command_data_source: ModelHandle, @@ -2951,10 +2948,6 @@ impl Input { cli_subagent_controller: cli_subagent_controller.clone(), terminal_view_id, }; - // The V2 menu renders in a narrow 320px floating panel where the - // legacy fixed-width name column would push descriptions - // offscreen. When V2 is active we use the compact layout for - // every emitted item; legacy menus keep the column rendering. if FeatureFlag::CloudModeInputV2.is_enabled() { SlashCommandDataSource::for_cloud_mode_v2(args, ctx) } else { @@ -3122,9 +3115,6 @@ impl Input { me.handle_slash_commands_menu_event(event, ctx); }); - // The V2 menu shares `SlashCommandsEvent` with the legacy view so - // `handle_slash_commands_menu_event` handles both. Only constructed - // when V2 is enabled at runtime. let cloud_mode_v2_slash_commands_view = if FeatureFlag::CloudModeInputV2.is_enabled() { let view = ctx.add_typed_action_view(|ctx| { CloudModeV2SlashCommandView::new( diff --git a/app/src/terminal/input/agent.rs b/app/src/terminal/input/agent.rs index f3b7342e..71a448be 100644 --- a/app/src/terminal/input/agent.rs +++ b/app/src/terminal/input/agent.rs @@ -251,8 +251,6 @@ impl Input { } else if self.suggestions_mode_model.as_ref(app).is_slash_commands() && !self.is_cloud_mode_input_v2_composing(app) { - // V2 composing renders its own cursor-anchored menu; the legacy - // inline-above-input slash menu must not also appear. column.add_child(ChildView::new(&self.inline_slash_commands_view).finish()); } else if self.suggestions_mode_model.as_ref(app).is_prompts_menu() { column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish()); @@ -394,11 +392,6 @@ impl Input { ); } - // Cursor-anchored slash command menu for V2 composing. Mirrors the - // overlay pattern used by `render_ai_context_menu`. The legacy menu is - // gated out of the V1 column path above when V2 is composing, so the - // two cannot render simultaneously. Anchor parent's bottom-left to the - // child's top-left so the menu drops *below* the cursor. if self.suggestions_mode_model.as_ref(app).is_slash_commands() { if let Some(view) = self.cloud_mode_v2_slash_commands_view.as_ref() { let cursor_position = position_id_for_cursor(self.editor.id()); diff --git a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs index b651e857..b0874fe8 100644 --- a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs +++ b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs @@ -1,13 +1,3 @@ -//! Cloud-mode V2 slash command menu. -//! -//! A floating, cursor-anchored alternative to `InlineSlashCommandView` that is -//! gated behind `FeatureFlag::CloudModeInputV2`. The legacy view is left -//! untouched everywhere else. -//! -//! Rendering is driven by a single `MenuState` enum so that the two visible -//! shapes (`NoSearchActive` sectioned, `SearchActive` flat) are mutually -//! exclusive and never coexist. - use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; @@ -42,13 +32,8 @@ use crate::terminal::input::suggestions_mode_model::{ const MENU_WIDTH: f32 = 320.; -/// Figma frame is 380px tall; 400px leaves a few pixels of breathing room and -/// the `Scrollable` clamps to whatever vertical space the parent grants when -/// the window is narrow. const MENU_MAX_HEIGHT: f32 = 400.; -/// Number of items shown per section in the `NoSearchActive` state before the -/// "Show N more" affordance appears. const ITEMS_PER_SECTION_COLLAPSED: usize = 3; const SECTION_HEADER_FONT_SIZE: f32 = 12.; @@ -69,23 +54,12 @@ const ICON_TO_TEXT_GAP: f32 = 8.; const DIVIDER_HEIGHT: f32 = 1.; -/// No vertical padding on the divider itself: the show-more row above and the -/// section header below already contribute their own `ROW_VERTICAL_PADDING`, -/// so adding more here produced a visible gap below the "Show N more" -/// highlight before the next section. const DIVIDER_VERTICAL_PADDING: f32 = 0.; -/// Stable position id for the selectable row at `visible_idx`. Wrapping each -/// row in a `SavePosition` with this id lets `ClippedScrollStateHandle:: -/// scroll_to_position` find the row's painted bounds and scroll the viewport -/// just enough to bring it fully into view when keyboard selection moves. fn row_position_id(visible_idx: usize) -> String { format!("cloud_mode_v2_slash_row_{visible_idx}") } -/// Shared renderer styles for the V2 menu rows. Mirrors the subset of -/// `InlineMenuView::QUERY_RESULT_RENDERER_STYLES` we need; we don't reuse that -/// constant because it is private to `inline_menu::view`. static QUERY_RESULT_RENDERER_STYLES: LazyLock = LazyLock::new(|| QueryResultRendererStyles { result_item_height_fn: |appearance| appearance.monospace_font_size() + 8., @@ -94,8 +68,6 @@ static QUERY_RESULT_RENDERER_STYLES: LazyLock = ..Default::default() }); -/// Section identifier. The mapping from `AcceptSlashCommandOrSavedPrompt` -/// variant to section is deterministic; see `Section::for_action`. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum Section { Commands, @@ -104,7 +76,6 @@ pub enum Section { } impl Section { - /// Order in which sections render in the `NoSearchActive` state. const RENDER_ORDER: [Self; 3] = [Self::Commands, Self::Skills, Self::Prompts]; fn header(self) -> &'static str { @@ -124,16 +95,11 @@ impl Section { } } -/// Result renderers grouped under a section. Items keep score order within the -/// section (mixer returns ascending priority). struct RenderedSection { section: Section, items: Vec>, } -/// Visible-row representation used only by the `NoSearchActive` state. -/// Computed on demand from `(sections, expanded_sections)` for both rendering -/// and keyboard navigation; not stored on the struct. #[derive(Clone, Copy)] enum NoSearchActiveRow { SectionHeader(Section), @@ -141,8 +107,6 @@ enum NoSearchActiveRow { section: Section, item_idx: usize, }, - /// Show the remaining items in `section`. `hidden_count` is the number of - /// items currently hidden behind the truncation; used in the row label. ShowMore { section: Section, hidden_count: usize, @@ -159,28 +123,15 @@ impl NoSearchActiveRow { } } -/// Top-level state for the V2 menu. Exactly one variant is live at a time. enum MenuState { - /// User has not entered a query (just typed `/`). Results are grouped into - /// the three sections, each truncated to `ITEMS_PER_SECTION_COLLAPSED` - /// until the user activates the section's `Show N more` row. NoSearchActive { sections: Vec, expanded_sections: HashSet
, - /// Index into the visible-row sequence (`browsing_rows`). Headers and - /// dividers are skipped during navigation; items and `ShowMore` rows - /// are selectable. selected_idx: Option, - /// Mouse-state handles for `ShowMore` rows, keyed by section. show_more_mouse_states: HashMap, }, - /// User has typed a query. Results are pulled from across all sections - /// into a single flat list sorted by match score, with fuzzy match indices - /// highlighted. SearchActive { results: Vec>, - /// Index directly into `results`. Disabled items are skipped during - /// navigation. selected_idx: Option, }, } @@ -196,19 +147,14 @@ impl MenuState { } } -/// Internal action used to wire mouse hover/click events back into the view. #[derive(Debug, Clone)] pub enum CloudModeV2SlashCommandAction { - /// Accept the given item (Enter or click). Accept { item: AcceptSlashCommandOrSavedPrompt, cmd_or_ctrl_enter: bool, }, - /// Move the keyboard selection to the result at `idx` (mouse hover). HoverIdx(usize), - /// Toggle expansion for `section` (Show N more clicked). ToggleSection(Section), - /// User dismissed the menu (clicked outside, escape). Dismiss, } @@ -218,11 +164,6 @@ pub struct CloudModeV2SlashCommandView { input_buffer_model: ModelHandle, weak_handle: WeakViewHandle, scroll_state: ClippedScrollStateHandle, - /// Mutually exclusive: at any moment the menu is either `NoSearchActive` - /// (no query, sectioned with expand controls) or `SearchActive` (query, - /// flat ranked list). Replacing the previous `sections` + `flat_rows` - /// field pair with this enum means we never carry a stale empty copy of - /// the unused shape. menu_state: MenuState, } @@ -234,8 +175,6 @@ impl CloudModeV2SlashCommandView { input_buffer_model: ModelHandle, ctx: &mut ViewContext, ) -> Self { - // Re-run the active query whenever the set of active commands changes - // (e.g. CWD update, AI toggle). Mirrors `InlineSlashCommandView::new`. ctx.subscribe_to_model( &slash_commands_source, |me, _, _: &UpdatedActiveCommands, ctx| { @@ -257,10 +196,6 @@ impl CloudModeV2SlashCommandView { slash_commands_source.clone(), [QueryFilter::StaticSlashCommands], ); - // V2 keeps the saved-prompts async source but configures it - // identically to the legacy view; saved prompts in zero state are - // sourced from the V2 zero-state extension instead so the legacy - // mixer config doesn't need to change. mixer.add_async_source( saved_prompts_source, [QueryFilter::StaticSlashCommands], @@ -282,8 +217,6 @@ impl CloudModeV2SlashCommandView { ctx.subscribe_to_model(&mixer, |me, _, event, ctx| match event { SearchMixerEvent::ResultsChanged => { if me.mixer.as_ref(ctx).is_loading() { - // Keep stale results visible while async sources are - // pending to avoid flicker. Mirrors `InlineMenuView`. return; } me.rebuild_from_results(ctx); @@ -291,10 +224,6 @@ impl CloudModeV2SlashCommandView { } }); - // Re-run query when the slash command model state changes (the user - // typed after the leading `/`). Same gating as the legacy view: only - // re-run while the menu is open so we don't burn cycles on saved - // prompt searches after the menu has been closed. ctx.subscribe_to_model(slash_command_model, |me, model, _, ctx| { if !me.suggestions_mode_model.as_ref(ctx).is_slash_commands() { return; @@ -309,9 +238,6 @@ impl CloudModeV2SlashCommandView { } }); - // Buffer subscription so we transition between `NoSearchActive` and - // `SearchActive` immediately as the user adds/removes characters - // after the `/`, even if the slash command model didn't move state. ctx.subscribe_to_model( &input_buffer_model, |me, _, _: &InputBufferUpdateEvent, ctx| { @@ -331,8 +257,6 @@ impl CloudModeV2SlashCommandView { me.menu_state = MenuState::empty(); return; } - // If the menu reopened with a slash query already in the buffer, - // re-run the query so we don't show stale results. if me.suggestions_mode_model.as_ref(ctx).is_slash_commands() { me.run_query_for_current_slash_filter(ctx); } @@ -356,9 +280,6 @@ impl CloudModeV2SlashCommandView { self.move_selection(SelectionDirection::Down, ctx); } - /// Asks the outer `ClippedScrollable` to scroll the row at `visible_idx` - /// fully into view. Each row is wrapped in `SavePosition` with the - /// matching id so the position cache resolves it at paint time. fn scroll_selected_into_view(&self, visible_idx: usize) { self.scroll_state.scroll_to_position(ScrollTarget { position_id: row_position_id(visible_idx), @@ -424,8 +345,6 @@ impl CloudModeV2SlashCommandView { ctx.emit(SlashCommandsEvent::Close(CloseReason::ManualDismissal)); } - /// Returns the number of currently visible result items (for callers that - /// gate on the count, e.g. `Input::handle_slash_command_model_event`). pub fn result_count(&self, app: &AppContext) -> usize { self.mixer.as_ref(app).results().len() } @@ -454,20 +373,13 @@ impl CloudModeV2SlashCommandView { let on_click_fn = move |_idx: usize, item: AcceptSlashCommandOrSavedPrompt, evt_ctx: &mut warpui::EventContext| { - // Forward clicks through the typed action so the view receives - // them in `handle_action` regardless of which row dispatched. evt_ctx.dispatch_typed_action(CloudModeV2SlashCommandAction::Accept { item, cmd_or_ctrl_enter: false, }); - let _ = weak_handle; // keep the closure self-contained. + let _ = weak_handle; }; - // The mixer sorts results ascending by `(priority_tier, score, - // source_order)`, so the highest-priority item is at the *end* of - // the vec. Our menu renders top-to-bottom, so we reverse here to put - // the best match (or, for zero state, the alphabetically-first item - // since data sources emit in name-descending order) at the top. let renderers: Vec> = self .mixer .as_ref(ctx) @@ -501,8 +413,6 @@ impl CloudModeV2SlashCommandView { }) .collect(); - // Carry expansion across rebuilds while staying in - // `NoSearchActive`; reset on transition from `SearchActive`. let expanded_sections = match &self.menu_state { MenuState::NoSearchActive { expanded_sections, .. @@ -510,8 +420,6 @@ impl CloudModeV2SlashCommandView { MenuState::SearchActive { .. } => HashSet::new(), }; - // Allocate a stable mouse-state handle per section's `Show More` - // row so hover state survives rebuilds. let mut show_more_mouse_states = match &self.menu_state { MenuState::NoSearchActive { show_more_mouse_states, @@ -552,7 +460,6 @@ impl CloudModeV2SlashCommandView { expanded_sections.remove(§ion); } } - // Selection may now point past the end of the new visible row list. clamp_browsing_selection(&mut self.menu_state); ctx.notify(); } @@ -631,9 +538,6 @@ impl CloudModeV2SlashCommandView { let rows = browsing_rows(sections, expanded_sections); if rows.get(idx).is_some_and(|r| r.is_selectable()) { *selected_idx = Some(idx); - // Mouse-driven hover updates also benefit from re-anchoring - // the scroll position so keyboard navigation continues from - // a row that's actually visible. self.scroll_selected_into_view(idx); ctx.notify(); } @@ -663,7 +567,6 @@ enum SelectionDirection { Down, } -/// Builds the visible-row sequence for the `NoSearchActive` state. fn browsing_rows( sections: &[RenderedSection], expanded_sections: &HashSet
, @@ -829,11 +732,8 @@ impl CloudModeV2SlashCommandView { let row_element = self.wrap_with_hover( renderer.render_inline(visible_idx, is_selected, app), visible_idx, - /*is_browsing=*/ true, + true, ); - // Cache the row's painted bounds so the scrollable can - // scroll it into view when keyboard navigation lands on - // a row outside the viewport. column.add_child( SavePosition::new(row_element, &row_position_id(visible_idx)).finish(), ); @@ -878,21 +778,13 @@ impl CloudModeV2SlashCommandView { .with_main_axis_size(MainAxisSize::Min); for (idx, renderer) in results.iter().enumerate() { let is_selected = selected_idx == Some(idx); - let row_element = self.wrap_with_hover( - renderer.render_inline(idx, is_selected, app), - idx, - /*is_browsing=*/ false, - ); - // Cache row bounds for `scroll_to_position` keyboard - // navigation; `idx` here is the same index `selected_idx` - // tracks in `MenuState::SearchActive`. + let row_element = + self.wrap_with_hover(renderer.render_inline(idx, is_selected, app), idx, false); column.add_child(SavePosition::new(row_element, &row_position_id(idx)).finish()); } column.finish() } - /// Wraps an item-row element with a `MouseInBehavior` so hover updates the - /// keyboard selection — same UX as `InlineMenuView` rows. fn wrap_with_hover( &self, element: Box, @@ -1033,8 +925,6 @@ impl View for CloudModeV2SlashCommandView { .with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS))) .with_padding_top(MENU_VERTICAL_PADDING) .with_padding_bottom(MENU_VERTICAL_PADDING) - // Match the inline model/profile selector menus, which use the - // shared default shadow from `DropShadow::default()`. .with_drop_shadow(DropShadow::default()) .finish() } @@ -1108,10 +998,6 @@ fn render_show_more_row( fn render_divider(app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); - // Paint the 1px constrained box itself; the outer container only - // contributes transparent vertical spacing. Putting the background - // on the outer container would also color its padding, producing a - // band of `padding + height + padding` pixels instead of 1px. Container::new( ConstrainedBox::new( Container::new(warpui::elements::Empty::new().finish()) @@ -1125,9 +1011,6 @@ fn render_divider(app: &AppContext) -> Box { .finish() } -// ICON_SIZE / ICON_TO_TEXT_GAP are referenced from the design specs; surface -// them via small helpers so the constants don't appear unused if the per-row -// element layout shifts. #[allow(dead_code)] fn _icon_size() -> f32 { ICON_SIZE diff --git a/app/src/terminal/input/slash_commands/data_source/mod.rs b/app/src/terminal/input/slash_commands/data_source/mod.rs index f1b95ea1..72db46ab 100644 --- a/app/src/terminal/input/slash_commands/data_source/mod.rs +++ b/app/src/terminal/input/slash_commands/data_source/mod.rs @@ -59,11 +59,6 @@ pub struct SlashCommandDataSource { terminal_view_id: EntityId, active_commands_by_id: HashMap, active_repo_root: Option, - /// When true, items emitted from this source carry `compact_layout = - /// true` so the V2 cloud-mode menu renders them with a tight - /// `name → 8px → description` layout instead of the legacy fixed-width - /// name column. The legacy menu sets this to false to preserve column - /// alignment across rows. compact_layout: bool, } @@ -72,10 +67,6 @@ impl SlashCommandDataSource { Self::build(args, false, ctx) } - /// Constructor used when the V2 cloud-mode menu is active. Items - /// emitted by `run_query` carry `compact_layout = true` so the narrow - /// floating menu (320px) renders descriptions close to the name - /// instead of pushing them offscreen behind a fixed name column. pub fn for_cloud_mode_v2(args: DataSourceArgs, ctx: &mut ModelContext) -> Self { Self::build(args, true, ctx) } @@ -274,9 +265,6 @@ impl SlashCommandDataSource { self.agent_view_controller.as_ref(ctx).is_active() } - /// Accessor used by `ZeroStateDataSource::for_cloud_mode_v2` so it can - /// resolve the current working directory for skill scoping without - /// duplicating the active-session model on the new data source. pub fn active_session_for_v2_zero_state(&self) -> &ModelHandle { &self.active_session } @@ -444,10 +432,6 @@ pub struct InlineItem { pub name_match_result: Option, pub description_match_result: Option, pub score: OrderedFloat, - /// When true, render with a tight `name → 8px → description` layout - /// instead of the legacy fixed-width name column. Set by V2 data - /// sources where the menu is narrow (320px) and a fixed name column - /// would push descriptions offscreen. pub compact_layout: bool, } @@ -471,9 +455,6 @@ impl InlineItem { } } - /// Builds an inline item for a saved prompt (cloud workflow). Used by the - /// V2 zero-state path; the legacy menu reaches saved prompts through the - /// async `saved_prompts_data_source` instead. pub(crate) fn from_saved_prompt( saved_prompt: &crate::workflows::CloudWorkflow, app: &AppContext, @@ -483,11 +464,9 @@ impl InlineItem { action: AcceptSlashCommandOrSavedPrompt::SavedPrompt { id: saved_prompt.id, }, - // Matches the icon used by the legacy async `saved_prompts.rs` path. icon_path: "bundled/svg/prompt.svg", name: saved_prompt.model().data.name().to_owned(), description: None, - // Saved prompts use the UI font family (matching the legacy async path). font_family: appearance.ui_font_family(), name_match_result: None, description_match_result: None, @@ -544,9 +523,6 @@ impl InlineItem { self } - /// Toggles the compact layout flag used by `SearchItem::render_item` - /// to choose between the legacy fixed-width name column and the V2 - /// tight `name → 8px → description` layout. pub(crate) fn with_compact_layout(mut self, compact: bool) -> Self { self.compact_layout = compact; self diff --git a/app/src/terminal/input/slash_commands/data_source/saved_prompts.rs b/app/src/terminal/input/slash_commands/data_source/saved_prompts.rs index 8ab0cd8d..90304cf3 100644 --- a/app/src/terminal/input/slash_commands/data_source/saved_prompts.rs +++ b/app/src/terminal/input/slash_commands/data_source/saved_prompts.rs @@ -114,8 +114,6 @@ pub(crate) fn fuzzy_match_saved_prompts( name_match_result, description_match_result: None, score: OrderedFloat(100.0), - // Saved prompts have no description so the layout - // flag is unobservable; default to false. compact_layout: false, }; results.push(QueryResult::from(item)); @@ -146,8 +144,6 @@ pub(crate) fn fuzzy_match_saved_prompts( name_match_result: match_result.name_match_result, description_match_result: match_result.content_match_result, score, - // Saved prompts have no description so the layout - // flag is unobservable; default to false. compact_layout: false, }; results.push(QueryResult::from(item)); diff --git a/app/src/terminal/input/slash_commands/data_source/zero_state.rs b/app/src/terminal/input/slash_commands/data_source/zero_state.rs index 87d0d1d4..e1d7a5ca 100644 --- a/app/src/terminal/input/slash_commands/data_source/zero_state.rs +++ b/app/src/terminal/input/slash_commands/data_source/zero_state.rs @@ -15,19 +15,8 @@ use crate::terminal::input::slash_commands::{ pub struct ZeroStateDataSource { slash_command_data_source: ModelHandle, - /// When true, surface skills (in addition to slash commands) when the query - /// is empty. Used by the cloud-mode V2 menu, which renders skills in their - /// own section. The legacy inline menu keeps this disabled. include_skills: bool, - /// When true, surface saved prompts (in addition to slash commands) when - /// the query is empty. Used by the cloud-mode V2 menu, which renders - /// prompts in their own section. The legacy inline menu keeps this - /// disabled. include_saved_prompts: bool, - /// When true, items emitted carry `compact_layout = true` so the V2 - /// menu renders them with a tight `name → 8px → description` layout. - /// Tracked separately from `include_*` so the layout decision is - /// explicit rather than coupled to which sections are surfaced. compact_layout: bool, } @@ -41,10 +30,6 @@ impl ZeroStateDataSource { } } - /// Constructor for the cloud-mode V2 slash command menu. Surfaces skills - /// and saved prompts in zero state alongside slash commands so the V2 - /// menu can render all three sections (Commands / Skills / Prompts) before - /// the user types a query. pub fn for_cloud_mode_v2( slash_command_data_source: &ModelHandle, ) -> Self { @@ -126,10 +111,6 @@ impl SyncDataSource for ZeroStateDataSource { } } - // Skills are gated by the `ListSkills` feature flag and the global AI - // setting (matching `SlashCommandDataSource::run_query` for non-empty - // queries). Items are emitted in name-descending order so the mixer's - // ascending priority sort lands on alphabetical order. if self.include_skills && FeatureFlag::ListSkills.is_enabled() && AISettings::as_ref(app).is_any_ai_enabled(app) @@ -149,8 +130,6 @@ impl SyncDataSource for ZeroStateDataSource { .into_iter() .sorted_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase())) { - // Mirror the CLI-agent provider filtering applied to fuzzy search - // so zero state and search state stay consistent. if let Some(providers) = &cli_agent_providers { if !skill_manager.skill_exists_for_any_provider(&skill, providers) { continue; @@ -165,9 +144,6 @@ impl SyncDataSource for ZeroStateDataSource { } } - // Saved prompts are agent-mode workflows; only surface them when AI is - // globally enabled. Items are emitted in name-descending order so the - // mixer's ascending priority sort lands on alphabetical order. if self.include_saved_prompts && AISettings::as_ref(app).is_any_ai_enabled(app) { let saved_prompts: Vec<_> = CloudModel::as_ref(app) .get_all_active_workflows() diff --git a/app/src/terminal/input/slash_commands/search_item.rs b/app/src/terminal/input/slash_commands/search_item.rs index d9452048..5bddad1c 100644 --- a/app/src/terminal/input/slash_commands/search_item.rs +++ b/app/src/terminal/input/slash_commands/search_item.rs @@ -112,12 +112,6 @@ impl SearchItem for InlineItem { .with_margin_left(4.) .finish(), ); - // The trailing `Shrinkable` is filler that pushes the keystroke - // chip flush-left inside the legacy fixed-width name column. - // The compact V2 layout sizes the row to its content (no - // fixed-width parent), so adding a flexible child here would - // be measured against an infinite main-axis constraint and - // panic the flex layout. if !self.compact_layout { row = row.with_child(Shrinkable::new(1., Empty::new().finish()).finish()); } @@ -128,12 +122,8 @@ impl SearchItem for InlineItem { row.add_child(if self.description.is_some() { if self.compact_layout { - // V2 narrow menu: tight 8px gap so descriptions stay - // onscreen in the 320px floating panel. Container::new(name_element).with_margin_right(8.).finish() } else { - // Legacy menu: align descriptions in a fixed column for - // visual continuity across rows. ConstrainedBox::new(name_element) .with_width(inline_width_for_name_column(app)) .finish() diff --git a/app/src/terminal/input/slash_commands/view.rs b/app/src/terminal/input/slash_commands/view.rs index b853c677..8a08cbe2 100644 --- a/app/src/terminal/input/slash_commands/view.rs +++ b/app/src/terminal/input/slash_commands/view.rs @@ -273,9 +273,6 @@ impl View for InlineSlashCommandView { } } -/// Build a Query that includes the StaticSlashCommands filter so both sync and -/// async sources run. Reused by the V2 cloud-mode menu so the two views agree on -/// query shape. pub(super) fn slash_command_query(text: &str) -> Query { Query { text: text.to_owned(), From a9aebecd6a0081bf6a42240bf7409269349389cc Mon Sep 17 00:00:00 2001 From: Abhishek Pandya Date: Tue, 28 Apr 2026 23:32:12 -0400 Subject: [PATCH 6/6] robot feedback --- app/src/terminal/input.rs | 53 +++++++++++-------- .../slash_commands/cloud_mode_v2_view.rs | 11 ++-- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 63b52a0b..289a5365 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -2948,12 +2948,20 @@ impl Input { cli_subagent_controller: cli_subagent_controller.clone(), terminal_view_id, }; - if FeatureFlag::CloudModeInputV2.is_enabled() { - SlashCommandDataSource::for_cloud_mode_v2(args, ctx) - } else { - SlashCommandDataSource::new(args, ctx) - } + SlashCommandDataSource::new(args, ctx) }); + + let v2_slash_command_data_source = if FeatureFlag::CloudModeInputV2.is_enabled() { + let args = slash_commands::DataSourceArgs { + active_session: active_session.clone(), + agent_view_controller: agent_view_controller.clone(), + cli_subagent_controller: cli_subagent_controller.clone(), + terminal_view_id, + }; + Some(ctx.add_model(|ctx| SlashCommandDataSource::for_cloud_mode_v2(args, ctx))) + } else { + None + }; let slash_command_model = ctx.add_model(|ctx| { SlashCommandModel::new( &buffer_model, @@ -3115,23 +3123,24 @@ impl Input { me.handle_slash_commands_menu_event(event, ctx); }); - let cloud_mode_v2_slash_commands_view = if FeatureFlag::CloudModeInputV2.is_enabled() { - let view = ctx.add_typed_action_view(|ctx| { - CloudModeV2SlashCommandView::new( - &slash_command_model, - slash_command_data_source.clone(), - suggestions_mode_model.clone(), - buffer_model.clone(), - ctx, - ) - }); - ctx.subscribe_to_view(&view, |me, _, event, ctx| { - me.handle_slash_commands_menu_event(event, ctx); - }); - Some(view) - } else { - None - }; + let cloud_mode_v2_slash_commands_view = + if let Some(v2_data_source) = v2_slash_command_data_source { + let view = ctx.add_typed_action_view(|ctx| { + CloudModeV2SlashCommandView::new( + &slash_command_model, + v2_data_source, + suggestions_mode_model.clone(), + buffer_model.clone(), + ctx, + ) + }); + ctx.subscribe_to_view(&view, |me, _, event, ctx| { + me.handle_slash_commands_menu_event(event, ctx); + }); + Some(view) + } else { + None + }; ctx.subscribe_to_model(&ai_input_model, move |me, _, event, ctx| { match event { diff --git a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs index b0874fe8..5db6654f 100644 --- a/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs +++ b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs @@ -12,7 +12,6 @@ use warpui::elements::{ use warpui::platform::Cursor; use warpui::{ AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, - WeakViewHandle, }; use crate::search::data_source::QueryFilter; @@ -162,7 +161,6 @@ pub struct CloudModeV2SlashCommandView { mixer: ModelHandle>, suggestions_mode_model: ModelHandle, input_buffer_model: ModelHandle, - weak_handle: WeakViewHandle, scroll_state: ClippedScrollStateHandle, menu_state: MenuState, } @@ -266,7 +264,6 @@ impl CloudModeV2SlashCommandView { mixer, suggestions_mode_model, input_buffer_model, - weak_handle: ctx.handle(), scroll_state: Default::default(), menu_state: MenuState::empty(), } @@ -369,15 +366,13 @@ impl CloudModeV2SlashCommandView { } fn rebuild_from_results(&mut self, ctx: &mut ViewContext) { - let weak_handle = self.weak_handle.clone(); - let on_click_fn = move |_idx: usize, - item: AcceptSlashCommandOrSavedPrompt, - evt_ctx: &mut warpui::EventContext| { + let on_click_fn = |_idx: usize, + item: AcceptSlashCommandOrSavedPrompt, + evt_ctx: &mut warpui::EventContext| { evt_ctx.dispatch_typed_action(CloudModeV2SlashCommandAction::Accept { item, cmd_or_ctrl_enter: false, }); - let _ = weak_handle; }; let renderers: Vec> = self