Unified mentions in TUI (#19068)

This PR replaces the TUI’s file-only `@mention` popup with a unified
mentions experience. Typing `@...` now searches across filesystem
matches, installed plugins, and skills in one popup, with result types
clearly labeled and selectable from the same flow.

- Adds a unified `@mentions` popup that returns:
  - plugins
  - skills
  - files
  - directories

- Adds search modes so users can narrow the popup without changing their
query:
  - All Results _(default/same as Codex App)_
  - Filesystem Only
  - Plugins _(...and skills)_

- Preserves existing insertion behavior:
  - selected file paths are inserted into the prompt
  - paths with spaces are quoted
  - image file selections still attach as images when possible
  - selecting a plugin or skill inserts the corresponding `$name`
- the composer records the canonical mention binding, such as
`plugin://...` or the skill path

- Expanded `@mentions` rendering:
  - type tags for Plugin, Skill, File, and Dir
  - distinct plugin/filesystem colors
  - stable fixed-height layout (8 rows)
  - truncation behavior for narrow terminals

Note:
- The unified mentions popup does not display app connectors under
`@mention` results for Codex App parity. Connector mentions remain
available through the existing `$mention` path.


https://github.com/user-attachments/assets/f93781ed-57d3-4cb5-9972-675bc5f3ef3f
This commit is contained in:
canvrno-oai
2026-05-11 11:34:52 -07:00
committed by GitHub
Unverified
parent b401666ca5
commit eaf05c9002
17 changed files with 1167 additions and 7 deletions
@@ -52,6 +52,7 @@ use std::path::PathBuf;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"memories",
"mentions_v2",
"plugins",
"remote_control",
"tool_search",
@@ -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
+6
View File
@@ -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"
},
+12
View File
@@ -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",
+222 -5
View File
@@ -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<ServiceTierCommand>,
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<FileMatch>) {
// 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<MentionV2Selection> = 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<String> {
if !self.mentions_v2_enabled {
return None;
}
Self::current_prefixed_token(&self.textarea, '@', /*allow_empty*/ true)
}
fn current_mention_token(&self) -> Option<String> {
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<MentionItem> {
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 =
@@ -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<String>,
},
}
#[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!("{:<width$}", self.label(), width = TAG_WIDTH).set_style(style)
}
fn label(self) -> &'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<String>,
pub(super) search_terms: Vec<String>,
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<String>,
pub(super) mention_type: MentionType,
pub(super) selection: Selection,
pub(super) match_indices: Option<Vec<usize>>,
pub(super) score: i32,
}
impl Candidate {
pub(super) fn to_result(&self, match_indices: Option<Vec<usize>>, 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,
}
}
}
@@ -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<SearchResult> {
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<Vec<usize>>, 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,
}
}
@@ -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)
}
@@ -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;
@@ -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<Candidate>,
search_mode: SearchMode,
state: ScrollState,
}
impl Popup {
pub(crate) fn new(candidates: Vec<Candidate>) -> 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<Candidate>) {
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<FileMatch>) {
self.file_search.set_matches(query, matches);
self.clamp_selection();
}
pub(crate) fn selected(&self) -> Option<Selection> {
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<SearchResult> {
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<FileMatch>,
}
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<FileMatch>) {
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"
}
}
}
@@ -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<Span<'static>> {
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<Line<'static>> {
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<Span<'static>> {
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,
}
}
@@ -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<Candidate> {
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<String> {
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<String> {
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<String> {
let description = skill_description(skill).trim();
(!description.is_empty()).then(|| description.to_string())
}
@@ -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",
}
}
}
+6
View File
@@ -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<MentionBinding> {
self.composer.take_mention_bindings()
}
+10
View File
@@ -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) {
+6 -1
View File
@@ -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) {
@@ -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.