diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index cda2bbe61..40985f78a 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -52,6 +52,7 @@ use std::path::PathBuf; const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "apps", "memories", + "mentions_v2", "plugins", "remote_control", "tool_search", diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index a186485df..d50683cf8 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -382,7 +382,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - ); assert!( error.message.contains( - "apps, memories, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation" + "apps, memories, mentions_v2, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation" ), "{}", error.message diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 77773a805..99fab110d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -478,6 +478,9 @@ "memory_tool": { "type": "boolean" }, + "mentions_v2": { + "type": "boolean" + }, "multi_agent": { "type": "boolean" }, @@ -4031,6 +4034,9 @@ "memory_tool": { "type": "boolean" }, + "mentions_v2": { + "type": "boolean" + }, "multi_agent": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 6bb2a08dd..3cddefa8d 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -190,6 +190,8 @@ pub enum Feature { SkillMcpDependencyInstall, /// Prompt for missing skill env var dependencies. SkillEnvVarDependencyPrompt, + /// Enable the unified mention popup prototype. + MentionsV2, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. /// Kept for config backward compatibility; behavior is always steer-enabled. Steer, @@ -1009,6 +1011,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::MentionsV2, + key: "mentions_v2", + stage: Stage::Experimental { + name: "Mentions v2", + menu_description: "Use a unified @ mention popup for files, folders, apps, plugins, and skills.", + announcement: "", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::Steer, key: "steer", diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2bd553ba6..131236533 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -188,6 +188,8 @@ use super::footer::single_line_footer_layout; use super::footer::status_line_right_indicator_line; use super::footer::toggle_shortcut_mode; use super::footer::uses_passive_footer_status_layout; +use super::mentions_v2::MentionV2Popup; +use super::mentions_v2::MentionV2Selection; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use super::skill_popup::MentionItem; @@ -406,6 +408,7 @@ pub(crate) struct ChatComposer { plugins_command_enabled: bool, service_tier_commands_enabled: bool, service_tier_commands: Vec, + mentions_v2_enabled: bool, goal_command_enabled: bool, personality_command_enabled: bool, realtime_conversation_enabled: bool, @@ -466,6 +469,7 @@ enum ActivePopup { Command(CommandPopup), File(FileSearchPopup), Skill(SkillPopup), + MentionV2(MentionV2Popup), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -588,6 +592,7 @@ impl ChatComposer { plugins_command_enabled: false, service_tier_commands_enabled: false, service_tier_commands: Vec::new(), + mentions_v2_enabled: false, goal_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, @@ -662,6 +667,11 @@ impl ChatComposer { self.plugins_command_enabled = enabled; } + pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { + self.mentions_v2_enabled = enabled; + self.sync_popups(); + } + /// Toggle composer-side image paste handling. /// /// This only affects whether image-like paste content is converted into attachments; the @@ -804,6 +814,9 @@ impl ChatComposer { ActivePopup::Skill(popup) => { Constraint::Max(popup.calculate_required_height(area.width)) } + ActivePopup::MentionV2(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } ActivePopup::None => Constraint::Max(footer_total_height), }; let [composer_rect, popup_rect] = @@ -1608,7 +1621,11 @@ impl ChatComposer { /// Integrate results from an asynchronous file search. pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { // Only apply if user is still editing a token starting with `query`. - let current_opt = Self::current_at_token(&self.textarea); + let current_opt = if self.mentions_v2_enabled { + self.current_mentions_v2_token() + } else { + Self::current_at_token(&self.textarea) + }; let Some(current_token) = current_opt else { return; }; @@ -1617,8 +1634,14 @@ impl ChatComposer { return; } - if let ActivePopup::File(popup) = &mut self.active_popup { - popup.set_matches(&query, matches); + match &mut self.active_popup { + ActivePopup::File(popup) => { + popup.set_matches(&query, matches); + } + ActivePopup::MentionV2(popup) => { + popup.set_file_matches(&query, matches); + } + _ => {} } } @@ -1705,6 +1728,7 @@ impl ChatComposer { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), + ActivePopup::MentionV2(_) => self.handle_key_event_with_mentions_v2_popup(key_event), ActivePopup::None => self.handle_key_event_without_popup(key_event), }; self.reset_vim_mode_after_successful_dispatch(&result.0); @@ -2146,6 +2170,103 @@ impl ChatComposer { result } + fn handle_key_event_with_mentions_v2_popup( + &mut self, + key_event: KeyEvent, + ) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + self.footer_mode = reset_mode_after_activity(self.footer_mode); + + let ActivePopup::MentionV2(popup) = &mut self.active_popup else { + unreachable!(); + }; + + let mut selected: Option = None; + let mut close_popup = false; + + let result = match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } => { + popup.previous_search_mode(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } => { + popup.next_search_mode(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some(tok) = self.current_mentions_v2_token() { + self.dismissed_mention_popup_token = Some(tok); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + selected = popup.selected(); + close_popup = true; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + }; + + if close_popup { + if let Some(selected) = selected { + match selected { + MentionV2Selection::File(path) => { + self.insert_selected_file_path(path.to_string_lossy().as_ref()); + } + MentionV2Selection::Tool { insert_text, path } => { + self.insert_selected_mention(&insert_text, path.as_deref()); + } + } + } + self.active_popup = ActivePopup::None; + } + + result + } + fn is_image_path(path: &str) -> bool { let lower = path.to_ascii_lowercase(); lower.ends_with(".png") @@ -2155,6 +2276,45 @@ impl ChatComposer { || lower.ends_with(".webp") } + fn insert_selected_file_path(&mut self, selected_path: &str) { + if Self::is_image_path(selected_path) { + let path_buf = PathBuf::from(selected_path); + match image::image_dimensions(&path_buf) { + Ok((width, height)) => { + tracing::debug!("selected image dimensions={}x{}", width, height); + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + self.textarea.replace_range(start_idx..end_idx, ""); + self.textarea.set_cursor(start_idx); + self.attach_image(path_buf); + self.textarea.insert_str(" "); + } + Err(err) => { + tracing::trace!("image dimensions lookup failed: {err}"); + self.insert_selected_path(selected_path); + } + } + } else { + self.insert_selected_path(selected_path); + } + } + fn trim_text_elements( original: &str, trimmed: &str, @@ -2410,6 +2570,13 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false) } + fn current_mentions_v2_token(&self) -> Option { + if !self.mentions_v2_enabled { + return None; + } + Self::current_prefixed_token(&self.textarea, '@', /*allow_empty*/ true) + } + fn current_mention_token(&self) -> Option { if !self.mentions_enabled() { return None; @@ -3642,7 +3809,12 @@ impl ChatComposer { self.active_popup = ActivePopup::None; return; } - let file_token = Self::current_at_token(&self.textarea); + let mentions_v2_token = self.current_mentions_v2_token(); + let file_token = if self.mentions_v2_enabled { + None + } else { + Self::current_at_token(&self.textarea) + }; let browsing_history = self .history .should_handle_navigation(&self.current_text(), self.history_navigation_cursor()); @@ -3662,6 +3834,7 @@ impl ChatComposer { let allow_command_popup = self.slash_commands_enabled() && !self.is_bash_mode && file_token.is_none() + && mentions_v2_token.is_none() && mention_token.is_none(); self.sync_command_popup(allow_command_popup); @@ -3676,6 +3849,11 @@ impl ChatComposer { return; } + if let Some(token) = mentions_v2_token { + self.sync_mentions_v2_popup(token); + return; + } + if let Some(token) = mention_token { if self.current_file_query.is_some() { self.app_event_tx @@ -3700,7 +3878,7 @@ impl ChatComposer { self.dismissed_file_popup_token = None; if matches!( self.active_popup, - ActivePopup::File(_) | ActivePopup::Skill(_) + ActivePopup::File(_) | ActivePopup::Skill(_) | ActivePopup::MentionV2(_) ) { self.active_popup = ActivePopup::None; } @@ -3960,6 +4138,41 @@ impl ChatComposer { } } + fn sync_mentions_v2_popup(&mut self, query: String) { + if self.dismissed_mention_popup_token.as_ref() == Some(&query) { + return; + } + + if query.is_empty() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.current_file_query = None; + } else { + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + self.current_file_query = Some(query.clone()); + } + + let candidates = super::mentions_v2::build_search_catalog( + self.skills.as_deref(), + self.plugins.as_deref(), + ); + + match &mut self.active_popup { + ActivePopup::MentionV2(popup) => { + popup.set_query(&query); + popup.set_candidates(candidates); + } + _ => { + let mut popup = MentionV2Popup::new(candidates); + popup.set_query(&query); + self.active_popup = ActivePopup::MentionV2(popup); + } + } + + self.dismissed_mention_popup_token = None; + } + fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); if let Some(skills) = self.skills.as_ref() { @@ -4291,6 +4504,7 @@ impl Renderable for ChatComposer { ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), ActivePopup::Skill(c) => c.calculate_required_height(width), + ActivePopup::MentionV2(c) => c.calculate_required_height(width), } } @@ -4313,6 +4527,9 @@ impl ChatComposer { ActivePopup::Skill(popup) => { popup.render_ref(popup_rect, buf); } + ActivePopup::MentionV2(popup) => { + popup.render_ref(popup_rect, buf); + } ActivePopup::None => { let footer_props = self.footer_props(); let show_cycle_hint = diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/candidate.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/candidate.rs new file mode 100644 index 000000000..e95eee063 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/candidate.rs @@ -0,0 +1,82 @@ +use std::path::PathBuf; + +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Span; + +const TAG_WIDTH: usize = "Plugin".len(); + +#[derive(Clone, Debug)] +pub(crate) enum Selection { + File(PathBuf), + Tool { + insert_text: String, + path: Option, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum MentionType { + Plugin, + Skill, + File, + Directory, +} + +impl MentionType { + pub(super) fn is_filesystem(self) -> bool { + matches!(self, Self::File | Self::Directory) + } + + pub(super) fn span(self, base_style: Style) -> Span<'static> { + let style = match self { + Self::Plugin => base_style.magenta(), + Self::Skill => base_style.dim(), + Self::File => base_style.cyan(), + Self::Directory => base_style, + }; + format!("{: &'static str { + match self { + Self::Plugin => "Plugin", + Self::Skill => "Skill", + Self::File => "File", + Self::Directory => "Dir", + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Candidate { + pub(super) display_name: String, + pub(super) description: Option, + pub(super) search_terms: Vec, + pub(super) mention_type: MentionType, + pub(super) selection: Selection, +} + +#[derive(Clone, Debug)] +pub(super) struct SearchResult { + pub(super) display_name: String, + pub(super) description: Option, + pub(super) mention_type: MentionType, + pub(super) selection: Selection, + pub(super) match_indices: Option>, + pub(super) score: i32, +} + +impl Candidate { + pub(super) fn to_result(&self, match_indices: Option>, score: i32) -> SearchResult { + SearchResult { + display_name: self.display_name.clone(), + description: self.description.clone(), + mention_type: self.mention_type, + selection: self.selection.clone(), + match_indices, + score, + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/filter.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/filter.rs new file mode 100644 index 000000000..479f07a6c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/filter.rs @@ -0,0 +1,107 @@ +use codex_file_search::FileMatch; +use codex_file_search::MatchType; +use codex_utils_fuzzy_match::fuzzy_match; + +use super::candidate::Candidate; +use super::candidate::MentionType; +use super::candidate::SearchResult; +use super::candidate::Selection; +use super::search_mode::SearchMode; + +pub(super) fn filtered_candidates( + candidates: &[Candidate], + file_matches: &[FileMatch], + query: &str, + search_mode: SearchMode, + show_file_matches: bool, +) -> Vec { + let filter = query.trim(); + let mut out = Vec::new(); + + for candidate in candidates { + if !search_mode.accepts(candidate.mention_type) { + continue; + } + if filter.is_empty() { + out.push(candidate.to_result(/*match_indices*/ None, /*score*/ 0)); + continue; + } + + if let Some((indices, score)) = best_tool_match(candidate, filter) { + out.push(candidate.to_result(indices, score)); + } + } + + if show_file_matches { + out.extend( + file_matches + .iter() + .map(file_match_to_row) + .filter(|candidate| search_mode.accepts(candidate.mention_type)), + ); + } + + sort_rows(&mut out, filter); + out +} + +fn best_tool_match(candidate: &Candidate, filter: &str) -> Option<(Option>, i32)> { + if let Some((indices, score)) = fuzzy_match(&candidate.display_name, filter) { + return Some((Some(indices), score)); + } + + candidate + .search_terms + .iter() + .filter(|term| *term != &candidate.display_name) + .filter_map(|term| fuzzy_match(term, filter).map(|(_indices, score)| score)) + .min() + .map(|score| (None, score)) +} + +fn sort_rows(rows: &mut [SearchResult], filter: &str) { + let type_order = |mention_type: MentionType| match mention_type { + MentionType::Plugin => 0, + MentionType::Skill => 1, + MentionType::File | MentionType::Directory => 2, + }; + + rows.sort_by(|a, b| { + type_order(a.mention_type) + .cmp(&type_order(b.mention_type)) + .then_with(|| compare_within_rank(a, b, filter)) + .then_with(|| a.display_name.cmp(&b.display_name)) + }); +} + +fn compare_within_rank(a: &SearchResult, b: &SearchResult, filter: &str) -> std::cmp::Ordering { + if a.mention_type.is_filesystem() && b.mention_type.is_filesystem() { + return b.score.cmp(&a.score); + } + if filter.is_empty() { + return a.display_name.cmp(&b.display_name); + } + + a.match_indices + .is_none() + .cmp(&b.match_indices.is_none()) + .then_with(|| a.score.cmp(&b.score)) +} + +fn file_match_to_row(file_match: &FileMatch) -> SearchResult { + let mention_type = match file_match.match_type { + MatchType::File => MentionType::File, + MatchType::Directory => MentionType::Directory, + }; + SearchResult { + display_name: file_match.path.to_string_lossy().to_string(), + description: None, + mention_type, + selection: Selection::File(file_match.path.clone()), + match_indices: file_match + .indices + .as_ref() + .map(|indices| indices.iter().map(|idx| *idx as usize).collect()), + score: file_match.score as i32, + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/footer.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/footer.rs new file mode 100644 index 000000000..6200083cf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/footer.rs @@ -0,0 +1,81 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Widget; + +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; + +use super::search_mode::SearchMode; + +pub(super) fn render_footer(area: Rect, buf: &mut Buffer, search_mode: SearchMode) { + let right_line = search_mode_indicator_line(search_mode); + let right_width = right_line.width() as u16; + let gap = u16::from(right_width > 0); + let left_width = area.width.saturating_sub(right_width).saturating_sub(gap); + let left_line = + truncate_line_with_ellipsis_if_overflow(footer_hint_line(), left_width as usize); + left_line.render( + Rect { + x: area.x, + y: area.y, + width: left_width, + height: 1, + }, + buf, + ); + if right_width > 0 && right_width <= area.width { + right_line.render( + Rect { + x: area.x + area.width - right_width, + y: area.y, + width: right_width, + height: 1, + }, + buf, + ); + } +} + +fn footer_hint_line() -> Line<'static> { + Line::from(vec![ + key_hint::plain(KeyCode::Enter).into(), + " insert · ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " close · ".dim(), + key_hint::plain(KeyCode::Left).into(), + "/".dim(), + key_hint::plain(KeyCode::Right).into(), + " switch search modes".dim(), + ]) +} + +fn search_mode_indicator_line(active_search_mode: SearchMode) -> Line<'static> { + let modes = [ + SearchMode::Results, + SearchMode::FilesystemOnly, + SearchMode::Tools, + ]; + let mut spans = Vec::with_capacity(modes.len() * 2 - 1); + + for (index, search_mode) in modes.into_iter().enumerate() { + if index > 0 { + spans.push(" ".dim()); + } + + if search_mode == active_search_mode { + let label = format!("[{}]", search_mode.label()); + let span = match search_mode { + SearchMode::Results | SearchMode::FilesystemOnly => label.cyan().bold(), + SearchMode::Tools => label.magenta().bold(), + }; + spans.push(span); + } else { + spans.push(format!(" {} ", search_mode.label()).dim()); + } + } + + Line::from(spans) +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/mod.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/mod.rs new file mode 100644 index 000000000..8e33c3e87 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/mod.rs @@ -0,0 +1,11 @@ +mod candidate; +mod filter; +mod footer; +mod popup; +mod render; +mod search_catalog; +mod search_mode; + +pub(crate) use candidate::Selection as MentionV2Selection; +pub(crate) use popup::Popup as MentionV2Popup; +pub(crate) use search_catalog::build_search_catalog; diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/popup.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/popup.rs new file mode 100644 index 000000000..4626cebfd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/popup.rs @@ -0,0 +1,154 @@ +use codex_file_search::FileMatch; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::candidate::Candidate; +use super::candidate::SearchResult; +use super::candidate::Selection; +use super::filter::filtered_candidates; +use super::render::render_popup; +use super::search_mode::SearchMode; +use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; +use crate::bottom_pane::scroll_state::ScrollState; + +pub(crate) struct Popup { + query: String, + file_search: FileSearch, + candidates: Vec, + search_mode: SearchMode, + state: ScrollState, +} + +impl Popup { + pub(crate) fn new(candidates: Vec) -> Self { + Self { + query: String::new(), + file_search: FileSearch::default(), + candidates, + search_mode: SearchMode::Results, + state: ScrollState::new(), + } + } + + pub(crate) fn set_candidates(&mut self, candidates: Vec) { + self.candidates = candidates; + self.clamp_selection(); + } + + pub(crate) fn set_query(&mut self, query: &str) { + self.query = query.to_string(); + self.file_search.set_query(query); + self.clamp_selection(); + } + + pub(crate) fn set_file_matches(&mut self, query: &str, matches: Vec) { + self.file_search.set_matches(query, matches); + self.clamp_selection(); + } + + pub(crate) fn selected(&self) -> Option { + let rows = self.rows(); + let idx = self.state.selected_idx?; + rows.get(idx).map(|row| row.selection.clone()) + } + + pub(crate) fn move_up(&mut self) { + let len = self.rows().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn move_down(&mut self) { + let len = self.rows().len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn previous_search_mode(&mut self) { + self.search_mode = self.search_mode.previous(); + self.clamp_selection(); + } + + pub(crate) fn next_search_mode(&mut self) { + self.search_mode = self.search_mode.next(); + self.clamp_selection(); + } + + pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 { + (MAX_POPUP_ROWS as u16).saturating_add(2) + } + + fn clamp_selection(&mut self) { + let len = self.rows().len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn rows(&self) -> Vec { + filtered_candidates( + &self.candidates, + &self.file_search.matches, + &self.query, + self.search_mode, + self.file_search.should_show_matches(), + ) + } +} + +impl WidgetRef for Popup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + render_popup( + area, + buf, + &self.rows(), + &self.state, + self.file_search.empty_message(), + self.search_mode, + ); + } +} + +#[derive(Default)] +struct FileSearch { + pending_query: String, + display_query: String, + waiting: bool, + matches: Vec, +} + +impl FileSearch { + fn set_query(&mut self, query: &str) { + if query.is_empty() { + self.pending_query.clear(); + self.display_query.clear(); + self.waiting = false; + self.matches.clear(); + } else if query != self.pending_query { + self.pending_query = query.to_string(); + self.waiting = true; + } + } + + fn set_matches(&mut self, query: &str, matches: Vec) { + if query != self.pending_query { + return; + } + + self.display_query = query.to_string(); + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); + self.waiting = false; + } + + fn should_show_matches(&self) -> bool { + !self.matches.is_empty() + } + + fn empty_message(&self) -> &'static str { + if self.waiting { + "loading..." + } else { + "no matches" + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs new file mode 100644 index 000000000..c8031a013 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs @@ -0,0 +1,306 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Widget; + +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::Insets; +use crate::render::RectExt; + +use super::candidate::MentionType; +use super::candidate::SearchResult; +use super::candidate::Selection; +use super::footer::render_footer; +use super::search_mode::SearchMode; +use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; +use crate::bottom_pane::scroll_state::ScrollState; + +pub(super) fn render_popup( + area: Rect, + buf: &mut Buffer, + rows: &[SearchResult], + state: &ScrollState, + empty_message: &str, + search_mode: SearchMode, +) { + let (list_area, hint_area) = if area.height > 2 { + let hint_area = Rect { + x: area.x, + y: area.y + area.height - 1, + width: area.width, + height: 1, + }; + let list_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: area.height - 2, + }; + (list_area, Some(hint_area)) + } else { + (area, None) + }; + + render_rows( + list_area.inset(Insets::tlbr( + /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, + )), + buf, + rows, + state, + empty_message, + ); + + if let Some(hint_area) = hint_area { + let hint_area = Rect { + x: hint_area.x + 2, + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + render_footer(hint_area, buf, search_mode); + } +} + +fn render_rows( + area: Rect, + buf: &mut Buffer, + rows: &[SearchResult], + state: &ScrollState, + empty_message: &str, +) { + if area.height == 0 { + return; + } + if rows.is_empty() { + Line::from(empty_message.italic()).render(area, buf); + return; + } + + let visible_items = MAX_POPUP_ROWS + .min(rows.len()) + .min(area.height.max(1) as usize); + let mut start_idx = state.scroll_top.min(rows.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } + + let mut cur_y = area.y; + let primary_column_width = rows + .iter() + .skip(start_idx) + .take(visible_items) + .map(primary_text_width) + .max() + .unwrap_or(0); + for (idx, row) in rows.iter().enumerate().skip(start_idx).take(visible_items) { + if cur_y >= area.y + area.height { + break; + } + + let selected = Some(idx) == state.selected_idx; + let line = build_line(row, selected, area.width as usize, primary_column_width); + line.render( + Rect { + x: area.x, + y: cur_y, + width: area.width, + height: 1, + }, + buf, + ); + cur_y = cur_y.saturating_add(1); + } +} + +fn build_line( + row: &SearchResult, + selected: bool, + width: usize, + primary_column_width: usize, +) -> Line<'static> { + let base_style = if selected { + Style::default().bold() + } else { + Style::default() + }; + let dim_style = if selected { + Style::default().bold() + } else { + Style::default().dim() + }; + let tag = row.mention_type.span(base_style); + let tag_width = tag.width(); + let content_width = width.saturating_sub(tag_width.saturating_add(2)); + let content = truncate_line_with_ellipsis_if_overflow( + content_line(row, base_style, dim_style, primary_column_width), + content_width, + ); + let rendered_content_width = content.width(); + let mut spans = Vec::new(); + spans.extend(content.spans); + let padding = width.saturating_sub(rendered_content_width.saturating_add(tag_width)); + if padding > 0 { + spans.push(" ".repeat(padding).set_style(dim_style)); + } + spans.push(tag); + + Line::from(spans) +} + +fn content_line( + row: &SearchResult, + base_style: Style, + dim_style: Style, + primary_column_width: usize, +) -> Line<'static> { + let mut spans = Vec::new(); + spans.extend(primary_spans(row, base_style)); + if let Some(secondary) = secondary_line(row, base_style, dim_style) { + let padding = primary_column_width + .saturating_sub(primary_text_width(row)) + .saturating_add(2); + spans.push(" ".repeat(padding).set_style(dim_style)); + spans.extend(secondary.spans); + } + + Line::from(spans) +} + +fn primary_spans(row: &SearchResult, base_style: Style) -> Vec> { + if let Some(file_name) = file_name(row) { + let style = if row.mention_type == MentionType::File { + base_style.fg(Color::Cyan) + } else { + base_style + }; + return vec![file_name.to_string().set_style(style)]; + } + + let mut spans = Vec::with_capacity(row.display_name.len()); + let name_style = match row.mention_type { + MentionType::Plugin => base_style.magenta(), + MentionType::Skill => base_style.dim(), + MentionType::File | MentionType::Directory => base_style, + }; + if let Some(indices) = row.match_indices.as_ref() { + let mut idx_iter = indices.iter().peekable(); + for (char_idx, ch) in row.display_name.chars().enumerate() { + let mut style = name_style; + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + style = style.bold(); + } + spans.push(ch.to_string().set_style(style)); + } + } else { + spans.push(row.display_name.clone().set_style(name_style)); + } + + spans +} + +fn secondary_line( + row: &SearchResult, + base_style: Style, + dim_style: Style, +) -> Option> { + if file_name(row).is_some() { + let mut spans = path_spans(row, base_style); + if let Some(description) = row + .description + .as_deref() + .filter(|description| !description.is_empty()) + { + spans.push(" ".set_style(dim_style)); + spans.push(description.to_string().set_style(dim_style)); + } + return Some(Line::from(spans)); + } + + row.description + .as_deref() + .filter(|description| !description.is_empty()) + .map(|description| Line::from(description.to_string().set_style(dim_style))) +} + +fn path_spans(row: &SearchResult, base_style: Style) -> Vec> { + let mut spans = Vec::with_capacity(row.display_name.len()); + let file_name_start = file_name_start(row); + let path_style = base_style.dim(); + if file_name_start == 0 { + spans.push("./".set_style(path_style)); + } else if let Some(indices) = row.match_indices.as_ref() { + let mut idx_iter = indices.iter().peekable(); + for (char_idx, ch) in row.display_name.chars().enumerate().take(file_name_start) { + let mut style = path_style; + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + style = style.bold(); + } + spans.push(ch.to_string().set_style(style)); + } + } else if file_name_start != usize::MAX { + let byte_start = row + .display_name + .char_indices() + .nth(file_name_start) + .map(|(idx, _)| idx) + .unwrap_or(row.display_name.len()); + spans.push( + row.display_name[..byte_start] + .to_string() + .set_style(path_style), + ); + } else { + spans.push(row.display_name.clone().set_style(base_style)); + } + spans +} + +fn primary_text_width(row: &SearchResult) -> usize { + file_name(row) + .map(|file_name| file_name.chars().count()) + .unwrap_or_else(|| row.display_name.chars().count()) +} + +fn file_name(row: &SearchResult) -> Option<&str> { + let file_name_start = file_name_start(row); + if file_name_start == usize::MAX { + return None; + } + if file_name_start == 0 { + return Some(&row.display_name); + } + + let byte_start = row + .display_name + .char_indices() + .nth(file_name_start) + .map(|(idx, _)| idx) + .unwrap_or(row.display_name.len()); + Some(&row.display_name[byte_start..]) +} + +fn file_name_start(row: &SearchResult) -> usize { + match row.selection { + Selection::File(_) if row.mention_type.is_filesystem() => row + .display_name + .rfind(['/', '\\']) + .map(|idx| row.display_name[..idx + 1].chars().count()) + .unwrap_or(0), + Selection::File(_) | Selection::Tool { .. } => usize::MAX, + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs new file mode 100644 index 000000000..b1dd7f914 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs @@ -0,0 +1,111 @@ +use codex_core_skills::model::SkillMetadata; +use codex_plugin::PluginCapabilitySummary; + +use crate::skills_helpers::skill_description; +use crate::skills_helpers::skill_display_name; + +use super::candidate::Candidate; +use super::candidate::MentionType; +use super::candidate::Selection; + +pub(crate) fn build_search_catalog( + skills: Option<&[SkillMetadata]>, + plugins: Option<&[PluginCapabilitySummary]>, +) -> Vec { + let mut candidates = Vec::new(); + if let Some(skills) = skills { + candidates.extend(skills.iter().map(skill_candidate)); + } + + if let Some(plugins) = plugins { + candidates.extend(plugins.iter().map(plugin_candidate)); + } + + candidates +} + +fn skill_candidate(skill: &SkillMetadata) -> Candidate { + let display_name = skill_display_name(skill); + let description = optional_skill_description(skill); + let skill_name = skill.name.clone(); + let search_terms = if display_name == skill.name { + vec![skill_name.clone()] + } else { + vec![skill_name.clone(), display_name.clone()] + }; + Candidate { + display_name, + description, + search_terms, + mention_type: MentionType::Skill, + selection: Selection::Tool { + insert_text: format!("${skill_name}"), + path: Some(skill.path_to_skills_md.to_string_lossy().into_owned()), + }, + } +} + +fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate { + let (plugin_name, marketplace_name) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()]; + if plugin.display_name != plugin_name { + search_terms.push(plugin.display_name.clone()); + } + if !marketplace_name.is_empty() { + search_terms.push(marketplace_name.to_string()); + } + + Candidate { + display_name: plugin.display_name.clone(), + description: plugin_description(plugin), + search_terms, + mention_type: MentionType::Plugin, + selection: Selection::Tool { + insert_text: format!("${plugin_name}"), + path: Some(format!("plugin://{}", plugin.config_name)), + }, + } +} + +fn plugin_description(plugin: &PluginCapabilitySummary) -> Option { + let capability_labels = plugin_capability_labels(plugin); + plugin.description.clone().or_else(|| { + Some(if capability_labels.is_empty() { + "Plugin".to_string() + } else { + format!("Plugin - {}", capability_labels.join(" - ")) + }) + }) +} + +fn plugin_capability_labels(plugin: &PluginCapabilitySummary) -> Vec { + let mut labels = Vec::new(); + if plugin.has_skills { + labels.push("skills".to_string()); + } + if !plugin.mcp_server_names.is_empty() { + let mcp_server_count = plugin.mcp_server_names.len(); + labels.push(if mcp_server_count == 1 { + "1 MCP server".to_string() + } else { + format!("{mcp_server_count} MCP servers") + }); + } + if !plugin.app_connector_ids.is_empty() { + let app_count = plugin.app_connector_ids.len(); + labels.push(if app_count == 1 { + "1 app".to_string() + } else { + format!("{app_count} apps") + }); + } + labels +} + +fn optional_skill_description(skill: &SkillMetadata) -> Option { + let description = skill_description(skill).trim(); + (!description.is_empty()).then(|| description.to_string()) +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/search_mode.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/search_mode.rs new file mode 100644 index 000000000..c95ebe7b0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/search_mode.rs @@ -0,0 +1,44 @@ +use super::candidate::MentionType; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SearchMode { + Results, + FilesystemOnly, + Tools, +} + +impl SearchMode { + pub(super) fn previous(self) -> Self { + match self { + Self::Results => Self::Tools, + Self::FilesystemOnly => Self::Results, + Self::Tools => Self::FilesystemOnly, + } + } + + pub(super) fn next(self) -> Self { + match self { + Self::Results => Self::FilesystemOnly, + Self::FilesystemOnly => Self::Tools, + Self::Tools => Self::Results, + } + } + + pub(super) fn accepts(self, mention_type: MentionType) -> bool { + match self { + Self::Results => true, + Self::FilesystemOnly => { + matches!(mention_type, MentionType::File | MentionType::Directory) + } + Self::Tools => matches!(mention_type, MentionType::Plugin | MentionType::Skill), + } + } + + pub(super) fn label(self) -> &'static str { + match self { + Self::Results => "All Results", + Self::FilesystemOnly => "Filesystem Only", + Self::Tools => "Plugins", + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d28a272cf..975062e4c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -95,6 +95,7 @@ mod file_search_popup; mod footer; mod list_selection_view; mod memories_settings_view; +mod mentions_v2; pub(crate) mod prompt_args; mod skill_popup; mod skills_toggle_view; @@ -319,6 +320,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { + self.composer.set_mentions_v2_enabled(enabled); + self.request_redraw(); + } + pub fn take_mention_bindings(&mut self) -> Vec { self.composer.take_mention_bindings() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c6fcfd829..678a73c5f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4896,6 +4896,7 @@ impl ChatWidget { widget.sync_personality_command_enabled(); widget.sync_plugins_command_enabled(); widget.sync_goal_command_enabled(); + widget.sync_mentions_v2_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_hint_binding); @@ -8711,6 +8712,9 @@ impl ChatWidget { self.update_collaboration_mode_indicator(); } } + if feature == Feature::MentionsV2 { + self.sync_mentions_v2_enabled(); + } if feature == Feature::PreventIdleSleep { self.turn_lifecycle.set_prevent_idle_sleep(enabled); } @@ -8912,6 +8916,11 @@ impl ChatWidget { .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); } + fn sync_mentions_v2_enabled(&mut self) { + self.bottom_pane + .set_mentions_v2_enabled(self.config.features.enabled(Feature::MentionsV2)); + } + fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.model_catalog @@ -10133,6 +10142,7 @@ impl ChatWidget { self.config.realtime = config.realtime.clone(); self.config.memories = config.memories.clone(); self.config.terminal_resize_reflow = config.terminal_resize_reflow; + self.sync_mentions_v2_enabled(); } pub(crate) fn open_review_popup(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 9c11151f0..bc431d0db 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -18,13 +18,18 @@ use codex_core_skills::model::SkillDependencies; use codex_core_skills::model::SkillInterface; use codex_core_skills::model::SkillMetadata; use codex_core_skills::model::SkillToolDependency; +use codex_features::Feature; use codex_protocol::parse_command::ParsedCommand; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; impl ChatWidget { pub(crate) fn open_skills_list(&mut self) { - self.insert_str("$"); + if self.config.features.enabled(Feature::MentionsV2) { + self.insert_str("@"); + } else { + self.insert_str("$"); + } } pub(crate) fn open_skills_menu(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_submission_warning_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_submission_warning_snapshot.snap new file mode 100644 index 000000000..c8bbb7c2a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_submission_warning_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/review_mode.rs +expression: lines_to_single_string(last) +--- +⚠ Steer messages aren't supported during /review. Send your message after the + review finishes, or press Ctrl+C now to cancel the review. +