use codex_utils_absolute_path::AbsolutePathBuf;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::key_hint::KeyBindingListExt;use crate::key_hint::is_plain_text_key_event;use crate::keymap::ListKeymap;use crate::keymap::primary_binding;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 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: AbsolutePathBuf,}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>, keymap: ListKeymap,}impl SkillsToggleView { pub(crate) fn new( items: Vec<SkillsToggleItem>, app_event_tx: AppEventSender, keymap: ListKeymap, ) -> 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(&keymap), search_query: String::new(), filtered_indices: Vec::new(), keymap, }; 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 page_up(&mut self) { let len = self.visible_len(); let visible = Self::max_visible_rows(len); self.state.page_up_clamped(len, visible); } fn page_down(&mut self) { let len = self.visible_len(); let visible = Self::max_visible_rows(len); self.state.page_down_clamped(len, visible); } fn jump_top(&mut self) { let len = self.visible_len(); let visible = Self::max_visible_rows(len); self.state.jump_top(len, visible); } fn jump_bottom(&mut self) { let len = self.visible_len(); let visible = Self::max_visible_rows(len); self.state.jump_bottom(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 .list_skills(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) { // Printable characters always feed search. Movement aliases such as // plain j/k only apply through non-text events or modified bindings. let allow_plain_char_navigation = !is_plain_text_key_event(key_event); match key_event { _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { self.move_up() } _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { self.move_down() } _ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => { self.page_up() } _ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => { self.page_down() } _ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => { self.jump_top() } _ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => { self.jump_bottom() } KeyEvent { code: KeyCode::Backspace, .. } => { self.search_query.pop(); self.apply_filter(); } KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => self.toggle_selected(), _ if self.keymap.accept.is_pressed(key_event) => self.toggle_selected(), _ if self.keymap.cancel.is_pressed(key_event) => { 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(/*v*/ 1, /*h*/ 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(keymap: &ListKeymap) -> Line<'static> { let space = key_hint::plain(KeyCode::Char(' ')); let accept = primary_binding(&keymap.accept).filter(|binding| *binding != space); let cancel = primary_binding(&keymap.cancel); match (accept, cancel) { (Some(accept), Some(cancel)) => Line::from(vec![ "Press ".into(), space.into(), " or ".into(), accept.into(), " to toggle; ".into(), cancel.into(), " to close".into(), ]), (Some(accept), None) => Line::from(vec![ "Press ".into(), space.into(), " or ".into(), accept.into(), " to toggle".into(), ]), (None, Some(cancel)) => Line::from(vec![ "Press ".into(), space.into(), " to toggle; ".into(), cancel.into(), " to close".into(), ]), (None, None) => Line::from(vec!["Press ".into(), space.into(), " to toggle".into()]), }}#[cfg(test)]mod tests { use super::*; use crate::app_event::AppEvent; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; 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: test_path_buf("/tmp/skills/repo_scout.toml").abs(), }, SkillsToggleItem { name: "Changelog Writer".to_string(), skill_name: "changelog_writer".to_string(), description: "Draft release notes".to_string(), enabled: false, path: test_path_buf("/tmp/skills/changelog_writer.toml").abs(), }, ]; let view = SkillsToggleView::new(items, tx, crate::keymap::RuntimeKeymap::defaults().list); assert_snapshot!("skills_toggle_basic", render_lines(&view, /*width*/ 72)); } #[test] fn footer_hint_uses_list_keymap_accept_and_cancel() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; keymap.accept = vec![key_hint::ctrl(KeyCode::Char('t'))]; keymap.cancel = vec![key_hint::ctrl(KeyCode::Char('x'))]; let view = SkillsToggleView::new(Vec::new(), tx, keymap); let rendered = render_lines(&view, /*width*/ 72); assert!(rendered.contains("ctrl + t")); assert!(rendered.contains("ctrl + x")); assert!(!rendered.contains("enter")); assert!(!rendered.contains("esc")); }}