feat: Support listing and selecting skills via $ or /skills (#7506)
List/Select skills with $-mention or /skills
This commit is contained in:
parent
231ff19ca2
commit
9a50a04400
11 changed files with 505 additions and 97 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
142
codex-rs/tui/src/bottom_pane/skill_popup.rs
Normal file
142
codex-rs/tui/src/bottom_pane/skill_popup.rs
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue