diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 5e5d1af8..289a5365 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,7 @@ pub struct Input { prompt_suggestions_view: ViewHandle, inline_slash_commands_view: ViewHandle, + cloud_mode_v2_slash_commands_view: Option>, slash_command_data_source: ModelHandle, /// Inline conversation menu for selecting AI conversations. @@ -2940,16 +2942,26 @@ 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, + }; + 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, @@ -3111,6 +3123,25 @@ impl Input { me.handle_slash_commands_menu_event(event, ctx); }); + 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 { BlocklistAIInputEvent::InputTypeChanged { .. } @@ -3247,6 +3278,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 +7558,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 +7919,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 +11777,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..71a448be 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,9 @@ 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) + { 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 +392,29 @@ impl Input { ); } + 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..5db6654f --- /dev/null +++ b/app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs @@ -0,0 +1,1017 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +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, SavePosition, + ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Text, +}; +use warpui::platform::Cursor; +use warpui::{ + AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, +}; + +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.; + +const MENU_MAX_HEIGHT: f32 = 400.; + +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 = 0.; + +fn row_position_id(visible_idx: usize) -> String { + format!("cloud_mode_v2_slash_row_{visible_idx}") +} + +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() + }); + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Section { + Commands, + Skills, + Prompts, +} + +impl Section { + 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, + } + } +} + +struct RenderedSection { + section: Section, + items: Vec>, +} + +#[derive(Clone, Copy)] +enum NoSearchActiveRow { + SectionHeader(Section), + Item { + section: Section, + item_idx: usize, + }, + ShowMore { + section: Section, + hidden_count: usize, + }, + Divider, +} + +impl NoSearchActiveRow { + fn is_selectable(self) -> bool { + matches!( + self, + NoSearchActiveRow::Item { .. } | NoSearchActiveRow::ShowMore { .. } + ) + } +} + +enum MenuState { + NoSearchActive { + sections: Vec, + expanded_sections: HashSet
, + selected_idx: Option, + show_more_mouse_states: HashMap, + }, + SearchActive { + results: Vec>, + 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(), + } + } +} + +#[derive(Debug, Clone)] +pub enum CloudModeV2SlashCommandAction { + Accept { + item: AcceptSlashCommandOrSavedPrompt, + cmd_or_ctrl_enter: bool, + }, + HoverIdx(usize), + ToggleSection(Section), + Dismiss, +} + +pub struct CloudModeV2SlashCommandView { + mixer: ModelHandle>, + suggestions_mode_model: ModelHandle, + input_buffer_model: ModelHandle, + scroll_state: ClippedScrollStateHandle, + 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 { + 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], + ); + 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() { + return; + } + me.rebuild_from_results(ctx); + ctx.notify(); + } + }); + + 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); + } + _ => (), + } + }); + + 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 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, + 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); + } + + 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 { + 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)); + } + + 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 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 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, + *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(); + + let expanded_sections = match &self.menu_state { + MenuState::NoSearchActive { + expanded_sections, .. + } => expanded_sections.clone(), + MenuState::SearchActive { .. } => HashSet::new(), + }; + + 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); + } + } + 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) { + let next_idx: Option = 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); + } + 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); + } + next + } + }; + if let Some(idx) = next_idx { + self.scroll_selected_into_view(idx); + } + 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); + self.scroll_selected_into_view(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); + self.scroll_selected_into_view(idx); + ctx.notify(); + } + } + } + } +} + +#[derive(Clone, Copy)] +enum SelectionDirection { + Up, + Down, +} + +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; + }; + let row_element = self.wrap_with_hover( + renderer.render_inline(visible_idx, is_selected, app), + visible_idx, + true, + ); + column.add_child( + SavePosition::new(row_element, &row_position_id(visible_idx)).finish(), + ); + } + NoSearchActiveRow::ShowMore { + section, + hidden_count, + } => { + let mouse_state = show_more_mouse_states + .get(§ion) + .cloned() + .unwrap_or_default(); + 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)); + } + } + } + + 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); + 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() + } + + 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::default()) + .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( + Container::new(warpui::elements::Empty::new().finish()) + .with_background(theme.surface_overlay_2()) + .finish(), + ) + .with_height(DIVIDER_HEIGHT) + .finish(), + ) + .with_vertical_padding(DIVIDER_VERTICAL_PADDING) + .finish() +} + +#[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..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,10 +59,19 @@ pub struct SlashCommandDataSource { terminal_view_id: EntityId, active_commands_by_id: HashMap, active_repo_root: Option, + compact_layout: bool, } impl SlashCommandDataSource { pub fn new(args: DataSourceArgs, ctx: &mut ModelContext) -> Self { + Self::build(args, false, ctx) + } + + 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 +142,7 @@ impl SlashCommandDataSource { terminal_view_id, active_commands_by_id: Default::default(), active_repo_root: None, + compact_layout, }; me.recompute_active_commands(ctx); me @@ -255,6 +265,10 @@ impl SlashCommandDataSource { self.agent_view_controller.as_ref(ctx).is_active() } + 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) @@ -313,6 +327,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 @@ -366,6 +381,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 @@ -416,6 +432,7 @@ pub struct InlineItem { pub name_match_result: Option, pub description_match_result: Option, pub score: OrderedFloat, + pub compact_layout: bool, } impl InlineItem { @@ -434,6 +451,27 @@ impl InlineItem { name_match_result: None, description_match_result: None, score: OrderedFloat(f64::MIN), + compact_layout: false, + } + } + + 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, + }, + icon_path: "bundled/svg/prompt.svg", + name: saved_prompt.model().data.name().to_owned(), + description: None, + font_family: appearance.ui_font_family(), + name_match_result: None, + description_match_result: None, + score: OrderedFloat(f64::MIN), + compact_layout: false, } } @@ -466,6 +504,7 @@ impl InlineItem { name_match_result: None, description_match_result: None, score: OrderedFloat(f64::MIN), + compact_layout: false, } } @@ -483,6 +522,11 @@ impl InlineItem { self.score = score; self } + + 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..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,6 +114,7 @@ pub(crate) fn fuzzy_match_saved_prompts( name_match_result, description_match_result: None, score: OrderedFloat(100.0), + compact_layout: false, }; results.push(QueryResult::from(item)); } @@ -143,6 +144,7 @@ pub(crate) fn fuzzy_match_saved_prompts( name_match_result: match_result.name_match_result, description_match_result: match_result.content_match_result, score, + 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 14243e9e..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 @@ -1,22 +1,43 @@ 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, + include_skills: bool, + include_saved_prompts: bool, + compact_layout: 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, + compact_layout: false, + } + } + + 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, + compact_layout: true, } } } @@ -70,7 +91,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(), ); } } @@ -80,7 +103,65 @@ 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(), + ); + } + } + + 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())) + { + 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) + .with_compact_layout(self.compact_layout) + .into(), + ); + } + } + + 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) + .with_compact_layout(self.compact_layout) + .into(), + ); } } 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/search_item.rs b/app/src/terminal/input/slash_commands/search_item.rs index c70d12df..5bddad1c 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,23 @@ impl SearchItem for InlineItem { )) .with_margin_left(4.) .finish(), - ) - .with_child(Shrinkable::new(1., Empty::new().finish()).finish()) - .finish() + ); + 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 { + Container::new(name_element).with_margin_right(8.).finish() + } else { + ConstrainedBox::new(name_element) + .with_width(inline_width_for_name_column(app)) + .finish() + } } else { name_element }); diff --git a/app/src/terminal/input/slash_commands/view.rs b/app/src/terminal/input/slash_commands/view.rs index 6eb7ee30..8a08cbe2 100644 --- a/app/src/terminal/input/slash_commands/view.rs +++ b/app/src/terminal/input/slash_commands/view.rs @@ -273,9 +273,7 @@ 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 { +pub(super) fn slash_command_query(text: &str) -> Query { Query { text: text.to_owned(), filters: SLASH_COMMAND_FILTERS.clone(),