Add UI for skill enable/disable. (#9627)

"/skill" will now allow you to enable/disable skills:
<img width="658" height="199" alt="image"
src="https://github.com/user-attachments/assets/bf8994c8-d6c1-462f-8bbb-f1ee9241caa4"
/>
This commit is contained in:
xl-openai 2026-01-21 18:21:12 -08:00 committed by GitHub
parent 96a72828be
commit 577ba3a4ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 769 additions and 86 deletions

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt::Debug;
use std::path::PathBuf;
use std::sync::Arc;
@ -2481,8 +2482,7 @@ mod handlers {
for cwd in cwds {
let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await;
let errors = super::errors_to_info(&outcome.errors);
let enabled_skills = outcome.enabled_skills();
let skills_metadata = super::skills_to_info(&enabled_skills);
let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths);
skills.push(SkillsListEntry {
cwd,
skills: skills_metadata,
@ -2735,7 +2735,10 @@ async fn spawn_review_thread(
.await;
}
fn skills_to_info(skills: &[SkillMetadata]) -> Vec<ProtocolSkillMetadata> {
fn skills_to_info(
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
) -> Vec<ProtocolSkillMetadata> {
skills
.iter()
.map(|skill| ProtocolSkillMetadata {
@ -2755,6 +2758,7 @@ fn skills_to_info(skills: &[SkillMetadata]) -> Vec<ProtocolSkillMetadata> {
}),
path: skill.path.clone(),
scope: skill.scope,
enabled: !disabled_paths.contains(&skill.path),
})
.collect()
}

View file

@ -2058,6 +2058,7 @@ pub struct SkillMetadata {
pub interface: Option<SkillInterface>,
pub path: PathBuf,
pub scope: SkillScope,
pub enabled: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)]

View file

@ -1476,6 +1476,33 @@ impl App {
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
AppEvent::OpenSkillsList => {
self.chat_widget.open_skills_list();
}
AppEvent::OpenManageSkillsPopup => {
self.chat_widget.open_manage_skills_popup();
}
AppEvent::SetSkillEnabled { path, enabled } => {
let edits = [ConfigEdit::SetSkillConfig {
path: path.clone(),
enabled,
}];
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
{
Ok(()) => {
self.chat_widget.update_skill_enabled(path.clone(), enabled);
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to update skill config for {path_display}: {err}"
));
}
}
}
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;
}
@ -1485,6 +1512,9 @@ impl App {
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::ManageSkillsClosed => {
self.chat_widget.handle_manage_skills_closed();
}
AppEvent::FullScreenApprovalRequest(request) => match request {
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
let _ = tui.enter_alt_screen();

View file

@ -212,6 +212,21 @@ pub(crate) enum AppEvent {
/// Re-open the approval presets popup.
OpenApprovalsPopup,
/// Open the skills list popup.
OpenSkillsList,
/// Open the skills enable/disable picker.
OpenManageSkillsPopup,
/// Enable or disable a skill by path.
SetSkillEnabled {
path: PathBuf,
enabled: bool,
},
/// Notify that the manage skills popup was closed.
ManageSkillsClosed,
/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),

View file

@ -2321,12 +2321,10 @@ impl ChatComposer {
}
_ => {
if is_editing_slash_command_name {
let skills_enabled = self.skills_enabled();
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
skills_enabled,
collaboration_modes_enabled,
},
);

View file

@ -38,7 +38,6 @@ pub(crate) struct CommandPopup {
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct CommandPopupFlags {
pub(crate) skills_enabled: bool,
pub(crate) collaboration_modes_enabled: bool,
}
@ -47,7 +46,6 @@ impl CommandPopup {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills)
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.collect();
@ -467,7 +465,6 @@ mod tests {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
skills_enabled: false,
collaboration_modes_enabled: true,
},
);

View file

@ -59,6 +59,7 @@ mod footer;
mod list_selection_view;
mod prompt_args;
mod skill_popup;
mod skills_toggle_view;
pub(crate) use list_selection_view::SelectionViewParams;
mod feedback_view;
pub(crate) use feedback_view::feedback_disabled_params;
@ -109,6 +110,8 @@ pub(crate) use experimental_features_view::BetaFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
pub(crate) use skills_toggle_view::SkillsToggleItem;
pub(crate) use skills_toggle_view::SkillsToggleView;
/// Pane displayed in the lower half of the chat UI.
///

View file

@ -14,10 +14,12 @@ use super::selection_popup_common::render_rows_single_line;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt;
use codex_common::fuzzy_match::fuzzy_match;
use codex_core::skills::model::SkillMetadata;
use crate::text_formatting::truncate_text;
use crate::skills_helpers::match_skill;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use crate::skills_helpers::truncated_skill_display_name;
pub(crate) struct SkillPopup {
query: String,
@ -87,7 +89,7 @@ impl SkillPopup {
.into_iter()
.map(|(idx, indices, _score)| {
let skill = &self.skills[idx];
let name = truncate_text(skill_display_name(skill), 21);
let name = truncated_skill_display_name(skill);
let description = skill_description(skill).to_string();
GenericDisplayRow {
name,
@ -114,12 +116,8 @@ impl SkillPopup {
for (idx, skill) in self.skills.iter().enumerate() {
let display_name = skill_display_name(skill);
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
out.push((idx, Some(indices), score));
} else if display_name != skill.name
&& let Some((_indices, score)) = fuzzy_match(&skill.name, filter)
{
out.push((idx, None, score));
if let Some((indices, score)) = match_skill(filter, display_name, &skill.name) {
out.push((idx, indices, score));
}
}
@ -178,20 +176,3 @@ fn skill_popup_hint_line() -> Line<'static> {
" to close".into(),
])
}
fn skill_display_name(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.unwrap_or(&skill.name)
}
fn skill_description(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
.or(skill.short_description.as_deref())
.unwrap_or(&skill.description)
}

View file

@ -0,0 +1,437 @@
use std::path::PathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::skills_helpers::match_skill;
use crate::skills_helpers::truncate_skill_name;
use crate::style::user_message_style;
use codex_core::protocol::Op;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows_single_line;
const SEARCH_PLACEHOLDER: &str = "Type to search skills";
const SEARCH_PROMPT_PREFIX: &str = "> ";
pub(crate) struct SkillsToggleItem {
pub name: String,
pub skill_name: String,
pub description: String,
pub enabled: bool,
pub path: PathBuf,
}
pub(crate) struct SkillsToggleView {
items: Vec<SkillsToggleItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
footer_hint: Line<'static>,
search_query: String,
filtered_indices: Vec<usize>,
}
impl SkillsToggleView {
pub(crate) fn new(items: Vec<SkillsToggleItem>, app_event_tx: AppEventSender) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Enable/Disable Skills".bold()));
header.push(Line::from(
"Turn skills on or off. Your changes are saved automatically.".dim(),
));
let mut view = Self {
items,
state: ScrollState::new(),
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: skills_toggle_hint_line(),
search_query: String::new(),
filtered_indices: Vec::new(),
};
view.apply_filter();
view
}
fn visible_len(&self) -> usize {
self.filtered_indices.len()
}
fn max_visible_rows(len: usize) -> usize {
MAX_POPUP_ROWS.min(len.max(1))
}
fn apply_filter(&mut self) {
// Filter + sort while preserving the current selection when possible.
let previously_selected = self
.state
.selected_idx
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied());
let filter = self.search_query.trim();
if filter.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
} else {
let mut matches: Vec<(usize, i32)> = Vec::new();
for (idx, item) in self.items.iter().enumerate() {
let display_name = item.name.as_str();
if let Some((_indices, score)) = match_skill(filter, display_name, &item.skill_name)
{
matches.push((idx, score));
}
}
matches.sort_by(|a, b| {
a.1.cmp(&b.1).then_with(|| {
let an = self.items[a.0].name.as_str();
let bn = self.items[b.0].name.as_str();
an.cmp(bn)
})
});
self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect();
}
let len = self.filtered_indices.len();
self.state.selected_idx = previously_selected
.and_then(|actual_idx| {
self.filtered_indices
.iter()
.position(|idx| *idx == actual_idx)
})
.or_else(|| (len > 0).then_some(0));
let visible = Self::max_visible_rows(len);
self.state.clamp_selection(len);
self.state.ensure_visible(len, visible);
}
fn build_rows(&self) -> Vec<GenericDisplayRow> {
self.filtered_indices
.iter()
.enumerate()
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '' } else { ' ' };
let marker = if item.enabled { 'x' } else { ' ' };
let item_name = truncate_skill_name(&item.name);
let name = format!("{prefix} [{marker}] {item_name}");
GenericDisplayRow {
name,
description: Some(item.description.clone()),
..Default::default()
}
})
})
.collect()
}
fn move_up(&mut self) {
let len = self.visible_len();
self.state.move_up_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
fn move_down(&mut self) {
let len = self.visible_len();
self.state.move_down_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
fn toggle_selected(&mut self) {
let Some(idx) = self.state.selected_idx else {
return;
};
let Some(actual_idx) = self.filtered_indices.get(idx).copied() else {
return;
};
let Some(item) = self.items.get_mut(actual_idx) else {
return;
};
item.enabled = !item.enabled;
self.app_event_tx.send(AppEvent::SetSkillEnabled {
path: item.path.clone(),
enabled: item.enabled,
});
}
fn close(&mut self) {
if self.complete {
return;
}
self.complete = true;
self.app_event_tx.send(AppEvent::ManageSkillsClosed);
self.app_event_tx.send(AppEvent::CodexOp(Op::ListSkills {
cwds: Vec::new(),
force_reload: true,
}));
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
fn rows_height(&self, rows: &[GenericDisplayRow]) -> u16 {
rows.len().clamp(1, MAX_POPUP_ROWS).try_into().unwrap_or(1)
}
}
impl BottomPaneView for SkillsToggleView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Backspace,
..
} => {
self.search_query.pop();
self.apply_filter();
}
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
self.search_query.push(c);
self.apply_filter();
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.close();
CancellationEvent::Handled
}
}
impl Renderable for SkillsToggleView {
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_height = self.rows_height(&rows);
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height = height.saturating_add(2);
height.saturating_add(1)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
// Reserve the footer line for the key-hint row.
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = self.rows_height(&rows);
let [header_area, _, search_area, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(2),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));
self.header.render(header_area, buf);
// Render the search prompt as two lines to mimic the composer.
if search_area.height >= 2 {
let [placeholder_area, input_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area);
Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf);
let line = if self.search_query.is_empty() {
Line::from(vec![SEARCH_PROMPT_PREFIX.dim()])
} else {
Line::from(vec![
SEARCH_PROMPT_PREFIX.dim(),
self.search_query.clone().into(),
])
};
line.render(input_area, buf);
} else if search_area.height > 0 {
let query_span = if self.search_query.is_empty() {
SEARCH_PLACEHOLDER.dim()
} else {
self.search_query.clone().into()
};
Line::from(query_span).render(search_area, buf);
}
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows_single_line(
render_area,
buf,
&rows,
&self.state,
render_area.height as usize,
"no matches",
);
}
let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
}
}
fn skills_toggle_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" or ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use insta::assert_snapshot;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
fn render_lines(view: &SkillsToggleView, width: u16) -> String {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line
})
.collect();
lines.join("\n")
}
#[test]
fn renders_basic_popup() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let items = vec![
SkillsToggleItem {
name: "Repo Scout".to_string(),
skill_name: "repo_scout".to_string(),
description: "Summarize the repo layout".to_string(),
enabled: true,
path: PathBuf::from("/tmp/skills/repo_scout.toml"),
},
SkillsToggleItem {
name: "Changelog Writer".to_string(),
skill_name: "changelog_writer".to_string(),
description: "Draft release notes".to_string(),
enabled: false,
path: PathBuf::from("/tmp/skills/changelog_writer.toml"),
},
];
let view = SkillsToggleView::new(items, tx);
assert_snapshot!("skills_toggle_basic", render_lines(&view, 72));
}
}

View file

@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/skills_toggle_view.rs
assertion_line: 439
expression: "render_lines(&view, 72)"
---
Enable/Disable Skills
Turn skills on or off. Your changes are saved automatically.
Type to search skills
>
[x] Repo Scout Summarize the repo layout
[ ] Changelog Writer Draft release notes
Press space or enter to toggle; esc to close

View file

@ -72,7 +72,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget;
use codex_core::protocol::SkillsListEntry;
use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TerminalInteractionEvent;
use codex_core::protocol::TokenUsage;
@ -87,7 +87,6 @@ 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::SkillInterface;
use codex_core::skills::model::SkillMetadata;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
@ -175,6 +174,8 @@ use self::agent::spawn_agent;
use self::agent::spawn_agent_from_existing;
mod session_header;
use self::session_header::SessionHeader;
mod skills;
use self::skills::find_skill_mentions;
use crate::streaming::controller::StreamController;
use std::path::Path;
@ -428,6 +429,8 @@ pub(crate) struct ChatWidget {
stream_controller: Option<StreamController>,
running_commands: HashMap<String, RunningCommand>,
suppressed_exec_calls: HashSet<String>,
skills_all: Vec<ProtocolSkillMetadata>,
skills_initial_state: Option<HashMap<PathBuf, bool>>,
last_unified_wait: Option<UnifiedExecWaitState>,
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
task_complete_pending: bool,
@ -760,11 +763,6 @@ impl ChatWidget {
self.bottom_pane.set_skills(skills);
}
fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
self.set_skills(Some(skills));
}
pub(crate) fn open_feedback_note(
&mut self,
category: crate::app_event::FeedbackCategory,
@ -1885,6 +1883,8 @@ impl ChatWidget {
active_cell,
active_cell_revision: 0,
config,
skills_all: Vec::new(),
skills_initial_state: None,
stored_collaboration_mode,
auth_manager,
models_manager,
@ -2002,6 +2002,8 @@ impl ChatWidget {
active_cell: None,
active_cell_revision: 0,
config,
skills_all: Vec::new(),
skills_initial_state: None,
stored_collaboration_mode,
auth_manager,
models_manager,
@ -2375,7 +2377,7 @@ impl ChatWidget {
self.insert_str("@");
}
SlashCommand::Skills => {
self.insert_str("$");
self.open_skills_menu();
}
SlashCommand::Status => {
self.add_status_output();
@ -5085,49 +5087,5 @@ pub(crate) fn show_review_commit_picker_with_entries(
});
}
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMetadata> {
skills_entries
.iter()
.find(|entry| entry.cwd.as_path() == cwd)
.map(|entry| {
entry
.skills
.iter()
.map(|skill| SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}),
path: skill.path.clone(),
scope: skill.scope,
})
.collect()
})
.unwrap_or_default()
}
fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec<SkillMetadata> {
let mut seen: HashSet<String> = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
for skill in skills {
if seen.contains(&skill.name) {
continue;
}
let needle = format!("${}", skill.name);
if text.contains(&needle) {
seen.insert(skill.name.clone());
matches.push(skill.clone());
}
}
matches
}
#[cfg(test)]
pub(crate) mod tests;

View file

@ -0,0 +1,194 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::SkillsToggleItem;
use crate::bottom_pane::SkillsToggleView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use codex_core::protocol::ListSkillsResponseEvent;
use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_core::protocol::SkillsListEntry;
use codex_core::skills::model::SkillInterface;
use codex_core::skills::model::SkillMetadata;
impl ChatWidget {
pub(crate) fn open_skills_list(&mut self) {
self.insert_str("$");
}
pub(crate) fn open_skills_menu(&mut self) {
let items = vec![
SelectionItem {
name: "List skills".to_string(),
description: Some("Tip: press $ to open this list directly.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenSkillsList);
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Enable/Disable Skills".to_string(),
description: Some("Enable or disable skills.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenManageSkillsPopup);
})],
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Skills".to_string()),
subtitle: Some("Choose an action".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_manage_skills_popup(&mut self) {
if self.skills_all.is_empty() {
self.add_info_message("No skills available.".to_string(), None);
return;
}
let mut initial_state = HashMap::new();
for skill in &self.skills_all {
initial_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
}
self.skills_initial_state = Some(initial_state);
let items: Vec<SkillsToggleItem> = self
.skills_all
.iter()
.map(|skill| {
let core_skill = protocol_skill_to_core(skill);
let display_name = skill_display_name(&core_skill).to_string();
let description = skill_description(&core_skill).to_string();
let name = core_skill.name.clone();
let path = core_skill.path;
SkillsToggleItem {
name: display_name,
skill_name: name,
description,
enabled: skill.enabled,
path,
}
})
.collect();
let view = SkillsToggleView::new(items, self.app_event_tx.clone());
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn update_skill_enabled(&mut self, path: PathBuf, enabled: bool) {
let target = normalize_skill_config_path(&path);
for skill in &mut self.skills_all {
if normalize_skill_config_path(&skill.path) == target {
skill.enabled = enabled;
}
}
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
}
pub(crate) fn handle_manage_skills_closed(&mut self) {
let Some(initial_state) = self.skills_initial_state.take() else {
return;
};
let mut current_state = HashMap::new();
for skill in &self.skills_all {
current_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
}
let mut enabled_count = 0;
let mut disabled_count = 0;
for (path, was_enabled) in initial_state {
let Some(is_enabled) = current_state.get(&path) else {
continue;
};
if was_enabled != *is_enabled {
if *is_enabled {
enabled_count += 1;
} else {
disabled_count += 1;
}
}
}
if enabled_count == 0 && disabled_count == 0 {
return;
}
self.add_info_message(
format!("{enabled_count} skills enabled, {disabled_count} skills disabled"),
None,
);
}
pub(crate) fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
self.skills_all = skills;
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
}
}
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<ProtocolSkillMetadata> {
skills_entries
.iter()
.find(|entry| entry.cwd.as_path() == cwd)
.map(|entry| entry.skills.clone())
.unwrap_or_default()
}
fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec<SkillMetadata> {
skills
.iter()
.filter(|skill| skill.enabled)
.map(protocol_skill_to_core)
.collect()
}
fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}),
path: skill.path.clone(),
scope: skill.scope,
}
}
pub(crate) fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec<SkillMetadata> {
let mut seen: HashSet<String> = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
for skill in skills {
if seen.contains(&skill.name) {
continue;
}
let needle = format!("${}", skill.name);
if text.contains(&needle) {
seen.insert(skill.name.clone());
matches.push(skill.clone());
}
}
matches
}
fn normalize_skill_config_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

View file

@ -814,6 +814,8 @@ async fn make_chatwidget_manual(
stream_controller: None,
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
skills_all: Vec::new(),
skills_initial_state: None,
last_unified_wait: None,
unified_exec_wait_streak: None,
task_complete_pending: false,

View file

@ -77,6 +77,7 @@ mod resume_picker;
mod selection_list;
mod session_log;
mod shimmer;
mod skills_helpers;
mod slash_command;
mod status;
mod status_indicator_widget;

View file

@ -0,0 +1,47 @@
use codex_common::fuzzy_match::fuzzy_match;
use codex_core::skills::model::SkillMetadata;
use crate::text_formatting::truncate_text;
pub(crate) const SKILL_NAME_TRUNCATE_LEN: usize = 21;
pub(crate) fn skill_display_name(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.unwrap_or(&skill.name)
}
pub(crate) fn skill_description(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
.or(skill.short_description.as_deref())
.unwrap_or(&skill.description)
}
pub(crate) fn truncate_skill_name(name: &str) -> String {
truncate_text(name, SKILL_NAME_TRUNCATE_LEN)
}
pub(crate) fn truncated_skill_display_name(skill: &SkillMetadata) -> String {
truncate_skill_name(skill_display_name(skill))
}
pub(crate) fn match_skill(
filter: &str,
display_name: &str,
skill_name: &str,
) -> Option<(Option<Vec<usize>>, i32)> {
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
return Some((Some(indices), score));
}
if display_name != skill_name
&& let Some((_indices, score)) = fuzzy_match(skill_name, filter)
{
return Some((None, score));
}
None
}