diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b7dbd079693..3cc5d9de141 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::fmt::Debug; use std::path::PathBuf; use std::sync::Arc; @@ -2464,8 +2465,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, @@ -2718,7 +2718,10 @@ async fn spawn_review_thread( .await; } -fn skills_to_info(skills: &[SkillMetadata]) -> Vec { +fn skills_to_info( + skills: &[SkillMetadata], + disabled_paths: &HashSet, +) -> Vec { skills .iter() .map(|skill| ProtocolSkillMetadata { @@ -2738,6 +2741,7 @@ fn skills_to_info(skills: &[SkillMetadata]) -> Vec { }), path: skill.path.clone(), scope: skill.scope, + enabled: !disabled_paths.contains(&skill.path), }) .collect() } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 533fe04d881..563170ff1a6 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2041,6 +2041,7 @@ pub struct SkillMetadata { pub interface: Option, pub path: PathBuf, pub scope: SkillScope, + pub enabled: bool, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b361425aeb5..1682918d923 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1383,6 +1383,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; } @@ -1392,6 +1419,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(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0b2617416d6..68053a1d217 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d54e7005eb0..f6145c0af1c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2256,12 +2256,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, }, ); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 83afdca30c1..876e27a0ab1 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -39,7 +39,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, } @@ -48,7 +47,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(); @@ -446,7 +444,6 @@ mod tests { let mut popup = CommandPopup::new( Vec::new(), CommandPopupFlags { - skills_enabled: false, collaboration_modes_enabled: true, }, ); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ee23cad334e..ca87c93b943 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -56,6 +56,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; @@ -106,6 +107,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. /// diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 867c10a8bf4..44afb1ff0b7 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -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) -} diff --git a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs new file mode 100644 index 00000000000..7b25b9841ea --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs @@ -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, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + header: Box, + footer_hint: Line<'static>, + search_query: String, + filtered_indices: Vec, +} + +impl SkillsToggleView { + pub(crate) fn new(items: Vec, 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 { + 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 = (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::(); + 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)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_basic.snap new file mode 100644 index 00000000000..53ed604e4e1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_basic.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8fb3dbacbe7..1b6cac8f375 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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_protocol::ThreadId; use codex_protocol::account::PlanType; @@ -173,6 +172,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; @@ -424,6 +425,8 @@ pub(crate) struct ChatWidget { stream_controller: Option, running_commands: HashMap, suppressed_exec_calls: HashSet, + skills_all: Vec, + skills_initial_state: Option>, last_unified_wait: Option, unified_exec_wait_streak: Option, task_complete_pending: bool, @@ -751,11 +754,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, @@ -1853,6 +1851,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, @@ -1967,6 +1967,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, @@ -2332,7 +2334,7 @@ impl ChatWidget { self.insert_str("@"); } SlashCommand::Skills => { - self.insert_str("$"); + self.open_skills_menu(); } SlashCommand::Status => { self.add_status_output(); @@ -5008,49 +5010,5 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } -fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec { - 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 { - let mut seen: HashSet = HashSet::new(); - let mut matches: Vec = 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; diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs new file mode 100644 index 00000000000..d72ca60455c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -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 = 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 { + 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 { + 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 { + let mut seen: HashSet = HashSet::new(); + let mut matches: Vec = 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()) +} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0610a640c76..fe7a47418d8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -793,6 +793,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, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f5c2bc1e75d..cac1b12e062 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/skills_helpers.rs b/codex-rs/tui/src/skills_helpers.rs new file mode 100644 index 00000000000..8332be886f8 --- /dev/null +++ b/codex-rs/tui/src/skills_helpers.rs @@ -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>, 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 +}