Codex Handbook
tui/src/bottom_pane/skills_toggle_view.rs 495 lines
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"));    }}