use std::collections::HashMap;use std::collections::HashSet;use super::ChatWidget;use crate::app_event::AppEvent;use crate::bottom_pane::SelectionItem;use crate::bottom_pane::SelectionViewParams;use crate::bottom_pane::SkillsToggleItem;use crate::bottom_pane::SkillsToggleView;use crate::bottom_pane::popup_consts::standard_popup_hint_line;use crate::skills_helpers::skill_description;use crate::skills_helpers::skill_display_name;use codex_app_server_protocol::AppInfo;use codex_app_server_protocol::SkillMetadata as ProtocolSkillMetadata;use codex_app_server_protocol::SkillsListEntry;use codex_app_server_protocol::SkillsListResponse;use codex_core_skills::model::SkillDependencies;use codex_core_skills::model::SkillInterface;use codex_core_skills::model::SkillMetadata;use codex_core_skills::model::SkillToolDependency;use codex_features::Feature;use codex_protocol::parse_command::ParsedCommand;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;impl ChatWidget { pub(crate) fn open_skills_list(&mut self) { if self.config.features.enabled(Feature::MentionsV2) { self.insert_str("@"); } else { self.insert_str("$"); } } pub(crate) fn open_skills_menu(&mut self) { let list_shortcut = if self.config.features.enabled(Feature::MentionsV2) { '@' } else { '$' }; let items = vec![ SelectionItem { name: "List skills".to_string(), description: Some(format!( "Tip: press {list_shortcut} to open this list directly." )), actions: vec![Box::new(|tx| { tx.send(AppEvent::OpenSkillsList); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Enable/Disable Skills".to_string(), description: Some("Enable or disable skills.".to_string()), actions: vec![Box::new(|tx| { tx.send(AppEvent::OpenManageSkillsPopup); })], dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Skills".to_string()), subtitle: Some("Choose an action".to_string()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } pub(crate) fn open_manage_skills_popup(&mut self) { if self.skills_all.is_empty() { self.add_info_message("No skills available.".to_string(), /*hint*/ None); return; } let mut initial_state = HashMap::new(); for skill in &self.skills_all { initial_state.insert(skill.path.clone(), skill.enabled); } self.skills_initial_state = Some(initial_state); let items: Vec<SkillsToggleItem> = self .skills_all .iter() .filter_map(|skill| { let core_skill = protocol_skill_to_core(skill)?; let display_name = skill_display_name(&core_skill); let description = skill_description(&core_skill).to_string(); let name = core_skill.name.clone(); let path = core_skill.path_to_skills_md; Some(SkillsToggleItem { name: display_name, skill_name: name, description, enabled: skill.enabled, path, }) }) .collect(); let view = SkillsToggleView::new( items, self.app_event_tx.clone(), self.bottom_pane.list_keymap(), ); self.bottom_pane.show_view(Box::new(view)); } pub(crate) fn update_skill_enabled(&mut self, path: AbsolutePathBuf, enabled: bool) { for skill in &mut self.skills_all { if skill.path == path { skill.enabled = enabled; } } self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all))); } pub(crate) fn handle_manage_skills_closed(&mut self) { let Some(initial_state) = self.skills_initial_state.take() else { return; }; let mut current_state = HashMap::new(); for skill in &self.skills_all { current_state.insert(skill.path.clone(), skill.enabled); } let mut enabled_count = 0; let mut disabled_count = 0; for (path, was_enabled) in initial_state { let Some(is_enabled) = current_state.get(&path) else { continue; }; if was_enabled != *is_enabled { if *is_enabled { enabled_count += 1; } else { disabled_count += 1; } } } if enabled_count == 0 && disabled_count == 0 { return; } self.add_info_message( format!("{enabled_count} skills enabled, {disabled_count} skills disabled"), /*hint*/ None, ); } pub(crate) fn set_skills_from_response(&mut self, response: &SkillsListResponse) { let skills = skills_for_cwd(&self.config.cwd, &response.data); self.skills_all = skills; self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all))); } pub(crate) fn annotate_skill_reads_in_parsed_cmd( &self, mut parsed_cmd: Vec<ParsedCommand>, ) -> Vec<ParsedCommand> { if self.skills_all.is_empty() { return parsed_cmd; } for parsed in &mut parsed_cmd { let ParsedCommand::Read { name, path, .. } = parsed else { continue; }; if name != "SKILL.md" { continue; } // Best effort only: annotate exact SKILL.md path matches from the loaded skills list. if let Some(skill) = self .skills_all .iter() .find(|skill| skill.path.as_path() == path) { *name = format!("{name} ({} skill)", skill.name); } } parsed_cmd }}fn skills_for_cwd( cwd: &AbsolutePathBuf, skills_entries: &[SkillsListEntry],) -> Vec<ProtocolSkillMetadata> { skills_entries .iter() .find(|entry| entry.cwd.as_path() == cwd.as_path()) .map(|entry| entry.skills.clone()) .unwrap_or_default()}fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec<SkillMetadata> { skills .iter() .filter(|skill| skill.enabled) .filter_map(protocol_skill_to_core) .collect()}fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> Option<SkillMetadata> { let scope = serde_json::to_value(skill.scope) .and_then(serde_json::from_value) .inspect_err(|err| { tracing::warn!( skill_name = %skill.name, %err, "Failed to map app-server skill scope" ); }) .ok()?; Some(SkillMetadata { name: skill.name.clone(), description: skill.description.clone(), short_description: skill.short_description.clone(), interface: skill.interface.clone().map(|interface| SkillInterface { display_name: interface.display_name, short_description: interface.short_description, icon_small: interface.icon_small, icon_large: interface.icon_large, brand_color: interface.brand_color, default_prompt: interface.default_prompt, }), dependencies: skill .dependencies .clone() .map(|dependencies| SkillDependencies { tools: dependencies .tools .into_iter() .map(|tool| SkillToolDependency { r#type: tool.r#type, value: tool.value, description: tool.description, transport: tool.transport, command: tool.command, url: tool.url, }) .collect(), }), policy: None, path_to_skills_md: skill.path.clone(), scope, plugin_id: None, })}pub(crate) fn collect_tool_mentions( text: &str, mention_paths: &HashMap<String, String>,) -> ToolMentions { let mut mentions = extract_tool_mentions_from_text(text); for (name, path) in mention_paths { if mentions.names.contains(name) { mentions.linked_paths.insert(name.clone(), path.clone()); } } mentions}pub(crate) fn find_skill_mentions_with_tool_mentions( mentions: &ToolMentions, skills: &[SkillMetadata],) -> Vec<SkillMetadata> { let mention_skill_paths: HashSet<&str> = mentions .linked_paths .values() .filter(|path| is_skill_path(path)) .map(|path| normalize_skill_path(path)) .collect(); let mut seen_names = HashSet::new(); let mut seen_paths = HashSet::new(); let mut matches: Vec<SkillMetadata> = Vec::new(); for skill in skills { if seen_paths.contains(&skill.path_to_skills_md) { continue; } let path_str = skill.path_to_skills_md.to_string_lossy(); if mention_skill_paths.contains(path_str.as_ref()) { seen_paths.insert(skill.path_to_skills_md.clone()); seen_names.insert(skill.name.clone()); matches.push(skill.clone()); } } for skill in skills { if seen_paths.contains(&skill.path_to_skills_md) { continue; } if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) { seen_paths.insert(skill.path_to_skills_md.clone()); matches.push(skill.clone()); } } matches}pub(crate) fn find_app_mentions( mentions: &ToolMentions, apps: &[AppInfo], skill_names_lower: &HashSet<String>,) -> Vec<AppInfo> { let mut explicit_names = HashSet::new(); let mut selected_ids = HashSet::new(); for (name, path) in &mentions.linked_paths { if let Some(connector_id) = app_id_from_path(path) { explicit_names.insert(name.clone()); selected_ids.insert(connector_id.to_string()); } } let mut slug_counts: HashMap<String, usize> = HashMap::new(); for app in apps.iter().filter(|app| is_app_mentionable(app)) { let slug = codex_connectors::metadata::connector_mention_slug(app); *slug_counts.entry(slug).or_insert(0) += 1; } for app in apps.iter().filter(|app| is_app_mentionable(app)) { let slug = codex_connectors::metadata::connector_mention_slug(app); let slug_count = slug_counts.get(&slug).copied().unwrap_or(0); if mentions.names.contains(&slug) && !explicit_names.contains(&slug) && slug_count == 1 && !skill_names_lower.contains(&slug) { selected_ids.insert(app.id.clone()); } } apps.iter() .filter(|app| is_app_mentionable(app) && selected_ids.contains(&app.id)) .cloned() .collect()}pub(crate) fn is_app_mentionable(app: &AppInfo) -> bool { app.is_accessible && app.is_enabled}pub(crate) struct ToolMentions { names: HashSet<String>, linked_paths: HashMap<String, String>,}fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { extract_tool_mentions_from_text_with_sigil(text, TOOL_MENTION_SIGIL)}fn extract_tool_mentions_from_text_with_sigil(text: &str, sigil: char) -> ToolMentions { let text_bytes = text.as_bytes(); let mut names: HashSet<String> = HashSet::new(); let mut linked_paths: HashMap<String, String> = HashMap::new(); let mut index = 0; while index < text_bytes.len() { let byte = text_bytes[index]; if byte == b'[' && let Some((name, path, end_index)) = parse_linked_tool_mention(text, text_bytes, index, sigil) { if !is_common_env_var(name) { if is_skill_path(path) { names.insert(name.to_string()); } linked_paths .entry(name.to_string()) .or_insert(path.to_string()); } index = end_index; continue; } if byte != sigil as u8 { index += 1; continue; } let name_start = index + 1; let Some(first_name_byte) = text_bytes.get(name_start) else { index += 1; continue; }; if !is_mention_name_char(*first_name_byte) { index += 1; continue; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) && is_mention_name_char(*next_byte) { name_end += 1; } let name = &text[name_start..name_end]; if !is_common_env_var(name) { names.insert(name.to_string()); } index = name_end; } ToolMentions { names, linked_paths, }}fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, sigil: char,) -> Option<(&'a str, &'a str, usize)> { let sigil_index = start + 1; if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) && is_mention_name_char(*next_byte) { name_end += 1; } if text_bytes.get(name_end) != Some(&b']') { return None; } let mut path_start = name_end + 1; while let Some(next_byte) = text_bytes.get(path_start) && next_byte.is_ascii_whitespace() { path_start += 1; } if text_bytes.get(path_start) != Some(&b'(') { return None; } let mut path_end = path_start + 1; while let Some(next_byte) = text_bytes.get(path_end) && *next_byte != b')' { path_end += 1; } if text_bytes.get(path_end) != Some(&b')') { return None; } let path = text[path_start + 1..path_end].trim(); if path.is_empty() { return None; } let name = &text[name_start..name_end]; Some((name, path, path_end + 1))}fn is_common_env_var(name: &str) -> bool { let upper = name.to_ascii_uppercase(); matches!( upper.as_str(), "PATH" | "HOME" | "USER" | "SHELL" | "PWD" | "TMPDIR" | "TEMP" | "TMP" | "LANG" | "TERM" | "XDG_CONFIG_HOME" )}fn is_mention_name_char(byte: u8) -> bool { matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')}fn is_skill_path(path: &str) -> bool { !path.starts_with("app://") && !path.starts_with("mcp://") && !path.starts_with("plugin://")}fn normalize_skill_path(path: &str) -> &str { path.strip_prefix("skill://").unwrap_or(path)}fn app_id_from_path(path: &str) -> Option<&str> { path.strip_prefix("app://") .filter(|value| !value.is_empty())}#[cfg(test)]mod tests { use super::*; use pretty_assertions::assert_eq; fn app(id: &str, name: &str) -> AppInfo { AppInfo { id: id.to_string(), name: name.to_string(), description: None, logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: None, is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), } } #[test] fn find_app_mentions_requires_accessible_enabled_apps_for_slugs() { let apps = vec![ app("google_drive", "Google Drive"), AppInfo { is_accessible: false, ..app("arabica_uae", "% Arabica UAE") }, AppInfo { is_enabled: false, ..app("linear", "Linear") }, ]; let mentions = collect_tool_mentions("$google-drive $arabica-uae $linear", &HashMap::new()); assert_eq!( find_app_mentions(&mentions, &apps, &HashSet::new()), vec![apps[0].clone()] ); } #[test] fn find_app_mentions_requires_accessible_enabled_apps_for_bound_paths() { let apps = vec![ app("google_drive", "Google Drive"), AppInfo { is_accessible: false, ..app("arabica_uae", "% Arabica UAE") }, AppInfo { is_enabled: false, ..app("linear", "Linear") }, ]; let mention_paths = HashMap::from([ ("google-drive".to_string(), "app://google_drive".to_string()), ("arabica-uae".to_string(), "app://arabica_uae".to_string()), ("linear".to_string(), "app://linear".to_string()), ]); let mentions = collect_tool_mentions("$google-drive $arabica-uae $linear", &mention_paths); assert_eq!( find_app_mentions(&mentions, &apps, &HashSet::new()), vec![apps[0].clone()] ); }}