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:
sayan-oai
2026-03-06 11:08:36 -08:00
committed by GitHub
parent 0e41a5c4a8
commit 8a54d3caaa
18 changed files with 468 additions and 181 deletions

View File

@@ -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"
);
}
}