mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
b401666ca5
commit
eaf05c9002
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+7
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user