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::render_rows; use super::slash_commands; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; // Hide alias commands in the default popup list so each unique action appears once. // `quit` is an alias of `exit`, so we skip `quit` here. // `approvals` is an alias of `permissions`. const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { Builtin(SlashCommand), // Index into `prompts` UserPrompt(usize), } pub(crate) struct CommandPopup { command_filter: String, builtins: Vec<(&'static str, SlashCommand)>, prompts: Vec, state: ScrollState, } #[derive(Clone, Copy, Debug, Default)] pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, } impl CommandPopup { pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { // Keep built-in availability in sync with the composer. let builtins = slash_commands::builtins_for_input( flags.collaboration_modes_enabled, flags.connectors_enabled, flags.personality_command_enabled, flags.windows_degraded_sandbox_active, ); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); prompts.sort_by(|a, b| a.name.cmp(&b.name)); Self { command_filter: String::new(), builtins, prompts, state: ScrollState::new(), } } pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { let exclude: HashSet = self .builtins .iter() .map(|(n, _)| (*n).to_string()) .collect(); prompts.retain(|p| !exclude.contains(&p.name)); prompts.sort_by(|a, b| a.name.cmp(&b.name)); self.prompts = prompts; } pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> { self.prompts.get(idx) } /// Update the filter string based on the current composer text. The text /// passed in is expected to start with a leading '/'. Everything after the /// *first* '/' on the *first* line becomes the active filter that is used /// to narrow down the list of available commands. pub(crate) fn on_composer_text_change(&mut self, text: String) { let first_line = text.lines().next().unwrap_or(""); if let Some(stripped) = first_line.strip_prefix('/') { // Extract the *first* token (sequence of non-whitespace // characters) after the slash so that `/clear something` still // shows the help for `/clear`. let token = stripped.trim_start(); let cmd_token = token.split_whitespace().next().unwrap_or(""); // Update the filter keeping the original case (commands are all // lower-case for now but this may change in the future). self.command_filter = cmd_token.to_string(); } else { // The composer no longer starts with '/'. Reset the filter so the // popup shows the *full* command list if it is still displayed // for some reason. self.command_filter.clear(); } // Reset or clamp selected index based on new filtered list. let matches_len = self.filtered_items().len(); self.state.clamp_selection(matches_len); self.state .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } /// Determine the preferred height of the popup for a given width. /// Accounts for wrapped descriptions so that long tooltips don't overflow. pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { use super::selection_popup_common::measure_rows_height; let rows = self.rows_from_matches(self.filtered()); measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) } /// Compute exact/prefix matches over built-in commands and user prompts, /// paired with optional highlight indices. Preserves the original /// presentation order for built-ins and prompts. fn filtered(&self) -> Vec<(CommandItem, Option>)> { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { if ALIAS_COMMANDS.contains(cmd) { continue; } out.push((CommandItem::Builtin(*cmd), None)); } // Then prompts, already sorted by name. for idx in 0..self.prompts.len() { out.push((CommandItem::UserPrompt(idx), None)); } return out; } let filter_lower = filter.to_lowercase(); let filter_chars = filter.chars().count(); let mut exact: Vec<(CommandItem, Option>)> = Vec::new(); let mut prefix: Vec<(CommandItem, Option>)> = Vec::new(); let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1; let indices_for = |offset| Some((offset..offset + filter_chars).collect()); let mut push_match = |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { let display_lower = display.to_lowercase(); let name_lower = name.map(str::to_lowercase); let display_exact = display_lower == filter_lower; let name_exact = name_lower.as_deref() == Some(filter_lower.as_str()); if display_exact || name_exact { let offset = if display_exact { 0 } else { name_offset }; exact.push((item, indices_for(offset))); return; } let display_prefix = display_lower.starts_with(&filter_lower); let name_prefix = name_lower .as_ref() .is_some_and(|name| name.starts_with(&filter_lower)); if display_prefix || name_prefix { let offset = if display_prefix { 0 } else { name_offset }; prefix.push((item, indices_for(offset))); } }; for (_, cmd) in self.builtins.iter() { push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); } // Support both search styles: // - Typing "name" should surface "/prompts:name" results. // - Typing "prompts:name" should also work. for (idx, p) in self.prompts.iter().enumerate() { let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); push_match( CommandItem::UserPrompt(idx), &display, Some(&p.name), prompt_prefix_len, ); } out.extend(exact); out.extend(prefix); out } fn filtered_items(&self) -> Vec { self.filtered().into_iter().map(|(c, _)| c).collect() } fn rows_from_matches( &self, matches: Vec<(CommandItem, Option>)>, ) -> Vec { matches .into_iter() .map(|(item, indices)| { let (name, description) = match item { CommandItem::Builtin(cmd) => { (format!("/{}", cmd.command()), cmd.description().to_string()) } CommandItem::UserPrompt(i) => { let prompt = &self.prompts[i]; let description = prompt .description .clone() .unwrap_or_else(|| "send saved prompt".to_string()); ( format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), description, ) } }; GenericDisplayRow { name, match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), display_shortcut: None, description: Some(description), category_tag: None, wrap_indent: None, is_disabled: false, disabled_reason: None, } }) .collect() } /// Move the selection cursor one step up. 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)); } /// Move the selection cursor one step down. pub(crate) fn move_down(&mut self) { let matches_len = self.filtered_items().len(); self.state.move_down_wrap(matches_len); self.state .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } /// Return currently selected command, if any. pub(crate) fn selected_item(&self) -> Option { let matches = self.filtered_items(); self.state .selected_idx .and_then(|idx| matches.get(idx).copied()) } } impl WidgetRef for CommandPopup { 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 matches", ); } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn filter_includes_init_when_typing_prefix() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); // Access the filtered list via the selected command and ensure that // one of the matches is the new "init" command. let matches = popup.filtered_items(); let has_init = matches.iter().any(|item| match item { CommandItem::Builtin(cmd) => cmd.command() == "init", CommandItem::UserPrompt(_) => false, }); assert!( has_init, "expected '/init' to appear among filtered commands" ); } #[test] fn selecting_init_by_exact_match() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that // command by default. let selected = popup.selected_item(); match selected { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"), None => panic!("expected a selected command for exact match"), } } #[test] fn model_is_first_suggestion_for_mo() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), Some(CommandItem::UserPrompt(_)) => { panic!("unexpected prompt ranked before '/model' for '/mo'") } None => panic!("expected at least one match for '/mo'"), } } #[test] fn filtered_commands_keep_presentation_order_for_prefix() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() .filter_map(|item| match item { CommandItem::Builtin(cmd) => Some(cmd.command()), CommandItem::UserPrompt(_) => None, }) .collect(); assert_eq!(cmds, vec!["model", "mention", "mcp"]); } #[test] fn prompt_discovery_lists_custom_prompts() { let prompts = vec![ CustomPrompt { name: "foo".to_string(), path: "/tmp/foo.md".to_string().into(), content: "hello from foo".to_string(), description: None, argument_hint: None, }, CustomPrompt { name: "bar".to_string(), path: "/tmp/bar.md".to_string().into(), content: "hello from bar".to_string(), description: None, argument_hint: None, }, ]; let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() .filter_map(|it| match it { CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()), _ => None, }) .collect(); prompt_names.sort(); assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); } #[test] fn prompt_name_collision_with_builtin_is_ignored() { // Create a prompt named like a builtin (e.g. "init"). let popup = CommandPopup::new( vec![CustomPrompt { name: "init".to_string(), path: "/tmp/init.md".to_string().into(), content: "should be ignored".to_string(), description: None, argument_hint: None, }], CommandPopupFlags::default(), ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), _ => false, }); assert!( !has_collision_prompt, "prompt with builtin name should be ignored" ); } #[test] fn prompt_description_uses_frontmatter_metadata() { let popup = CommandPopup::new( vec![CustomPrompt { name: "draftpr".to_string(), path: "/tmp/draftpr.md".to_string().into(), content: "body".to_string(), description: Some("Create feature branch, commit and open draft PR.".to_string()), argument_hint: None, }], CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!( description, Some("Create feature branch, commit and open draft PR.") ); } #[test] fn prompt_description_falls_back_when_missing() { let popup = CommandPopup::new( vec![CustomPrompt { name: "foo".to_string(), path: "/tmp/foo.md".to_string().into(), content: "body".to_string(), description: None, argument_hint: None, }], CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!(description, Some("send saved prompt")); } #[test] fn prefix_filter_limits_matches_for_ac() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() .filter_map(|item| match item { CommandItem::Builtin(cmd) => Some(cmd.command()), CommandItem::UserPrompt(_) => None, }) .collect(); assert!( !cmds.contains(&"compact"), "expected prefix search for '/ac' to exclude 'compact', got {cmds:?}" ); } #[test] fn quit_hidden_in_empty_filter_but_shown_for_prefix() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/".to_string()); let items = popup.filtered_items(); assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit))); popup.on_composer_text_change("/qu".to_string()); let items = popup.filtered_items(); assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); } #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() .filter_map(|item| match item { CommandItem::Builtin(cmd) => Some(cmd.command()), CommandItem::UserPrompt(_) => None, }) .collect(); assert!( !cmds.contains(&"collab"), "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" ); assert!( !cmds.contains(&"plan"), "expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}" ); } #[test] fn collab_command_visible_when_collaboration_modes_enabled() { let mut popup = CommandPopup::new( Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, personality_command_enabled: true, windows_degraded_sandbox_active: false, }, ); popup.on_composer_text_change("/collab".to_string()); match popup.selected_item() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), other => panic!("expected collab to be selected for exact match, got {other:?}"), } } #[test] fn plan_command_visible_when_collaboration_modes_enabled() { let mut popup = CommandPopup::new( Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, personality_command_enabled: true, windows_degraded_sandbox_active: false, }, ); popup.on_composer_text_change("/plan".to_string()); match popup.selected_item() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"), other => panic!("expected plan to be selected for exact match, got {other:?}"), } } #[test] fn personality_command_hidden_when_disabled() { let mut popup = CommandPopup::new( Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, personality_command_enabled: false, windows_degraded_sandbox_active: false, }, ); popup.on_composer_text_change("/pers".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() .filter_map(|item| match item { CommandItem::Builtin(cmd) => Some(cmd.command()), CommandItem::UserPrompt(_) => None, }) .collect(); assert!( !cmds.contains(&"personality"), "expected '/personality' to be hidden when disabled, got {cmds:?}" ); } #[test] fn personality_command_visible_when_enabled() { let mut popup = CommandPopup::new( Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, personality_command_enabled: true, windows_degraded_sandbox_active: false, }, ); popup.on_composer_text_change("/personality".to_string()); match popup.selected_item() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "personality"), other => panic!("expected personality to be selected for exact match, got {other:?}"), } } }