feat: Support listing and selecting skills via $ or /skills (#7506)

List/Select skills with $-mention or /skills
This commit is contained in:
xl-openai 2025-12-03 15:12:46 -08:00 committed by GitHub
parent 231ff19ca2
commit 9a50a04400
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 505 additions and 97 deletions

View file

@ -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);
}

View file

@ -17,5 +17,26 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
));
}
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"))
}

View file

@ -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<Vec<SkillMetadata>>,
}
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,

View file

@ -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 =

View file

@ -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<Vec<(String, String)>>,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
}
/// 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<Vec<SkillMetadata>>) {
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<String> {
@ -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<usize> = 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<String> {
/// - 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<String> {
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('@') {
return right_at.or(left_at);
if after_cursor.starts_with(prefix) {
return right_prefixed.or(left_prefixed);
}
left_at.or(right_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<String> {
Self::current_prefixed_token(textarea, '@', false)
}
fn current_skill_token(&self) -> Option<String> {
if !self.skills_enabled() {
return None;
}
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();

View file

@ -31,8 +31,11 @@ pub(crate) struct CommandPopup {
}
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>) -> Self {
let builtins = built_in_slash_commands();
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, 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<String> = 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<String> = 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"));

View file

@ -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<Vec<SkillMetadata>>,
}
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);

View file

@ -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<SkillMetadata>,
state: ScrollState,
}
impl SkillPopup {
pub(crate) fn new(skills: Vec<SkillMetadata>) -> Self {
Self {
query: String::new(),
skills,
state: ScrollState::new(),
}
}
pub(crate) fn set_skills(&mut self, skills: Vec<SkillMetadata>) {
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<usize> {
self.filtered().into_iter().map(|(idx, _, _)| idx).collect()
}
fn rows_from_matches(
&self,
matches: Vec<(usize, Option<Vec<usize>>, i32)>,
) -> Vec<GenericDisplayRow> {
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<Vec<usize>>, i32)> {
let filter = self.query.trim();
let mut out: Vec<(usize, Option<Vec<usize>>, 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",
);
}
}

View file

@ -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<AuthManager>,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) skills: Option<Vec<SkillMetadata>>,
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();
}

View file

@ -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 {

View file

@ -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