Files
canvrno-oai 6c1215dac6 TUI: Unified mentions tweaks + polish mentions rendering (#23363)
This change keeps unified @mentions behind the mentions_v2 gate, moves
the flag to under-development, and polishes mention rendering/history
behavior.

It also adds a few small improvements to the mentions feature around
mention rendering and history round-tripping for plugin/tool mentions in
message edit scenarios. Plugin selections now insert `@` mentions with
better casing, and saved history preserves the visible sigil so recalled
messages look the same as what the user typed.

- Preserves `@` sigils when encoding/decoding mention history for
tool/plugin paths.
- Improves plugin mention insertion so display names/casing are
reflected more cleanly in the composer.
- Update composer to render user-entered plugin mentions in the same
color as the mentions menu. ALso applies to recalled/edited messages.
- Left/right arrows no longer switch unified-mention search modes after
an @mention has already been accepted (Ex: arrowing left through a
composed message that contains @mentions).
- Keeps bound mentions stable around punctuation, so accepted `@`
mentions do not reopen the popup and punctuated `$` mentions still
persist to cross-session history.

**Steps to test**
- Ensure mentions_v2 is enabled through configuration or `--enable
mentions_v2`
- Type `@` in the TUI composer and verify filesystem/plugin/skill
results are displayed in the unified mentions menu.
- Select a plugin mention from the `@` popup and confirm the inserted
text is an `@...` mention with casing, then recall/edit the message and
confirm it still renders as `@...`.
- Mention a skill and verify that skills still insert as `$skill`
mentions rather than `@` mentions.
- Verify punctuated mentions such as `@plugin.` and `($skill)` keep
their bound mention behavior across editing and history recall.
2026-05-28 10:30:15 -07:00

580 lines
18 KiB
Rust
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use std::collections::VecDeque;
use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct LinkedMention {
pub(crate) sigil: char,
pub(crate) mention: String,
pub(crate) path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DecodedHistoryText {
pub(crate) text: String,
pub(crate) mentions: Vec<LinkedMention>,
}
#[allow(dead_code)]
pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) -> String {
if mentions.is_empty() || text.is_empty() {
return text.to_string();
}
let mut mentions_by_token: HashMap<(char, &str), VecDeque<&str>> = HashMap::new();
for mention in mentions {
if !matches!(
mention.sigil,
TOOL_MENTION_SIGIL | PLUGIN_TEXT_MENTION_SIGIL
) {
continue;
}
mentions_by_token
.entry((mention.sigil, mention.mention.as_str()))
.or_default()
.push_back(mention.path.as_str());
}
let bytes = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut index = 0usize;
while index < bytes.len() {
if matches!(
bytes[index],
byte if byte == TOOL_MENTION_SIGIL as u8 || byte == PLUGIN_TEXT_MENTION_SIGIL as u8
) {
let sigil = bytes[index] as char;
if sigil == TOOL_MENTION_SIGIL || starts_plaintext_mention(text, index) {
let name_start = index + 1;
if let Some(first) = bytes.get(name_start)
&& is_mention_name_char(*first)
{
let mut name_end = name_start + 1;
while let Some(next) = bytes.get(name_end)
&& is_mention_name_char(*next)
{
name_end += 1;
}
let name = &text[name_start..name_end];
if (sigil == TOOL_MENTION_SIGIL || ends_plaintext_mention(bytes, name_end))
&& let Some(path) = mentions_by_token
.get_mut(&(sigil, name))
.and_then(VecDeque::pop_front)
{
out.push('[');
out.push(sigil);
out.push_str(name);
out.push_str("](");
out.push_str(path);
out.push(')');
index = name_end;
continue;
}
}
}
}
let Some(ch) = text[index..].chars().next() else {
break;
};
out.push(ch);
index += ch.len_utf8();
}
out
}
pub(crate) fn decode_history_mentions_with_at_mentions(
text: &str,
at_mentions_enabled: bool,
) -> DecodedHistoryText {
let bytes = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut mentions = Vec::new();
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] == b'['
&& let Some((sigil, name, path, end_index)) =
parse_history_linked_mention(text, bytes, index, at_mentions_enabled)
{
out.push(sigil);
out.push_str(name);
mentions.push(LinkedMention {
sigil,
mention: name.to_string(),
path: path.to_string(),
});
index = end_index;
continue;
}
let Some(ch) = text[index..].chars().next() else {
break;
};
out.push(ch);
index += ch.len_utf8();
}
DecodedHistoryText {
text: out,
mentions,
}
}
fn parse_history_linked_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
at_mentions_enabled: bool,
) -> Option<(char, &'a str, &'a str, usize)> {
// TUI historically wrote `$name`, but selected unified `@` mentions should preserve `@` on
// history round-trip for any canonical tool path.
if let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, start, TOOL_MENTION_SIGIL)
&& !is_common_env_var(name)
&& is_tool_path(path)
{
return Some((TOOL_MENTION_SIGIL, name, path, end_index));
}
if at_mentions_enabled {
if let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, start, PLUGIN_TEXT_MENTION_SIGIL)
&& !is_common_env_var(name)
&& is_tool_path(path)
{
return Some((PLUGIN_TEXT_MENTION_SIGIL, name, path, end_index));
}
} else if let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, start, PLUGIN_TEXT_MENTION_SIGIL)
&& !is_common_env_var(name)
&& path.starts_with("plugin://")
{
return Some((TOOL_MENTION_SIGIL, name, path, end_index));
}
None
}
fn parse_linked_tool_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
sigil: char,
) -> Option<(&'a str, &'a str, usize)> {
let sigil_index = start + 1;
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
return None;
}
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;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
if text_bytes.get(name_end) != Some(&b']') {
return None;
}
let mut path_start = name_end + 1;
while let Some(next_byte) = text_bytes.get(path_start)
&& next_byte.is_ascii_whitespace()
{
path_start += 1;
}
if text_bytes.get(path_start) != Some(&b'(') {
return None;
}
let mut path_end = path_start + 1;
while let Some(next_byte) = text_bytes.get(path_end)
&& *next_byte != b')'
{
path_end += 1;
}
if text_bytes.get(path_end) != Some(&b')') {
return None;
}
let path = text[path_start + 1..path_end].trim();
if path.is_empty() {
return None;
}
let name = &text[name_start..name_end];
Some((name, path, path_end + 1))
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn starts_plaintext_mention(text: &str, index: usize) -> bool {
if index == 0 {
return true;
}
text.get(..index)
.and_then(|prefix| prefix.chars().next_back())
.is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch))
}
fn ends_plaintext_mention(text_bytes: &[u8], index: usize) -> bool {
text_bytes.get(index).is_none_or(|byte| {
byte.is_ascii_whitespace()
|| *byte == b'.'
&& text_bytes.get(index + 1).is_none_or(|next| {
next.is_ascii_whitespace()
|| !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-'
})
|| !matches!(*byte, b'.' | b'/' | b'\\')
&& !byte.is_ascii_alphanumeric()
&& *byte != b'_'
&& *byte != b'-'
})
}
fn is_mention_name_char_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}
fn is_common_env_var(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
matches!(
upper.as_str(),
"PATH"
| "HOME"
| "USER"
| "SHELL"
| "PWD"
| "TMPDIR"
| "TEMP"
| "TMP"
| "LANG"
| "TERM"
| "XDG_CONFIG_HOME"
)
}
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(['/', '\\'])
.next()
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn decode_history_mentions_restores_visible_tokens() {
let decoded = decode_history_mentions_with_at_mentions(
"Use [$figma](app://figma-1), [$sample](plugin://sample@test), and [$figma](/tmp/figma/SKILL.md).",
/*at_mentions_enabled*/ true,
);
assert_eq!(decoded.text, "Use $figma, $sample, and $figma.");
assert_eq!(
decoded.mentions,
vec![
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-1".to_string(),
},
LinkedMention {
sigil: '$',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "/tmp/figma/SKILL.md".to_string(),
},
]
);
}
#[test]
fn decode_history_mentions_restores_plugin_links_with_at_sigil() {
let decoded = decode_history_mentions_with_at_mentions(
"Use [@sample](plugin://sample@test) and [$figma](app://figma-1).",
/*at_mentions_enabled*/ true,
);
assert_eq!(decoded.text, "Use @sample and $figma.");
assert_eq!(
decoded.mentions,
vec![
LinkedMention {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-1".to_string(),
},
]
);
}
#[test]
fn decode_history_mentions_without_at_mentions_uses_legacy_plugin_fallback() {
let decoded = decode_history_mentions_with_at_mentions(
"Use [@sample](plugin://sample@test) and [$figma](app://figma-1).",
/*at_mentions_enabled*/ false,
);
assert_eq!(decoded.text, "Use $sample and $figma.");
assert_eq!(
decoded.mentions,
vec![
LinkedMention {
sigil: '$',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-1".to_string(),
},
]
);
}
#[test]
fn decode_history_mentions_without_at_mentions_ignores_at_non_plugin_paths() {
let decoded = decode_history_mentions_with_at_mentions(
"Use [@figma](app://figma-1).",
/*at_mentions_enabled*/ false,
);
assert_eq!(decoded.text, "Use [@figma](app://figma-1).");
assert_eq!(decoded.mentions, Vec::<LinkedMention>::new());
}
#[test]
fn decode_history_mentions_restores_at_sigil_for_tool_paths() {
let decoded = decode_history_mentions_with_at_mentions(
"Use [@figma](app://figma-1).",
/*at_mentions_enabled*/ true,
);
assert_eq!(decoded.text, "Use @figma.");
assert_eq!(
decoded.mentions,
vec![LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "app://figma-1".to_string(),
}]
);
}
#[test]
fn encode_history_mentions_links_bound_mentions_in_order() {
let text = "$figma then $sample then $figma then $other";
let encoded = encode_history_mentions(
text,
&[
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-app".to_string(),
},
LinkedMention {
sigil: '$',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "/tmp/figma/SKILL.md".to_string(),
},
],
);
assert_eq!(
encoded,
"[$figma](app://figma-app) then [$sample](plugin://sample@test) then [$figma](/tmp/figma/SKILL.md) then $other"
);
}
#[test]
fn encode_history_mentions_links_dollar_mentions_after_punctuation() {
let encoded = encode_history_mentions(
"($figma)",
&[LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
}],
);
assert_eq!(encoded, "([$figma](app://figma))");
}
#[test]
fn encode_history_mentions_links_dollar_mentions_with_path_like_suffixes() {
let mention = LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
};
assert_eq!(
encode_history_mentions("$figma/docs", std::slice::from_ref(&mention)),
"[$figma](app://figma)/docs"
);
assert_eq!(
encode_history_mentions("$figma.suffix", std::slice::from_ref(&mention)),
"[$figma](app://figma).suffix"
);
assert_eq!(
encode_history_mentions("$figma\\docs", &[mention]),
"[$figma](app://figma)\\docs"
);
}
#[test]
fn encode_history_mentions_preserves_at_sigils() {
let text = "@figma then @sample then $other";
let encoded = encode_history_mentions(
text,
&[
LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "/tmp/figma/SKILL.md".to_string(),
},
LinkedMention {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
],
);
assert_eq!(
encoded,
"[@figma](/tmp/figma/SKILL.md) then [@sample](plugin://sample@test) then $other"
);
}
#[test]
fn encode_history_mentions_links_both_sigils_for_same_name() {
let text = "@figma then $figma";
let encoded = encode_history_mentions(
text,
&[
LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
},
],
);
assert_eq!(
encoded,
"[@figma](plugin://figma@test) then [$figma](app://figma)"
);
}
#[test]
fn encode_history_mentions_does_not_let_at_token_steal_later_tool_binding() {
let text = "@figma then $figma";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-app".to_string(),
}],
);
assert_eq!(encoded, "@figma then [$figma](app://figma-app)");
}
#[test]
fn encode_history_mentions_links_at_mentions_after_unicode_whitespace() {
// Fix coverage: full-width space should remain a valid plaintext boundary for `@` links.
let text = "foo @sample";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
assert_eq!(encoded, "foo [@sample](plugin://sample@test)");
}
#[test]
fn encode_history_mentions_links_sentence_ending_at_mentions() {
let text = "Please ask @figma.";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "/tmp/figma/SKILL.md".to_string(),
}],
);
assert_eq!(encoded, "Please ask [@figma](/tmp/figma/SKILL.md).");
}
#[test]
fn encode_history_mentions_links_parenthesized_at_mentions() {
let text = "Please ask (@figma)";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@test".to_string(),
}],
);
assert_eq!(encoded, "Please ask ([@figma](plugin://figma@test))");
}
#[test]
fn encode_history_mentions_skips_embedded_at_substrings() {
let text = "foo@sample.com npx @sample/pkg then @sample";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
assert_eq!(
encoded,
"foo@sample.com npx @sample/pkg then [@sample](plugin://sample@test)"
);
}
}