diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index ee3148e7e7b..43a0034801a 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -516,8 +516,9 @@ mod tests { ) .unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md")); let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; let expected = format!( - "base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})" + "base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}" ); assert_eq!(res, expected); } @@ -535,8 +536,9 @@ mod tests { dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path()) .unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md")); let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; let expected = format!( - "## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})" + "## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}" ); assert_eq!(res, expected); } diff --git a/codex-rs/core/src/skills/render.rs b/codex-rs/core/src/skills/render.rs index d547e21c283..b6645654591 100644 --- a/codex-rs/core/src/skills/render.rs +++ b/codex-rs/core/src/skills/render.rs @@ -17,5 +17,26 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option { )); } + lines.push( + r###"- Discovery: Available skills are listed in project docs and may also appear in a runtime "## Skills" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. + 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 4) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."### + .to_string(), + ); + Some(lines.join("\n")) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 409f047217e..df9e4b5d4ba 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -25,7 +25,6 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; -#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; @@ -37,6 +36,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; use codex_core::skills::load_skills; +use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -231,6 +231,8 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, + + pub(crate) skills: Option>, } impl App { @@ -285,6 +287,12 @@ impl App { } } + let skills = if config.features.enabled(Feature::Skills) { + Some(skills_outcome.skills.clone()) + } else { + None + }; + let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match resume_selection { @@ -298,6 +306,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + skills: skills.clone(), is_first_run, }; ChatWidget::new(init, conversation_manager.clone()) @@ -322,6 +331,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + skills: skills.clone(), is_first_run, }; ChatWidget::new_from_existing( @@ -357,6 +367,7 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills, }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -476,6 +487,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); @@ -523,6 +535,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = ChatWidget::new_from_existing( @@ -1147,6 +1160,7 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills: None, } } @@ -1184,6 +1198,7 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills: None, }, rx, op_rx, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 677f29abdda..a7c86116140 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -347,6 +347,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e9343fd8a4d..4529b665663 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -29,6 +29,7 @@ use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; +use super::skill_popup::SkillPopup; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::expand_custom_prompt; use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; @@ -53,6 +54,7 @@ use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; use crate::ui_consts::LIVE_PREFIX_COLS; +use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; @@ -115,6 +117,8 @@ pub(crate) struct ChatComposer { footer_hint_override: Option>, context_window_percent: Option, context_window_used_tokens: Option, + skills: Option>, + dismissed_skill_popup_token: Option, } /// Popup state – at most one can be visible at any time. @@ -122,6 +126,7 @@ enum ActivePopup { None, Command(CommandPopup), File(FileSearchPopup), + Skill(SkillPopup), } const FOOTER_SPACING_HEIGHT: u16 = 0; @@ -160,12 +165,18 @@ impl ChatComposer { footer_hint_override: None, context_window_percent: None, context_window_used_tokens: None, + skills: None, + dismissed_skill_popup_token: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); this } + pub fn set_skill_mentions(&mut self, skills: Option>) { + self.skills = skills; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -178,6 +189,9 @@ impl ChatComposer { Constraint::Max(popup.calculate_required_height(area.width)) } ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), + ActivePopup::Skill(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } ActivePopup::None => Constraint::Max(footer_total_height), }; let [composer_rect, popup_rect] = @@ -234,14 +248,7 @@ impl ChatComposer { } // Explicit paste events should not trigger Enter suppression. self.paste_burst.clear_after_explicit_paste(); - // Keep popup sync consistent with key handling: prefer slash popup; only - // sync file popup when slash popup is NOT active. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); true } @@ -286,8 +293,7 @@ impl ChatComposer { self.attached_images.clear(); self.textarea.set_text(&text); self.textarea.set_cursor(0); - self.sync_command_popup(); - self.sync_file_search_popup(); + self.sync_popups(); } pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { @@ -377,8 +383,7 @@ impl ChatComposer { pub(crate) fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); - self.sync_command_popup(); - self.sync_file_search_popup(); + self.sync_popups(); } /// Handle a key event coming from the main UI. @@ -386,16 +391,12 @@ impl ChatComposer { let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), ActivePopup::None => self.handle_key_event_without_popup(key_event), }; // Update (or hide/show) popup after processing the key. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); result } @@ -465,6 +466,11 @@ impl ChatComposer { let mut cursor_target: Option = None; match sel { CommandItem::Builtin(cmd) => { + if cmd == SlashCommand::Skills { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + let starts_with_cmd = first_line .trim_start() .starts_with(&format!("/{}", cmd.command())); @@ -714,23 +720,101 @@ impl ChatComposer { } } + fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + let ActivePopup::Skill(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some(tok) = self.current_skill_token() { + self.dismissed_skill_popup_token = Some(tok); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let selected = popup.selected_skill().map(|skill| skill.name.clone()); + if let Some(name) = selected { + self.insert_selected_skill(&name); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + } + } + fn is_image_path(path: &str) -> bool { let lower = path.to_ascii_lowercase(); lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } - /// Extract the `@token` that the cursor is currently positioned on, if any. + fn skills_enabled(&self) -> bool { + self.skills.as_ref().is_some_and(|s| !s.is_empty()) + } + + /// Extract a token prefixed with `prefix` under the cursor, if any. /// - /// The returned string **does not** include the leading `@`. + /// The returned string **does not** include the prefix. /// /// Behavior: /// - The cursor may be anywhere *inside* the token (including on the - /// leading `@`). It does **not** need to be at the end of the line. + /// leading prefix). It does **not** need to be at the end of the line. /// - A token is delimited by ASCII whitespace (space, tab, newline). - /// - If the token under the cursor starts with `@`, that token is - /// returned without the leading `@`. This includes the case where the - /// token is just "@" (empty query), which is used to trigger a UI hint - fn current_at_token(textarea: &TextArea) -> Option { + /// - If the token under the cursor starts with `prefix`, that token is + /// returned without the leading prefix. When `allow_empty` is true, a + /// lone prefix character yields `Some(String::new())` to surface hints. + fn current_prefixed_token( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + ) -> Option { let cursor_offset = textarea.cursor(); let text = textarea.text(); @@ -799,26 +883,40 @@ impl ChatComposer { None }; - let left_at = token_left - .filter(|t| t.starts_with('@')) - .map(|t| t[1..].to_string()); - let right_at = token_right - .filter(|t| t.starts_with('@')) - .map(|t| t[1..].to_string()); + let prefix_str = prefix.to_string(); + let left_match = token_left.filter(|t| t.starts_with(prefix)); + let right_match = token_right.filter(|t| t.starts_with(prefix)); + + let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); + let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); if at_whitespace { - if right_at.is_some() { - return right_at; + if right_prefixed.is_some() { + return right_prefixed; } - if token_left.is_some_and(|t| t == "@") { - return None; + if token_left.is_some_and(|t| t == prefix_str) { + return allow_empty.then(String::new); } - return left_at; + return left_prefixed; + } + if after_cursor.starts_with(prefix) { + return right_prefixed.or(left_prefixed); } - if after_cursor.starts_with('@') { - return right_at.or(left_at); + left_prefixed.or(right_prefixed) + } + + /// Extract the `@token` that the cursor is currently positioned on, if any. + /// + /// The returned string **does not** include the leading `@`. + fn current_at_token(textarea: &TextArea) -> Option { + Self::current_prefixed_token(textarea, '@', false) + } + + fn current_skill_token(&self) -> Option { + if !self.skills_enabled() { + return None; } - left_at.or(right_at) + Self::current_prefixed_token(&self.textarea, '$', true) } /// Replace the active `@token` (the one under the cursor) with `path`. @@ -872,6 +970,41 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + fn insert_selected_skill(&mut self, skill_name: &str) { + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + let inserted = format!("${skill_name}"); + + let mut new_text = + String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); + new_text.push_str(&text[..start_idx]); + new_text.push_str(&inserted); + new_text.push(' '); + new_text.push_str(&text[end_idx..]); + + self.textarea.set_text(&new_text); + let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); + self.textarea.set_cursor(new_cursor); + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -1075,14 +1208,7 @@ impl ChatComposer { // Mirror insert_str() behavior so popups stay in sync when a // pending fast char flushes as normal typed input. self.textarea.insert_str(ch.to_string().as_str()); - // Keep popup sync consistent with key handling: prefer slash popup; only - // sync file popup when slash popup is NOT active. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); true } FlushResult::None => false, @@ -1423,10 +1549,49 @@ impl ChatComposer { .map(|items| if items.is_empty() { 0 } else { 1 }) } + fn sync_popups(&mut self) { + let file_token = Self::current_at_token(&self.textarea); + let skill_token = self.current_skill_token(); + + let allow_command_popup = file_token.is_none() && skill_token.is_none(); + self.sync_command_popup(allow_command_popup); + + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + self.dismissed_skill_popup_token = None; + return; + } + + if let Some(token) = skill_token { + self.sync_skill_popup(token); + return; + } + self.dismissed_skill_popup_token = None; + + if let Some(token) = file_token { + self.sync_file_search_popup(token); + return; + } + + self.dismissed_file_popup_token = None; + if matches!( + self.active_popup, + ActivePopup::File(_) | ActivePopup::Skill(_) + ) { + self.active_popup = ActivePopup::None; + } + } + /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. - fn sync_command_popup(&mut self) { + fn sync_command_popup(&mut self, allow: bool) { + if !allow { + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.active_popup = ActivePopup::None; + } + return; + } // Determine whether the caret is inside the initial '/name' token on the first line. let text = self.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); @@ -1464,7 +1629,9 @@ impl ChatComposer { } _ => { if is_editing_slash_command_name { - let mut command_popup = CommandPopup::new(self.custom_prompts.clone()); + let skills_enabled = self.skills_enabled(); + let mut command_popup = + CommandPopup::new(self.custom_prompts.clone(), skills_enabled); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -1481,17 +1648,7 @@ impl ChatComposer { /// Synchronize `self.file_search_popup` with the current text in the textarea. /// Note this is only called when self.active_popup is NOT Command. - fn sync_file_search_popup(&mut self) { - // Determine if there is an @token underneath the cursor. - let query = match Self::current_at_token(&self.textarea) { - Some(token) => token, - None => { - self.active_popup = ActivePopup::None; - self.dismissed_file_popup_token = None; - return; - } - }; - + fn sync_file_search_popup(&mut self, query: String) { // If user dismissed popup for this exact query, don't reopen until text changes. if self.dismissed_file_popup_token.as_ref() == Some(&query) { return; @@ -1525,6 +1682,32 @@ impl ChatComposer { self.dismissed_file_popup_token = None; } + fn sync_skill_popup(&mut self, query: String) { + if self.dismissed_skill_popup_token.as_ref() == Some(&query) { + return; + } + + let skills = match self.skills.as_ref() { + Some(skills) if !skills.is_empty() => skills.clone(), + _ => { + self.active_popup = ActivePopup::None; + return; + } + }; + + match &mut self.active_popup { + ActivePopup::Skill(popup) => { + popup.set_query(&query); + popup.set_skills(skills); + } + _ => { + let mut popup = SkillPopup::new(skills); + popup.set_query(&query); + self.active_popup = ActivePopup::Skill(popup); + } + } + } + fn set_has_focus(&mut self, has_focus: bool) { self.has_focus = has_focus; } @@ -1574,6 +1757,7 @@ impl Renderable for ChatComposer { ActivePopup::None => footer_total_height, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), + ActivePopup::Skill(c) => c.calculate_required_height(width), } } @@ -1586,6 +1770,9 @@ impl Renderable for ChatComposer { ActivePopup::File(popup) => { popup.render_ref(popup_rect, buf); } + ActivePopup::Skill(popup) => { + popup.render_ref(popup_rect, buf); + } ActivePopup::None => { let footer_props = self.footer_props(); let custom_height = self.custom_footer_height(); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index d7501cebbcc..39bbfbd1822 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -31,8 +31,11 @@ pub(crate) struct CommandPopup { } impl CommandPopup { - pub(crate) fn new(mut prompts: Vec) -> Self { - let builtins = built_in_slash_commands(); + pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); @@ -232,7 +235,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -252,7 +255,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -267,7 +270,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -297,7 +300,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts); + let popup = CommandPopup::new(prompts, false); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -313,13 +316,16 @@ mod tests { #[test] fn prompt_name_collision_with_builtin_is_ignored() { // Create a prompt named like a builtin (e.g. "init"). - let popup = CommandPopup::new(vec![CustomPrompt { - name: "init".to_string(), - path: "/tmp/init.md".to_string().into(), - content: "should be ignored".to_string(), - description: None, - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "init".to_string(), + path: "/tmp/init.md".to_string().into(), + content: "should be ignored".to_string(), + description: None, + argument_hint: None, + }], + false, + ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), @@ -333,13 +339,16 @@ mod tests { #[test] fn prompt_description_uses_frontmatter_metadata() { - let popup = CommandPopup::new(vec![CustomPrompt { - name: "draftpr".to_string(), - path: "/tmp/draftpr.md".to_string().into(), - content: "body".to_string(), - description: Some("Create feature branch, commit and open draft PR.".to_string()), - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "draftpr".to_string(), + path: "/tmp/draftpr.md".to_string().into(), + content: "body".to_string(), + description: Some("Create feature branch, commit and open draft PR.".to_string()), + argument_hint: None, + }], + false, + ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!( @@ -350,13 +359,16 @@ mod tests { #[test] fn prompt_description_falls_back_when_missing() { - let popup = CommandPopup::new(vec![CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "body".to_string(), - description: None, - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "body".to_string(), + description: None, + argument_hint: None, + }], + false, + ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!(description, Some("send saved prompt")); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b4255fd9791..a0425c92d7c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -8,6 +8,7 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -27,6 +28,7 @@ mod file_search_popup; mod footer; mod list_selection_view; mod prompt_args; +mod skill_popup; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; pub(crate) use feedback_view::feedback_selection_params; @@ -87,6 +89,7 @@ pub(crate) struct BottomPaneParams { pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, pub(crate) animations_enabled: bool, + pub(crate) skills: Option>, } impl BottomPane { @@ -99,15 +102,19 @@ impl BottomPane { placeholder_text, disable_paste_burst, animations_enabled, + skills, } = params; + let mut composer = ChatComposer::new( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ); + composer.set_skill_mentions(skills); + Self { - composer: ChatComposer::new( - has_input_focus, - app_event_tx.clone(), - enhanced_keys_supported, - placeholder_text, - disable_paste_burst, - ), + composer, view_stack: Vec::new(), app_event_tx, frame_requester, @@ -578,6 +585,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.push_approval_request(exec_request()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); @@ -599,6 +607,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Create an approval modal (active view). @@ -631,6 +640,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Start a running task so the status indicator is active above the composer. @@ -697,6 +707,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Begin a task: show initial status. @@ -723,6 +734,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Activate spinner (status view replaces composer) with no live ring. @@ -753,6 +765,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.set_task_running(true); @@ -780,6 +793,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.set_task_running(true); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs new file mode 100644 index 00000000000..74c1b137ca1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -0,0 +1,142 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use crate::render::Insets; +use crate::render::RectExt; +use codex_common::fuzzy_match::fuzzy_match; +use codex_core::skills::model::SkillMetadata; + +pub(crate) struct SkillPopup { + query: String, + skills: Vec, + state: ScrollState, +} + +impl SkillPopup { + pub(crate) fn new(skills: Vec) -> Self { + Self { + query: String::new(), + skills, + state: ScrollState::new(), + } + } + + pub(crate) fn set_skills(&mut self, skills: Vec) { + self.skills = skills; + self.clamp_selection(); + } + + pub(crate) fn set_query(&mut self, query: &str) { + self.query = query.to_string(); + self.clamp_selection(); + } + + pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { + let rows = self.rows_from_matches(self.filtered()); + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + } + + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn move_down(&mut self) { + let len = self.filtered_items().len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { + let matches = self.filtered_items(); + let idx = self.state.selected_idx?; + let skill_idx = matches.get(idx)?; + self.skills.get(*skill_idx) + } + + fn clamp_selection(&mut self) { + let len = self.filtered_items().len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn filtered_items(&self) -> Vec { + self.filtered().into_iter().map(|(idx, _, _)| idx).collect() + } + + fn rows_from_matches( + &self, + matches: Vec<(usize, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(idx, indices, _score)| { + let skill = &self.skills[idx]; + let slug = skill + .path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(&skill.name); + let name = format!("{} ({slug})", skill.name); + let description = skill.description.clone(); + GenericDisplayRow { + name, + match_indices: indices, + is_current: false, + display_shortcut: None, + description: Some(description), + } + }) + .collect() + } + + fn filtered(&self) -> Vec<(usize, Option>, i32)> { + let filter = self.query.trim(); + let mut out: Vec<(usize, Option>, i32)> = Vec::new(); + + if filter.is_empty() { + for (idx, _skill) in self.skills.iter().enumerate() { + out.push((idx, None, 0)); + } + return out; + } + + for (idx, skill) in self.skills.iter().enumerate() { + if let Some((indices, score)) = fuzzy_match(&skill.name, filter) { + out.push((idx, Some(indices), score)); + } + } + + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = &self.skills[a.0].name; + let bn = &self.skills[b.0].name; + an.cmp(bn) + }) + }); + + out + } +} + +impl WidgetRef for SkillPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let rows = self.rows_from_matches(self.filtered()); + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no skills", + ); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f18725d8bd1..2ae53bc0c22 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -55,6 +55,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::parse_command::ParsedCommand; @@ -256,6 +257,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, + pub(crate) skills: Option>, pub(crate) is_first_run: bool, } @@ -1231,6 +1233,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + skills, is_first_run, } = common; let mut rng = rand::rng(); @@ -1249,6 +1252,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + skills, }), active_cell: None, config: config.clone(), @@ -1307,6 +1311,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + skills, .. } = common; let mut rng = rand::rng(); @@ -1327,6 +1332,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + skills, }), active_cell: None, config: config.clone(), @@ -1545,6 +1551,9 @@ impl ChatWidget { SlashCommand::Mention => { self.insert_str("@"); } + SlashCommand::Skills => { + self.insert_str("$"); + } SlashCommand::Status => { self.add_status_output(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a4d21608c81..699435a71ac 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -355,6 +355,7 @@ async fn helpers_are_available_and_do_not_panic() { enhanced_keys_supported: false, auth_manager, feedback: codex_feedback::CodexFeedback::new(), + skills: None, is_first_run: true, }; let mut w = ChatWidget::new(init, conversation_manager); @@ -380,6 +381,7 @@ fn make_chatwidget_manual() -> ( placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: cfg.animations, + skills: None, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let widget = ChatWidget { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 47b330cba6c..e0c676812c8 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + Skills, Review, New, Resume, @@ -46,6 +47,7 @@ impl SlashCommand { SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", + SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", @@ -76,6 +78,7 @@ impl SlashCommand { | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Mention + | SlashCommand::Skills | SlashCommand::Status | SlashCommand::Mcp | SlashCommand::Feedback