mirror of
https://github.com/openai/codex.git
synced 2026-11-02 16:20:35 +00:00
feat: Support listing and selecting skills via $ or /skills (#7506)
List/Select skills with $-mention or /skills
This commit is contained in:
1 parent
231ff19ca2
commit
9a50a04400
11 files changed
+505
-97
No files matched your search
@@ -516,8 +516,9 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md"));
|
.unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md"));
|
||||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
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!(
|
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);
|
assert_eq!(res, expected);
|
||||||
}
|
}
|
||||||
@@ -535,8 +536,9 @@ mod tests {
|
|||||||
dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path())
|
dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path())
|
||||||
.unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md"));
|
.unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md"));
|
||||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
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!(
|
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);
|
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"))
|
Some(lines.join("\n"))
|
||||||
}
|
}
|
||||||
+16
-1
@@ -25,7 +25,6 @@ use codex_core::AuthManager;
|
|||||||
use codex_core::ConversationManager;
|
use codex_core::ConversationManager;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config::edit::ConfigEditsBuilder;
|
use codex_core::config::edit::ConfigEditsBuilder;
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
use codex_core::features::Feature;
|
use codex_core::features::Feature;
|
||||||
use codex_core::model_family::find_family_for_model;
|
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;
|
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::SessionSource;
|
||||||
use codex_core::protocol::TokenUsage;
|
use codex_core::protocol::TokenUsage;
|
||||||
use codex_core::skills::load_skills;
|
use codex_core::skills::load_skills;
|
||||||
|
use codex_core::skills::model::SkillMetadata;
|
||||||
use codex_protocol::ConversationId;
|
use codex_protocol::ConversationId;
|
||||||
use codex_protocol::openai_models::ModelUpgrade;
|
use codex_protocol::openai_models::ModelUpgrade;
|
||||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
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.
|
// One-shot suppression of the next world-writable scan after user confirmation.
|
||||||
skip_world_writable_scan_once: bool,
|
skip_world_writable_scan_once: bool,
|
||||||
|
|
||||||
|
pub(crate) skills: Option<Vec<SkillMetadata>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
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 enhanced_keys_supported = tui.enhanced_keys_supported();
|
||||||
|
|
||||||
let mut chat_widget = match resume_selection {
|
let mut chat_widget = match resume_selection {
|
||||||
@@ -298,6 +306,7 @@ impl App {
|
|||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
auth_manager: auth_manager.clone(),
|
auth_manager: auth_manager.clone(),
|
||||||
feedback: feedback.clone(),
|
feedback: feedback.clone(),
|
||||||
|
skills: skills.clone(),
|
||||||
is_first_run,
|
is_first_run,
|
||||||
};
|
};
|
||||||
ChatWidget::new(init, conversation_manager.clone())
|
ChatWidget::new(init, conversation_manager.clone())
|
||||||
@@ -322,6 +331,7 @@ impl App {
|
|||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
auth_manager: auth_manager.clone(),
|
auth_manager: auth_manager.clone(),
|
||||||
feedback: feedback.clone(),
|
feedback: feedback.clone(),
|
||||||
|
skills: skills.clone(),
|
||||||
is_first_run,
|
is_first_run,
|
||||||
};
|
};
|
||||||
ChatWidget::new_from_existing(
|
ChatWidget::new_from_existing(
|
||||||
@@ -357,6 +367,7 @@ impl App {
|
|||||||
pending_update_action: None,
|
pending_update_action: None,
|
||||||
suppress_shutdown_complete: false,
|
suppress_shutdown_complete: false,
|
||||||
skip_world_writable_scan_once: 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.
|
// 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,
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||||
auth_manager: self.auth_manager.clone(),
|
auth_manager: self.auth_manager.clone(),
|
||||||
feedback: self.feedback.clone(),
|
feedback: self.feedback.clone(),
|
||||||
|
skills: self.skills.clone(),
|
||||||
is_first_run: false,
|
is_first_run: false,
|
||||||
};
|
};
|
||||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||||
@@ -523,6 +535,7 @@ impl App {
|
|||||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||||
auth_manager: self.auth_manager.clone(),
|
auth_manager: self.auth_manager.clone(),
|
||||||
feedback: self.feedback.clone(),
|
feedback: self.feedback.clone(),
|
||||||
|
skills: self.skills.clone(),
|
||||||
is_first_run: false,
|
is_first_run: false,
|
||||||
};
|
};
|
||||||
self.chat_widget = ChatWidget::new_from_existing(
|
self.chat_widget = ChatWidget::new_from_existing(
|
||||||
@@ -1147,6 +1160,7 @@ mod tests {
|
|||||||
pending_update_action: None,
|
pending_update_action: None,
|
||||||
suppress_shutdown_complete: false,
|
suppress_shutdown_complete: false,
|
||||||
skip_world_writable_scan_once: false,
|
skip_world_writable_scan_once: false,
|
||||||
|
skills: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1184,6 +1198,7 @@ mod tests {
|
|||||||
pending_update_action: None,
|
pending_update_action: None,
|
||||||
suppress_shutdown_complete: false,
|
suppress_shutdown_complete: false,
|
||||||
skip_world_writable_scan_once: false,
|
skip_world_writable_scan_once: false,
|
||||||
|
skills: None,
|
||||||
},
|
},
|
||||||
rx,
|
rx,
|
||||||
op_rx,
|
op_rx,
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ impl App {
|
|||||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||||
auth_manager: self.auth_manager.clone(),
|
auth_manager: self.auth_manager.clone(),
|
||||||
feedback: self.feedback.clone(),
|
feedback: self.feedback.clone(),
|
||||||
|
skills: self.skills.clone(),
|
||||||
is_first_run: false,
|
is_first_run: false,
|
||||||
};
|
};
|
||||||
self.chat_widget =
|
self.chat_widget =
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use super::footer::reset_mode_after_activity;
|
|||||||
use super::footer::toggle_shortcut_mode;
|
use super::footer::toggle_shortcut_mode;
|
||||||
use super::paste_burst::CharDecision;
|
use super::paste_burst::CharDecision;
|
||||||
use super::paste_burst::PasteBurst;
|
use super::paste_burst::PasteBurst;
|
||||||
|
use super::skill_popup::SkillPopup;
|
||||||
use crate::bottom_pane::paste_burst::FlushResult;
|
use crate::bottom_pane::paste_burst::FlushResult;
|
||||||
use crate::bottom_pane::prompt_args::expand_custom_prompt;
|
use crate::bottom_pane::prompt_args::expand_custom_prompt;
|
||||||
use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args;
|
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::clipboard_paste::pasted_image_format;
|
||||||
use crate::history_cell;
|
use crate::history_cell;
|
||||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||||
|
use codex_core::skills::model::SkillMetadata;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -115,6 +117,8 @@ pub(crate) struct ChatComposer {
|
|||||||
footer_hint_override: Option<Vec<(String, String)>>,
|
footer_hint_override: Option<Vec<(String, String)>>,
|
||||||
context_window_percent: Option<i64>,
|
context_window_percent: Option<i64>,
|
||||||
context_window_used_tokens: 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.
|
/// Popup state – at most one can be visible at any time.
|
||||||
@@ -122,6 +126,7 @@ enum ActivePopup {
|
|||||||
None,
|
None,
|
||||||
Command(CommandPopup),
|
Command(CommandPopup),
|
||||||
File(FileSearchPopup),
|
File(FileSearchPopup),
|
||||||
|
Skill(SkillPopup),
|
||||||
}
|
}
|
||||||
|
|
||||||
const FOOTER_SPACING_HEIGHT: u16 = 0;
|
const FOOTER_SPACING_HEIGHT: u16 = 0;
|
||||||
@@ -160,12 +165,18 @@ impl ChatComposer {
|
|||||||
footer_hint_override: None,
|
footer_hint_override: None,
|
||||||
context_window_percent: None,
|
context_window_percent: None,
|
||||||
context_window_used_tokens: None,
|
context_window_used_tokens: None,
|
||||||
|
skills: None,
|
||||||
|
dismissed_skill_popup_token: None,
|
||||||
};
|
};
|
||||||
// Apply configuration via the setter to keep side-effects centralized.
|
// Apply configuration via the setter to keep side-effects centralized.
|
||||||
this.set_disable_paste_burst(disable_paste_burst);
|
this.set_disable_paste_burst(disable_paste_burst);
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_skill_mentions(&mut self, skills: Option<Vec<SkillMetadata>>) {
|
||||||
|
self.skills = skills;
|
||||||
|
}
|
||||||
|
|
||||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||||
let footer_props = self.footer_props();
|
let footer_props = self.footer_props();
|
||||||
let footer_hint_height = self
|
let footer_hint_height = self
|
||||||
@@ -178,6 +189,9 @@ impl ChatComposer {
|
|||||||
Constraint::Max(popup.calculate_required_height(area.width))
|
Constraint::Max(popup.calculate_required_height(area.width))
|
||||||
}
|
}
|
||||||
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
|
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),
|
ActivePopup::None => Constraint::Max(footer_total_height),
|
||||||
};
|
};
|
||||||
let [composer_rect, popup_rect] =
|
let [composer_rect, popup_rect] =
|
||||||
@@ -234,14 +248,7 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
// Explicit paste events should not trigger Enter suppression.
|
// Explicit paste events should not trigger Enter suppression.
|
||||||
self.paste_burst.clear_after_explicit_paste();
|
self.paste_burst.clear_after_explicit_paste();
|
||||||
// Keep popup sync consistent with key handling: prefer slash popup; only
|
self.sync_popups();
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +293,7 @@ impl ChatComposer {
|
|||||||
self.attached_images.clear();
|
self.attached_images.clear();
|
||||||
self.textarea.set_text(&text);
|
self.textarea.set_text(&text);
|
||||||
self.textarea.set_cursor(0);
|
self.textarea.set_cursor(0);
|
||||||
self.sync_command_popup();
|
self.sync_popups();
|
||||||
self.sync_file_search_popup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
|
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) {
|
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||||
self.textarea.insert_str(text);
|
self.textarea.insert_str(text);
|
||||||
self.sync_command_popup();
|
self.sync_popups();
|
||||||
self.sync_file_search_popup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a key event coming from the main UI.
|
/// Handle a key event coming from the main UI.
|
||||||
@@ -386,16 +391,12 @@ impl ChatComposer {
|
|||||||
let result = match &mut self.active_popup {
|
let result = match &mut self.active_popup {
|
||||||
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
||||||
ActivePopup::File(_) => self.handle_key_event_with_file_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),
|
ActivePopup::None => self.handle_key_event_without_popup(key_event),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update (or hide/show) popup after processing the key.
|
// Update (or hide/show) popup after processing the key.
|
||||||
self.sync_command_popup();
|
self.sync_popups();
|
||||||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
|
||||||
self.dismissed_file_popup_token = None;
|
|
||||||
} else {
|
|
||||||
self.sync_file_search_popup();
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -465,6 +466,11 @@ impl ChatComposer {
|
|||||||
let mut cursor_target: Option<usize> = None;
|
let mut cursor_target: Option<usize> = None;
|
||||||
match sel {
|
match sel {
|
||||||
CommandItem::Builtin(cmd) => {
|
CommandItem::Builtin(cmd) => {
|
||||||
|
if cmd == SlashCommand::Skills {
|
||||||
|
self.textarea.set_text("");
|
||||||
|
return (InputResult::Command(cmd), true);
|
||||||
|
}
|
||||||
|
|
||||||
let starts_with_cmd = first_line
|
let starts_with_cmd = first_line
|
||||||
.trim_start()
|
.trim_start()
|
||||||
.starts_with(&format!("/{}", cmd.command()));
|
.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 {
|
fn is_image_path(path: &str) -> bool {
|
||||||
let lower = path.to_ascii_lowercase();
|
let lower = path.to_ascii_lowercase();
|
||||||
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
|
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:
|
/// Behavior:
|
||||||
/// - The cursor may be anywhere *inside* the token (including on the
|
/// - 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).
|
/// - A token is delimited by ASCII whitespace (space, tab, newline).
|
||||||
/// - If the token under the cursor starts with `@`, that token is
|
/// - If the token under the cursor starts with `prefix`, that token is
|
||||||
/// returned without the leading `@`. This includes the case where the
|
/// returned without the leading prefix. When `allow_empty` is true, a
|
||||||
/// token is just "@" (empty query), which is used to trigger a UI hint
|
/// lone prefix character yields `Some(String::new())` to surface hints.
|
||||||
fn current_at_token(textarea: &TextArea) -> Option<String> {
|
fn current_prefixed_token(
|
||||||
|
textarea: &TextArea,
|
||||||
|
prefix: char,
|
||||||
|
allow_empty: bool,
|
||||||
|
) -> Option<String> {
|
||||||
let cursor_offset = textarea.cursor();
|
let cursor_offset = textarea.cursor();
|
||||||
let text = textarea.text();
|
let text = textarea.text();
|
||||||
|
|
||||||
@@ -799,26 +883,40 @@ impl ChatComposer {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let left_at = token_left
|
let prefix_str = prefix.to_string();
|
||||||
.filter(|t| t.starts_with('@'))
|
let left_match = token_left.filter(|t| t.starts_with(prefix));
|
||||||
.map(|t| t[1..].to_string());
|
let right_match = token_right.filter(|t| t.starts_with(prefix));
|
||||||
let right_at = token_right
|
|
||||||
.filter(|t| t.starts_with('@'))
|
let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string());
|
||||||
.map(|t| t[1..].to_string());
|
let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string());
|
||||||
|
|
||||||
if at_whitespace {
|
if at_whitespace {
|
||||||
if right_at.is_some() {
|
if right_prefixed.is_some() {
|
||||||
return right_at;
|
return right_prefixed;
|
||||||
}
|
}
|
||||||
if token_left.is_some_and(|t| t == "@") {
|
if token_left.is_some_and(|t| t == prefix_str) {
|
||||||
return None;
|
return allow_empty.then(String::new);
|
||||||
}
|
}
|
||||||
return left_at;
|
return left_prefixed;
|
||||||
}
|
}
|
||||||
if after_cursor.starts_with('@') {
|
if after_cursor.starts_with(prefix) {
|
||||||
return right_at.or(left_at);
|
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`.
|
/// Replace the active `@token` (the one under the cursor) with `path`.
|
||||||
@@ -872,6 +970,41 @@ impl ChatComposer {
|
|||||||
self.textarea.set_cursor(new_cursor);
|
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.
|
/// Handle key event when no popup is visible.
|
||||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||||
if self.handle_shortcut_overlay_key(&key_event) {
|
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
|
// Mirror insert_str() behavior so popups stay in sync when a
|
||||||
// pending fast char flushes as normal typed input.
|
// pending fast char flushes as normal typed input.
|
||||||
self.textarea.insert_str(ch.to_string().as_str());
|
self.textarea.insert_str(ch.to_string().as_str());
|
||||||
// Keep popup sync consistent with key handling: prefer slash popup; only
|
self.sync_popups();
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
FlushResult::None => false,
|
FlushResult::None => false,
|
||||||
@@ -1423,10 +1549,49 @@ impl ChatComposer {
|
|||||||
.map(|items| if items.is_empty() { 0 } else { 1 })
|
.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
|
/// Synchronize `self.command_popup` with the current text in the
|
||||||
/// textarea. This must be called after every modification that can change
|
/// textarea. This must be called after every modification that can change
|
||||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
/// 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.
|
// Determine whether the caret is inside the initial '/name' token on the first line.
|
||||||
let text = self.textarea.text();
|
let text = self.textarea.text();
|
||||||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||||||
@@ -1464,7 +1629,9 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if is_editing_slash_command_name {
|
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());
|
command_popup.on_composer_text_change(first_line.to_string());
|
||||||
self.active_popup = ActivePopup::Command(command_popup);
|
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.
|
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
||||||
/// Note this is only called when self.active_popup is NOT Command.
|
/// Note this is only called when self.active_popup is NOT Command.
|
||||||
fn sync_file_search_popup(&mut self) {
|
fn sync_file_search_popup(&mut self, query: String) {
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// If user dismissed popup for this exact query, don't reopen until text changes.
|
// If user dismissed popup for this exact query, don't reopen until text changes.
|
||||||
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
|
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
|
||||||
return;
|
return;
|
||||||
@@ -1525,6 +1682,32 @@ impl ChatComposer {
|
|||||||
self.dismissed_file_popup_token = None;
|
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) {
|
fn set_has_focus(&mut self, has_focus: bool) {
|
||||||
self.has_focus = has_focus;
|
self.has_focus = has_focus;
|
||||||
}
|
}
|
||||||
@@ -1574,6 +1757,7 @@ impl Renderable for ChatComposer {
|
|||||||
ActivePopup::None => footer_total_height,
|
ActivePopup::None => footer_total_height,
|
||||||
ActivePopup::Command(c) => c.calculate_required_height(width),
|
ActivePopup::Command(c) => c.calculate_required_height(width),
|
||||||
ActivePopup::File(c) => c.calculate_required_height(),
|
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) => {
|
ActivePopup::File(popup) => {
|
||||||
popup.render_ref(popup_rect, buf);
|
popup.render_ref(popup_rect, buf);
|
||||||
}
|
}
|
||||||
|
ActivePopup::Skill(popup) => {
|
||||||
|
popup.render_ref(popup_rect, buf);
|
||||||
|
}
|
||||||
ActivePopup::None => {
|
ActivePopup::None => {
|
||||||
let footer_props = self.footer_props();
|
let footer_props = self.footer_props();
|
||||||
let custom_height = self.custom_footer_height();
|
let custom_height = self.custom_footer_height();
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ pub(crate) struct CommandPopup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommandPopup {
|
impl CommandPopup {
|
||||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>) -> Self {
|
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, skills_enabled: bool) -> Self {
|
||||||
let builtins = built_in_slash_commands();
|
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.
|
// Exclude prompts that collide with builtin command names and sort by name.
|
||||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||||
prompts.retain(|p| !exclude.contains(&p.name));
|
prompts.retain(|p| !exclude.contains(&p.name));
|
||||||
@@ -232,7 +235,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_includes_init_when_typing_prefix() {
|
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
|
// Simulate the composer line starting with '/in' so the popup filters
|
||||||
// matching commands by prefix.
|
// matching commands by prefix.
|
||||||
popup.on_composer_text_change("/in".to_string());
|
popup.on_composer_text_change("/in".to_string());
|
||||||
@@ -252,7 +255,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selecting_init_by_exact_match() {
|
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());
|
popup.on_composer_text_change("/init".to_string());
|
||||||
|
|
||||||
// When an exact match exists, the selected command should be that
|
// When an exact match exists, the selected command should be that
|
||||||
@@ -267,7 +270,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_is_first_suggestion_for_mo() {
|
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());
|
popup.on_composer_text_change("/mo".to_string());
|
||||||
let matches = popup.filtered_items();
|
let matches = popup.filtered_items();
|
||||||
match matches.first() {
|
match matches.first() {
|
||||||
@@ -297,7 +300,7 @@ mod tests {
|
|||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let popup = CommandPopup::new(prompts);
|
let popup = CommandPopup::new(prompts, false);
|
||||||
let items = popup.filtered_items();
|
let items = popup.filtered_items();
|
||||||
let mut prompt_names: Vec<String> = items
|
let mut prompt_names: Vec<String> = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -313,13 +316,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn prompt_name_collision_with_builtin_is_ignored() {
|
fn prompt_name_collision_with_builtin_is_ignored() {
|
||||||
// Create a prompt named like a builtin (e.g. "init").
|
// Create a prompt named like a builtin (e.g. "init").
|
||||||
let popup = CommandPopup::new(vec![CustomPrompt {
|
let popup = CommandPopup::new(
|
||||||
name: "init".to_string(),
|
vec![CustomPrompt {
|
||||||
path: "/tmp/init.md".to_string().into(),
|
name: "init".to_string(),
|
||||||
content: "should be ignored".to_string(),
|
path: "/tmp/init.md".to_string().into(),
|
||||||
description: None,
|
content: "should be ignored".to_string(),
|
||||||
argument_hint: None,
|
description: None,
|
||||||
}]);
|
argument_hint: None,
|
||||||
|
}],
|
||||||
|
false,
|
||||||
|
);
|
||||||
let items = popup.filtered_items();
|
let items = popup.filtered_items();
|
||||||
let has_collision_prompt = items.into_iter().any(|it| match it {
|
let has_collision_prompt = items.into_iter().any(|it| match it {
|
||||||
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
||||||
@@ -333,13 +339,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_description_uses_frontmatter_metadata() {
|
fn prompt_description_uses_frontmatter_metadata() {
|
||||||
let popup = CommandPopup::new(vec![CustomPrompt {
|
let popup = CommandPopup::new(
|
||||||
name: "draftpr".to_string(),
|
vec![CustomPrompt {
|
||||||
path: "/tmp/draftpr.md".to_string().into(),
|
name: "draftpr".to_string(),
|
||||||
content: "body".to_string(),
|
path: "/tmp/draftpr.md".to_string().into(),
|
||||||
description: Some("Create feature branch, commit and open draft PR.".to_string()),
|
content: "body".to_string(),
|
||||||
argument_hint: None,
|
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 rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
|
||||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -350,13 +359,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_description_falls_back_when_missing() {
|
fn prompt_description_falls_back_when_missing() {
|
||||||
let popup = CommandPopup::new(vec![CustomPrompt {
|
let popup = CommandPopup::new(
|
||||||
name: "foo".to_string(),
|
vec![CustomPrompt {
|
||||||
path: "/tmp/foo.md".to_string().into(),
|
name: "foo".to_string(),
|
||||||
content: "body".to_string(),
|
path: "/tmp/foo.md".to_string().into(),
|
||||||
description: None,
|
content: "body".to_string(),
|
||||||
argument_hint: None,
|
description: None,
|
||||||
}]);
|
argument_hint: None,
|
||||||
|
}],
|
||||||
|
false,
|
||||||
|
);
|
||||||
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
|
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
|
||||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||||
assert_eq!(description, Some("send saved prompt"));
|
assert_eq!(description, Some("send saved prompt"));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::render::renderable::Renderable;
|
|||||||
use crate::render::renderable::RenderableItem;
|
use crate::render::renderable::RenderableItem;
|
||||||
use crate::tui::FrameRequester;
|
use crate::tui::FrameRequester;
|
||||||
use bottom_pane_view::BottomPaneView;
|
use bottom_pane_view::BottomPaneView;
|
||||||
|
use codex_core::skills::model::SkillMetadata;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
@@ -27,6 +28,7 @@ mod file_search_popup;
|
|||||||
mod footer;
|
mod footer;
|
||||||
mod list_selection_view;
|
mod list_selection_view;
|
||||||
mod prompt_args;
|
mod prompt_args;
|
||||||
|
mod skill_popup;
|
||||||
pub(crate) use list_selection_view::SelectionViewParams;
|
pub(crate) use list_selection_view::SelectionViewParams;
|
||||||
mod feedback_view;
|
mod feedback_view;
|
||||||
pub(crate) use feedback_view::feedback_selection_params;
|
pub(crate) use feedback_view::feedback_selection_params;
|
||||||
@@ -87,6 +89,7 @@ pub(crate) struct BottomPaneParams {
|
|||||||
pub(crate) placeholder_text: String,
|
pub(crate) placeholder_text: String,
|
||||||
pub(crate) disable_paste_burst: bool,
|
pub(crate) disable_paste_burst: bool,
|
||||||
pub(crate) animations_enabled: bool,
|
pub(crate) animations_enabled: bool,
|
||||||
|
pub(crate) skills: Option<Vec<SkillMetadata>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BottomPane {
|
impl BottomPane {
|
||||||
@@ -99,15 +102,19 @@ impl BottomPane {
|
|||||||
placeholder_text,
|
placeholder_text,
|
||||||
disable_paste_burst,
|
disable_paste_burst,
|
||||||
animations_enabled,
|
animations_enabled,
|
||||||
|
skills,
|
||||||
} = params;
|
} = 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 {
|
Self {
|
||||||
composer: ChatComposer::new(
|
composer,
|
||||||
has_input_focus,
|
|
||||||
app_event_tx.clone(),
|
|
||||||
enhanced_keys_supported,
|
|
||||||
placeholder_text,
|
|
||||||
disable_paste_burst,
|
|
||||||
),
|
|
||||||
view_stack: Vec::new(),
|
view_stack: Vec::new(),
|
||||||
app_event_tx,
|
app_event_tx,
|
||||||
frame_requester,
|
frame_requester,
|
||||||
@@ -578,6 +585,7 @@ mod tests {
|
|||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
animations_enabled: true,
|
animations_enabled: true,
|
||||||
|
skills: Some(Vec::new()),
|
||||||
});
|
});
|
||||||
pane.push_approval_request(exec_request());
|
pane.push_approval_request(exec_request());
|
||||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||||
@@ -599,6 +607,7 @@ mod tests {
|
|||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
animations_enabled: true,
|
animations_enabled: true,
|
||||||
|
skills: Some(Vec::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create an approval modal (active view).
|
// Create an approval modal (active view).
|
||||||
@@ -631,6 +640,7 @@ mod tests {
|
|||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
animations_enabled: true,
|
animations_enabled: true,
|
||||||
|
skills: Some(Vec::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start a running task so the status indicator is active above the composer.
|
// 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(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
animations_enabled: true,
|
animations_enabled: true,
|
||||||
|
skills: Some(Vec::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Begin a task: show initial status.
|
// Begin a task: show initial status.
|
||||||
@@ -723,6 +734,7 @@ mod tests {
|
|||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
animations_enabled: true,
|
animations_enabled: true,
|
||||||
|
skills: Some(Vec::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate spinner (status view replaces composer) with no live ring.
|
// 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(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
animations_enabled: true,
|
animations_enabled: true,
|
||||||
|
skills: Some(Vec::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
pane.set_task_running(true);
|
pane.set_task_running(true);
|
||||||
@@ -780,6 +793,7 @@ mod tests {
|
|||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
animations_enabled: true,
|
animations_enabled: true,
|
||||||
|
skills: Some(Vec::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
pane.set_task_running(true);
|
pane.set_task_running(true);
|
||||||
|
|||||||
@@ -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::WarningEvent;
|
||||||
use codex_core::protocol::WebSearchBeginEvent;
|
use codex_core::protocol::WebSearchBeginEvent;
|
||||||
use codex_core::protocol::WebSearchEndEvent;
|
use codex_core::protocol::WebSearchEndEvent;
|
||||||
|
use codex_core::skills::model::SkillMetadata;
|
||||||
use codex_protocol::ConversationId;
|
use codex_protocol::ConversationId;
|
||||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||||
use codex_protocol::parse_command::ParsedCommand;
|
use codex_protocol::parse_command::ParsedCommand;
|
||||||
@@ -256,6 +257,7 @@ pub(crate) struct ChatWidgetInit {
|
|||||||
pub(crate) enhanced_keys_supported: bool,
|
pub(crate) enhanced_keys_supported: bool,
|
||||||
pub(crate) auth_manager: Arc<AuthManager>,
|
pub(crate) auth_manager: Arc<AuthManager>,
|
||||||
pub(crate) feedback: codex_feedback::CodexFeedback,
|
pub(crate) feedback: codex_feedback::CodexFeedback,
|
||||||
|
pub(crate) skills: Option<Vec<SkillMetadata>>,
|
||||||
pub(crate) is_first_run: bool,
|
pub(crate) is_first_run: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,6 +1233,7 @@ impl ChatWidget {
|
|||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
auth_manager,
|
auth_manager,
|
||||||
feedback,
|
feedback,
|
||||||
|
skills,
|
||||||
is_first_run,
|
is_first_run,
|
||||||
} = common;
|
} = common;
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
@@ -1249,6 +1252,7 @@ impl ChatWidget {
|
|||||||
placeholder_text: placeholder,
|
placeholder_text: placeholder,
|
||||||
disable_paste_burst: config.disable_paste_burst,
|
disable_paste_burst: config.disable_paste_burst,
|
||||||
animations_enabled: config.animations,
|
animations_enabled: config.animations,
|
||||||
|
skills,
|
||||||
}),
|
}),
|
||||||
active_cell: None,
|
active_cell: None,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
@@ -1307,6 +1311,7 @@ impl ChatWidget {
|
|||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
auth_manager,
|
auth_manager,
|
||||||
feedback,
|
feedback,
|
||||||
|
skills,
|
||||||
..
|
..
|
||||||
} = common;
|
} = common;
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
@@ -1327,6 +1332,7 @@ impl ChatWidget {
|
|||||||
placeholder_text: placeholder,
|
placeholder_text: placeholder,
|
||||||
disable_paste_burst: config.disable_paste_burst,
|
disable_paste_burst: config.disable_paste_burst,
|
||||||
animations_enabled: config.animations,
|
animations_enabled: config.animations,
|
||||||
|
skills,
|
||||||
}),
|
}),
|
||||||
active_cell: None,
|
active_cell: None,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
@@ -1545,6 +1551,9 @@ impl ChatWidget {
|
|||||||
SlashCommand::Mention => {
|
SlashCommand::Mention => {
|
||||||
self.insert_str("@");
|
self.insert_str("@");
|
||||||
}
|
}
|
||||||
|
SlashCommand::Skills => {
|
||||||
|
self.insert_str("$");
|
||||||
|
}
|
||||||
SlashCommand::Status => {
|
SlashCommand::Status => {
|
||||||
self.add_status_output();
|
self.add_status_output();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ async fn helpers_are_available_and_do_not_panic() {
|
|||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
auth_manager,
|
auth_manager,
|
||||||
feedback: codex_feedback::CodexFeedback::new(),
|
feedback: codex_feedback::CodexFeedback::new(),
|
||||||
|
skills: None,
|
||||||
is_first_run: true,
|
is_first_run: true,
|
||||||
};
|
};
|
||||||
let mut w = ChatWidget::new(init, conversation_manager);
|
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(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
animations_enabled: cfg.animations,
|
animations_enabled: cfg.animations,
|
||||||
|
skills: None,
|
||||||
});
|
});
|
||||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||||
let widget = ChatWidget {
|
let widget = ChatWidget {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub enum SlashCommand {
|
|||||||
// more frequently used commands should be listed first.
|
// more frequently used commands should be listed first.
|
||||||
Model,
|
Model,
|
||||||
Approvals,
|
Approvals,
|
||||||
|
Skills,
|
||||||
Review,
|
Review,
|
||||||
New,
|
New,
|
||||||
Resume,
|
Resume,
|
||||||
@@ -46,6 +47,7 @@ impl SlashCommand {
|
|||||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||||
SlashCommand::Mention => "mention a file",
|
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::Status => "show current session configuration and token usage",
|
||||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||||
@@ -76,6 +78,7 @@ impl SlashCommand {
|
|||||||
| SlashCommand::Logout => false,
|
| SlashCommand::Logout => false,
|
||||||
SlashCommand::Diff
|
SlashCommand::Diff
|
||||||
| SlashCommand::Mention
|
| SlashCommand::Mention
|
||||||
|
| SlashCommand::Skills
|
||||||
| SlashCommand::Status
|
| SlashCommand::Status
|
||||||
| SlashCommand::Mcp
|
| SlashCommand::Mcp
|
||||||
| SlashCommand::Feedback
|
| SlashCommand::Feedback
|
||||||
|
|||||||
Reference in new issue
Block a user