use ai::skills::{SkillProvider, SkillReference, SkillScope}; use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult}; use ordered_float::OrderedFloat; use galaxy_core::ui::icons::Icon; use galaxy_core::ui::theme::Fill; use galaxy_util::local_or_remote_path::LocalOrRemotePath; use galaxyui::elements::{ ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, ParentElement, Shrinkable, Text, }; use galaxyui::fonts::{Properties, Weight}; use galaxyui::keymap::Keystroke; use galaxyui::scene::{CornerRadius, Radius}; use galaxyui::text_layout::ClipConfig; use galaxyui::{ AppContext, Element, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, }; use crate::ai::skills::SkillManager; use crate::appearance::Appearance; use crate::search::data_source::{Query, QueryResult}; use crate::search::mixer::DataSourceRunErrorWrapper; use crate::search::result_renderer::ItemHighlightState; use crate::search::{SearchItem, SyncDataSource}; use crate::terminal::cli_agent_sessions::{CLIAgentInputState, CLIAgentSessionsModel}; use crate::terminal::input::inline_menu::{ default_navigation_message_items, styles as inline_styles, InlineMenuAction, InlineMenuMessageArgs, InlineMenuType, }; use crate::terminal::input::message_bar::{Message, MessageItem}; use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent}; #[derive(Clone, Debug)] pub struct AcceptSkill { pub skill_name: String, pub skill_reference: SkillReference, } impl InlineMenuAction for AcceptSkill { const MENU_TYPE: InlineMenuType = InlineMenuType::SkillMenu; fn produce_inline_menu_message(args: InlineMenuMessageArgs<'_, Self, T>) -> Option { // If no item is selected, show "No skills found" message with escape hint if args.inline_menu_model.selected_item().is_none() { return Some(Message::new(vec![ MessageItem::text("No skills found"), MessageItem::keystroke(Keystroke { key: "escape".to_owned(), ..Default::default() }), MessageItem::text(" to dismiss"), ])); } // Otherwise show default navigation hints Some(Message::new(default_navigation_message_items(&args))) } // No details panel - we show inline descriptions instead } /// Event emitted when available skills may have changed. #[derive(Debug, Clone, Copy)] pub struct UpdatedAvailableSkills; pub struct SkillSelectorDataSource { active_session: ModelHandle, terminal_view_id: EntityId, /// Whether bundled skills should be included in results. /// False for `/open-skill` (bundled skills can't be edited), true for `/skills` (they can be invoked). include_bundled: bool, } impl SkillSelectorDataSource { pub fn new( active_session: ModelHandle, terminal_view_id: EntityId, ctx: &mut ModelContext, ) -> Self { ctx.subscribe_to_model(&active_session, |_, _, event, ctx| match event { // Emit event so the mixer can re-run its query with the new pwd ActiveSessionEvent::UpdatedPwd | ActiveSessionEvent::Bootstrapped => { ctx.emit(UpdatedAvailableSkills); } }); Self { active_session, terminal_view_id, include_bundled: false, } } /// Returns the supported skill providers for the active CLI agent, or `None` if /// CLI agent input is not open. fn active_cli_agent_providers(&self, app: &AppContext) -> Option<&'static [SkillProvider]> { CLIAgentSessionsModel::as_ref(app) .session(self.terminal_view_id) .filter(|s| matches!(s.input_state, CLIAgentInputState::Open { .. })) .map(|s| s.agent.supported_skill_providers()) } pub fn set_include_bundled(&mut self, include_bundled: bool) { self.include_bundled = include_bundled; } /// Get the current working directory location from the active session. fn get_current_working_directory(&self, app: &AppContext) -> Option { self.active_session .as_ref(app) .current_working_directory_location(app) } } impl SyncDataSource for SkillSelectorDataSource { type Action = AcceptSkill; fn run_query( &self, query: &Query, app: &AppContext, ) -> Result>, DataSourceRunErrorWrapper> { let cwd = self.get_current_working_directory(app); let cli_agent_providers = self.active_cli_agent_providers(app); let skills = SkillManager::as_ref(app).get_skills_for_working_directory(cwd.as_ref(), app); // Filter out bundled skills when in open mode, since they cannot be opened. // Bundled skills are identified by scope rather than reference: local // catalog entries are `BundledSkillId`-referenced, but remote catalog // entries are path-referenced, and both must be excluded here. // When CLI agent input is open, filter to skills that exist in a supported // provider folder. We check all paths for the skill name (not just the // deduplicated provider) because deduplication may pick a higher-priority // provider even when the skill also exists in the CLI agent's folder. let skill_manager = SkillManager::as_ref(app); let skills: Vec<_> = skills .into_iter() .filter(|skill| { if let Some(providers) = &cli_agent_providers { skill_manager.skill_exists_for_any_provider(skill, providers) } else { self.include_bundled || skill.scope != SkillScope::Bundled } }) .map(|mut skill| { // When a CLI agent is active, re-map the provider to the best // supported one so the icon reflects the agent's native provider // rather than the global dedup winner. if let Some(providers) = &cli_agent_providers { skill.provider = skill_manager.best_supported_provider(&skill, providers); } skill }) .collect(); let query_text = query.text.trim(); if query_text.is_empty() { return Ok(skills .into_iter() .map(|skill| { QueryResult::from(SkillSearchItem::new( skill.name, skill.reference, skill.description, skill.scope, skill.provider, skill.icon_override, )) }) .collect()); } Ok(skills .into_iter() .filter_map(|skill| { let match_result = match_indices_case_insensitive(skill.name.as_str(), query_text)?; // Avoid spamming results with extremely weak matches. if query_text.len() > 1 && match_result.score < 10 { return None; } Some(QueryResult::from( SkillSearchItem::new( skill.name, skill.reference, skill.description, skill.scope, skill.provider, skill.icon_override, ) .with_name_match_result(Some(match_result.clone())) .with_score(OrderedFloat(match_result.score as f64)), )) }) .collect()) } } impl Entity for SkillSelectorDataSource { type Event = UpdatedAvailableSkills; } #[derive(Clone)] struct SkillSearchItem { skill_name: String, skill_reference: SkillReference, skill_description: String, scope: SkillScope, provider: SkillProvider, icon_override: Option, name_match_result: Option, score: OrderedFloat, } impl SkillSearchItem { fn new( skill_name: String, skill_reference: SkillReference, skill_description: String, scope: SkillScope, provider: SkillProvider, icon_override: Option, ) -> Self { Self { skill_name, skill_reference, skill_description, scope, provider, icon_override, name_match_result: None, score: OrderedFloat(f64::MIN), } } fn with_name_match_result(mut self, result: Option) -> Self { self.name_match_result = result; self } fn with_score(mut self, score: OrderedFloat) -> Self { self.score = score; self } } /// Fixed width for the skill name column (similar to slash commands). fn skill_name_column_width(app: &AppContext) -> f32 { let appearance = Appearance::as_ref(app); // Use a reasonable fixed width for skill names app.font_cache().em_width( appearance.monospace_font_family(), inline_styles::font_size(appearance), ) * 20.0 // Allow space for skill names like "/analyze-code" + 32.0 } impl SearchItem for SkillSearchItem { type Action = AcceptSkill; fn render_icon( &self, _highlight_state: ItemHighlightState, appearance: &Appearance, ) -> Box { let icon_color = inline_styles::icon_color(appearance); let icon_size = inline_styles::font_size(appearance); // Use icon_override if set (e.g. Figma skills), otherwise derive from provider. let icon = if let Some(override_icon) = self.icon_override { override_icon.to_galaxyui_icon(icon_color).finish() } else { self.provider .icon() .to_galaxyui_icon(self.provider.icon_fill(icon_color)) .finish() }; Container::new( ConstrainedBox::new(icon) .with_width(icon_size) .with_height(icon_size) .finish(), ) .with_margin_right(inline_styles::ICON_MARGIN) .finish() } fn render_item( &self, _highlight_state: ItemHighlightState, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let font_size = inline_styles::font_size(appearance); let background_color = inline_styles::menu_background_color(app); let primary_text_color = inline_styles::primary_text_color(theme, background_color.into()); let secondary_color = inline_styles::secondary_text_color(theme, background_color.into()); // Create row layout for inline text let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); // Skill name with fuzzy match highlighting let mut name_text = Text::new_inline( self.skill_name.clone(), appearance.ui_font_family(), font_size, ) .with_color(primary_text_color.into()) .with_clip(ClipConfig::ellipsis()); if let Some(name_match) = &self.name_match_result { if !name_match.matched_indices.is_empty() { name_text = name_text.with_single_highlight( Highlight::new().with_properties(Properties::default().weight(Weight::Bold)), name_match.matched_indices.clone(), ); } } row.add_child( ConstrainedBox::new(name_text.finish()) .with_width(skill_name_column_width(app)) .finish(), ); // Description and optional "Project Skill" badge // The description should truncate first, badge stays fixed size // We wrap the whole description_row in Shrinkable to give it a bounded constraint let mut description_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); if !self.skill_description.is_empty() { let description_text = Text::new_inline( self.skill_description.clone(), appearance.ui_font_family(), font_size, ) .with_color(secondary_color.into()) .with_clip(ClipConfig::ellipsis()); // Use Shrinkable so description truncates before the badge description_row.add_child(Shrinkable::new(1., description_text.finish()).finish()); } // "Project Skill" badge for project skills (placed after description) if self.scope == SkillScope::Project { let badge_font_size = font_size - 4.0; // Badge text uses disabled_text_color (40% opacity) per Figma #6d7276 let badge_text_color = inline_styles::disabled_text_color(theme, background_color.into()); let badge_text = Text::new_inline( "Project Skill".to_string(), appearance.ui_font_family(), badge_font_size, ) .with_color(badge_text_color.into()) .with_clip(ClipConfig::ellipsis()); let badge = Container::new(badge_text.finish()) .with_horizontal_padding(6.0) .with_vertical_padding(2.0) .with_background(theme.surface_overlay_1()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))) .with_margin_left(8.0) .finish(); description_row.add_child(badge); } // Wrap in Shrinkable to provide bounded constraint for inner flexible children row.add_child(Shrinkable::new(1., description_row.finish()).finish()); row.finish() } fn item_background( &self, highlight_state: ItemHighlightState, appearance: &Appearance, ) -> Option { inline_styles::item_background(highlight_state, appearance) } // No details panel - we show inline descriptions instead fn render_details(&self, _app: &AppContext) -> Option> { None } fn score(&self) -> OrderedFloat { self.score } fn accept_result(&self) -> Self::Action { AcceptSkill { skill_name: self.skill_name.clone(), skill_reference: self.skill_reference.clone(), } } fn execute_result(&self) -> Self::Action { self.accept_result() } fn accessibility_label(&self) -> String { format!("Skill: {}", self.skill_name) } }