mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: structured plugin parsing (#13711)
#### What Add structured `@plugin` parsing and TUI support for plugin mentions. - Core: switch from plain-text `@display_name` parsing to structured `plugin://...` mentions via `UserInput::Mention` and `[$...](plugin://...)` links in text, same pattern as apps/skills. - TUI: add plugin mention popup, autocomplete, and chips when typing `$`. Load plugin capability summaries and feed them into the composer; plugin mentions appear alongside skills and apps. - Generalize mention parsing to a sigil parameter, still defaults to `$` <img width="797" height="119" alt="image" src="https://github.com/user-attachments/assets/f0fe2658-d908-4927-9139-73f850805ceb" /> Builds on #13510. Currently clients have to build their own `id` via `plugin@marketplace` and filter plugins to show by `enabled`, but we will add `id` and `available` as fields returned from `plugin/list` soon. ####Tests Added tests, verified locally.
This commit is contained in:
@@ -213,6 +213,7 @@ use crate::tui::FrameRequester;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_chatgpt::connectors::AppInfo;
|
||||
use codex_core::plugins::PluginCapabilitySummary;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
@@ -391,6 +392,7 @@ pub(crate) struct ChatComposer {
|
||||
next_element_id: u64,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
plugins: Option<Vec<PluginCapabilitySummary>>,
|
||||
connectors_snapshot: Option<ConnectorsSnapshot>,
|
||||
dismissed_mention_popup_token: Option<String>,
|
||||
mention_bindings: HashMap<u64, ComposerMentionBinding>,
|
||||
@@ -510,6 +512,7 @@ impl ChatComposer {
|
||||
next_element_id: 0,
|
||||
context_window_used_tokens: None,
|
||||
skills: None,
|
||||
plugins: None,
|
||||
connectors_snapshot: None,
|
||||
dismissed_mention_popup_token: None,
|
||||
mention_bindings: HashMap::new(),
|
||||
@@ -546,6 +549,11 @@ impl ChatComposer {
|
||||
self.skills = skills;
|
||||
}
|
||||
|
||||
pub fn set_plugin_mentions(&mut self, plugins: Option<Vec<PluginCapabilitySummary>>) {
|
||||
self.plugins = plugins;
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
/// Toggle composer-side image paste handling.
|
||||
///
|
||||
/// This only affects whether image-like paste content is converted into attachments; the
|
||||
@@ -1926,17 +1934,25 @@ impl ChatComposer {
|
||||
self.skills.as_ref()
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> Option<&Vec<PluginCapabilitySummary>> {
|
||||
self.plugins.as_ref()
|
||||
}
|
||||
|
||||
fn mentions_enabled(&self) -> bool {
|
||||
let skills_ready = self
|
||||
.skills
|
||||
.as_ref()
|
||||
.is_some_and(|skills| !skills.is_empty());
|
||||
let plugins_ready = self
|
||||
.plugins
|
||||
.as_ref()
|
||||
.is_some_and(|plugins| !plugins.is_empty());
|
||||
let connectors_ready = self.connectors_enabled
|
||||
&& self
|
||||
.connectors_snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| !snapshot.connectors.is_empty());
|
||||
skills_ready || connectors_ready
|
||||
skills_ready || plugins_ready || connectors_ready
|
||||
}
|
||||
|
||||
/// Extract a token prefixed with `prefix` under the cursor, if any.
|
||||
@@ -3559,6 +3575,58 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(plugins) = self.plugins.as_ref() {
|
||||
for plugin in plugins {
|
||||
let (plugin_name, marketplace_name) = plugin
|
||||
.config_name
|
||||
.split_once('@')
|
||||
.unwrap_or((plugin.config_name.as_str(), ""));
|
||||
let mut capability_labels = Vec::new();
|
||||
if plugin.has_skills {
|
||||
capability_labels.push("skills".to_string());
|
||||
}
|
||||
if !plugin.mcp_server_names.is_empty() {
|
||||
let mcp_server_count = plugin.mcp_server_names.len();
|
||||
capability_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();
|
||||
capability_labels.push(if app_count == 1 {
|
||||
"1 app".to_string()
|
||||
} else {
|
||||
format!("{app_count} apps")
|
||||
});
|
||||
}
|
||||
let description = plugin.description.clone().or_else(|| {
|
||||
Some(if capability_labels.is_empty() {
|
||||
"Plugin".to_string()
|
||||
} else {
|
||||
format!("Plugin · {}", capability_labels.join(" · "))
|
||||
})
|
||||
});
|
||||
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());
|
||||
}
|
||||
mentions.push(MentionItem {
|
||||
display_name: plugin.display_name.clone(),
|
||||
description,
|
||||
insert_text: format!("${plugin_name}"),
|
||||
search_terms,
|
||||
path: Some(format!("plugin://{}", plugin.config_name)),
|
||||
category_tag: (!marketplace_name.is_empty())
|
||||
.then(|| format!("[{marketplace_name}]")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if self.connectors_enabled
|
||||
&& let Some(snapshot) = self.connectors_snapshot.as_ref()
|
||||
{
|
||||
@@ -5212,6 +5280,59 @@ mod tests {
|
||||
assert_eq!(mention.path, Some("app://connector_1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_plugin_mentions_refreshes_open_mention_popup() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
|
||||
assert!(matches!(composer.active_popup, ActivePopup::None));
|
||||
|
||||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "Sample Plugin".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample".to_string()],
|
||||
app_connector_ids: Vec::new(),
|
||||
}]));
|
||||
|
||||
let ActivePopup::Skill(popup) = &composer.active_popup else {
|
||||
panic!("expected mention popup to open after plugin update");
|
||||
};
|
||||
let mention = popup
|
||||
.selected_mention()
|
||||
.expect("expected plugin mention to be selected");
|
||||
assert_eq!(mention.insert_text, "$sample".to_string());
|
||||
assert_eq!(mention.path, Some("plugin://sample@test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_mention_popup_snapshot() {
|
||||
snapshot_composer_state("plugin_mention_popup", false, |composer| {
|
||||
composer.set_text_content("$sa".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "Sample Plugin".to_string(),
|
||||
description: Some(
|
||||
"Plugin that includes the Figma MCP server and Skills for common workflows"
|
||||
.to_string(),
|
||||
),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample".to_string()],
|
||||
app_connector_ids: vec![codex_core::plugins::AppConnectorId(
|
||||
"calendar".to_string(),
|
||||
)],
|
||||
}]));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::render::renderable::RenderableItem;
|
||||
use crate::tui::FrameRequester;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use codex_core::features::Features;
|
||||
use codex_core::plugins::PluginCapabilitySummary;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
@@ -254,6 +255,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_plugin_mentions(&mut self, plugins: Option<Vec<PluginCapabilitySummary>>) {
|
||||
self.composer.set_plugin_mentions(plugins);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
|
||||
self.composer.take_mention_bindings()
|
||||
}
|
||||
@@ -333,6 +339,10 @@ impl BottomPane {
|
||||
self.composer.skills()
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> Option<&Vec<PluginCapabilitySummary>> {
|
||||
self.composer.plugins()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn context_window_percent(&self) -> Option<i64> {
|
||||
self.context_window_percent
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› $sa "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" Sample Plugin Plugin that includes the Figma MCP server and Skills for common workflows "
|
||||
" "
|
||||
" Press enter to insert or esc to close "
|
||||
@@ -1225,6 +1225,7 @@ impl ChatWidget {
|
||||
self.refresh_model_display();
|
||||
self.sync_fast_command_enabled();
|
||||
self.sync_personality_command_enabled();
|
||||
self.refresh_plugin_mentions();
|
||||
let startup_tooltip_override = self.startup_tooltip_override.take();
|
||||
let show_fast_status = self.should_show_fast_status(event.service_tier);
|
||||
let session_info_cell = history_cell::new_session_info(
|
||||
@@ -4411,6 +4412,7 @@ impl ChatWidget {
|
||||
.collect();
|
||||
let mut skill_names_lower: HashSet<String> = HashSet::new();
|
||||
let mut selected_skill_paths: HashSet<PathBuf> = HashSet::new();
|
||||
let mut selected_plugin_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
if let Some(skills) = self.bottom_pane.skills() {
|
||||
skill_names_lower = skills
|
||||
@@ -4450,6 +4452,30 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(plugins) = self.plugins_for_mentions() {
|
||||
for binding in &mention_bindings {
|
||||
let Some(plugin_config_name) = binding
|
||||
.path
|
||||
.strip_prefix("plugin://")
|
||||
.filter(|id| !id.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !selected_plugin_ids.insert(plugin_config_name.to_string()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(plugin) = plugins
|
||||
.iter()
|
||||
.find(|plugin| plugin.config_name == plugin_config_name)
|
||||
{
|
||||
items.push(UserInput::Mention {
|
||||
name: plugin.display_name.clone(),
|
||||
path: binding.path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut selected_app_ids: HashSet<String> = HashSet::new();
|
||||
if let Some(apps) = self.connectors_for_mentions() {
|
||||
for binding in &mention_bindings {
|
||||
@@ -7153,6 +7179,9 @@ impl ChatWidget {
|
||||
if feature == Feature::Personality {
|
||||
self.sync_personality_command_enabled();
|
||||
}
|
||||
if feature == Feature::Plugins {
|
||||
self.refresh_plugin_mentions();
|
||||
}
|
||||
if feature == Feature::PreventIdleSleep {
|
||||
self.turn_sleep_inhibitor = SleepInhibitor::new(enabled);
|
||||
self.turn_sleep_inhibitor
|
||||
@@ -7578,6 +7607,14 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_for_mentions(&self) -> Option<&[codex_core::plugins::PluginCapabilitySummary]> {
|
||||
if !self.config.features.enabled(Feature::Plugins) {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.bottom_pane.plugins().map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Build a placeholder header cell while the session is configuring.
|
||||
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
|
||||
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
|
||||
@@ -8077,6 +8114,7 @@ impl ChatWidget {
|
||||
|
||||
fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) {
|
||||
self.set_skills_from_response(&ev);
|
||||
self.refresh_plugin_mentions();
|
||||
}
|
||||
|
||||
pub(crate) fn on_connectors_loaded(
|
||||
@@ -8160,6 +8198,19 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
|
||||
}
|
||||
|
||||
fn refresh_plugin_mentions(&mut self) {
|
||||
if !self.config.features.enabled(Feature::Plugins) {
|
||||
self.bottom_pane.set_plugin_mentions(None);
|
||||
return;
|
||||
}
|
||||
|
||||
let plugins = PluginsManager::new(self.config.codex_home.clone())
|
||||
.plugins_for_config(&self.config)
|
||||
.capability_summaries()
|
||||
.to_vec();
|
||||
self.bottom_pane.set_plugin_mentions(Some(plugins));
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
|
||||
@@ -296,7 +296,13 @@ pub(crate) struct ToolMentions {
|
||||
linked_paths: HashMap<String, String>,
|
||||
}
|
||||
|
||||
const TOOL_MENTION_SIGIL: char = '$';
|
||||
|
||||
fn extract_tool_mentions_from_text(text: &str) -> ToolMentions {
|
||||
extract_tool_mentions_from_text_with_sigil(text, TOOL_MENTION_SIGIL)
|
||||
}
|
||||
|
||||
fn extract_tool_mentions_from_text_with_sigil(text: &str, sigil: char) -> ToolMentions {
|
||||
let text_bytes = text.as_bytes();
|
||||
let mut names: HashSet<String> = HashSet::new();
|
||||
let mut linked_paths: HashMap<String, String> = HashMap::new();
|
||||
@@ -306,10 +312,10 @@ fn extract_tool_mentions_from_text(text: &str) -> ToolMentions {
|
||||
let byte = text_bytes[index];
|
||||
if byte == b'['
|
||||
&& let Some((name, path, end_index)) =
|
||||
parse_linked_tool_mention(text, text_bytes, index)
|
||||
parse_linked_tool_mention(text, text_bytes, index, sigil)
|
||||
{
|
||||
if !is_common_env_var(name) {
|
||||
if !is_app_or_mcp_path(path) {
|
||||
if is_skill_path(path) {
|
||||
names.insert(name.to_string());
|
||||
}
|
||||
linked_paths
|
||||
@@ -320,7 +326,7 @@ fn extract_tool_mentions_from_text(text: &str) -> ToolMentions {
|
||||
continue;
|
||||
}
|
||||
|
||||
if byte != b'$' {
|
||||
if byte != sigil as u8 {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -359,13 +365,14 @@ fn parse_linked_tool_mention<'a>(
|
||||
text: &'a str,
|
||||
text_bytes: &[u8],
|
||||
start: usize,
|
||||
sigil: char,
|
||||
) -> Option<(&'a str, &'a str, usize)> {
|
||||
let dollar_index = start + 1;
|
||||
if text_bytes.get(dollar_index) != Some(&b'$') {
|
||||
let sigil_index = start + 1;
|
||||
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_start = dollar_index + 1;
|
||||
let name_start = sigil_index + 1;
|
||||
let first_name_byte = text_bytes.get(name_start)?;
|
||||
if !is_mention_name_char(*first_name_byte) {
|
||||
return None;
|
||||
@@ -434,7 +441,7 @@ fn is_mention_name_char(byte: u8) -> bool {
|
||||
}
|
||||
|
||||
fn is_skill_path(path: &str) -> bool {
|
||||
!is_app_or_mcp_path(path)
|
||||
!path.starts_with("app://") && !path.starts_with("mcp://") && !path.starts_with("plugin://")
|
||||
}
|
||||
|
||||
fn normalize_skill_path(path: &str) -> &str {
|
||||
@@ -445,7 +452,3 @@ fn app_id_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix("app://")
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn is_app_or_mcp_path(path: &str) -> bool {
|
||||
path.starts_with("app://") || path.starts_with("mcp://")
|
||||
}
|
||||
|
||||
@@ -4141,6 +4141,73 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements()
|
||||
assert!(stored_remote_image_urls.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_protocol::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
chat.bottom_pane.set_plugin_mentions(Some(vec![
|
||||
codex_core::plugins::PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "Sample Plugin".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
},
|
||||
]));
|
||||
|
||||
chat.submit_user_message(UserMessage {
|
||||
text: "$sample".to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: vec![MentionBinding {
|
||||
mention: "sample".to_string(),
|
||||
path: "plugin://sample@test".to_string(),
|
||||
}],
|
||||
});
|
||||
|
||||
let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else {
|
||||
panic!("expected Op::UserTurn");
|
||||
};
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
UserInput::Text {
|
||||
text: "$sample".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Mention {
|
||||
name: "Sample Plugin".to_string(),
|
||||
path: "plugin://sample@test".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
@@ -7,6 +7,8 @@ pub(crate) struct LinkedMention {
|
||||
pub(crate) path: String,
|
||||
}
|
||||
|
||||
const TOOL_MENTION_SIGIL: char = '$';
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DecodedHistoryText {
|
||||
pub(crate) text: String,
|
||||
@@ -31,7 +33,7 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) ->
|
||||
let mut index = 0usize;
|
||||
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'$' {
|
||||
if bytes[index] == TOOL_MENTION_SIGIL as u8 {
|
||||
let name_start = index + 1;
|
||||
if let Some(first) = bytes.get(name_start)
|
||||
&& is_mention_name_char(*first)
|
||||
@@ -46,7 +48,7 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) ->
|
||||
let name = &text[name_start..name_end];
|
||||
if let Some(path) = mentions_by_name.get_mut(name).and_then(VecDeque::pop_front) {
|
||||
out.push('[');
|
||||
out.push('$');
|
||||
out.push(TOOL_MENTION_SIGIL);
|
||||
out.push_str(name);
|
||||
out.push_str("](");
|
||||
out.push_str(path);
|
||||
@@ -75,11 +77,12 @@ pub(crate) fn decode_history_mentions(text: &str) -> DecodedHistoryText {
|
||||
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'['
|
||||
&& let Some((name, path, end_index)) = parse_linked_tool_mention(text, bytes, index)
|
||||
&& let Some((name, path, end_index)) =
|
||||
parse_linked_tool_mention(text, bytes, index, TOOL_MENTION_SIGIL)
|
||||
&& !is_common_env_var(name)
|
||||
&& is_tool_path(path)
|
||||
{
|
||||
out.push('$');
|
||||
out.push(TOOL_MENTION_SIGIL);
|
||||
out.push_str(name);
|
||||
mentions.push(LinkedMention {
|
||||
mention: name.to_string(),
|
||||
@@ -106,13 +109,14 @@ fn parse_linked_tool_mention<'a>(
|
||||
text: &'a str,
|
||||
text_bytes: &[u8],
|
||||
start: usize,
|
||||
sigil: char,
|
||||
) -> Option<(&'a str, &'a str, usize)> {
|
||||
let dollar_index = start + 1;
|
||||
if text_bytes.get(dollar_index) != Some(&b'$') {
|
||||
let sigil_index = start + 1;
|
||||
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_start = dollar_index + 1;
|
||||
let name_start = sigil_index + 1;
|
||||
let first_name_byte = text_bytes.get(name_start)?;
|
||||
if !is_mention_name_char(*first_name_byte) {
|
||||
return None;
|
||||
@@ -183,6 +187,7 @@ fn is_common_env_var(name: &str) -> bool {
|
||||
fn is_tool_path(path: &str) -> bool {
|
||||
path.starts_with("app://")
|
||||
|| path.starts_with("mcp://")
|
||||
|| path.starts_with("plugin://")
|
||||
|| path.starts_with("skill://")
|
||||
|| path
|
||||
.rsplit(['/', '\\'])
|
||||
@@ -198,9 +203,9 @@ mod tests {
|
||||
#[test]
|
||||
fn decode_history_mentions_restores_visible_tokens() {
|
||||
let decoded = decode_history_mentions(
|
||||
"Use [$figma](app://figma-1) and [$figma](/tmp/figma/SKILL.md).",
|
||||
"Use [$figma](app://figma-1), [$sample](plugin://sample@test), and [$figma](/tmp/figma/SKILL.md).",
|
||||
);
|
||||
assert_eq!(decoded.text, "Use $figma and $figma.");
|
||||
assert_eq!(decoded.text, "Use $figma, $sample, and $figma.");
|
||||
assert_eq!(
|
||||
decoded.mentions,
|
||||
vec![
|
||||
@@ -208,6 +213,10 @@ mod tests {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma-1".to_string(),
|
||||
},
|
||||
LinkedMention {
|
||||
mention: "sample".to_string(),
|
||||
path: "plugin://sample@test".to_string(),
|
||||
},
|
||||
LinkedMention {
|
||||
mention: "figma".to_string(),
|
||||
path: "/tmp/figma/SKILL.md".to_string(),
|
||||
@@ -218,7 +227,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn encode_history_mentions_links_bound_mentions_in_order() {
|
||||
let text = "$figma then $figma then $other";
|
||||
let text = "$figma then $sample then $figma then $other";
|
||||
let encoded = encode_history_mentions(
|
||||
text,
|
||||
&[
|
||||
@@ -226,6 +235,10 @@ mod tests {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma-app".to_string(),
|
||||
},
|
||||
LinkedMention {
|
||||
mention: "sample".to_string(),
|
||||
path: "plugin://sample@test".to_string(),
|
||||
},
|
||||
LinkedMention {
|
||||
mention: "figma".to_string(),
|
||||
path: "/tmp/figma/SKILL.md".to_string(),
|
||||
@@ -234,7 +247,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
encoded,
|
||||
"[$figma](app://figma-app) then [$figma](/tmp/figma/SKILL.md) then $other"
|
||||
"[$figma](app://figma-app) then [$sample](plugin://sample@test) then [$figma](/tmp/figma/SKILL.md) then $other"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user