mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
6c1215dac6
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.
580 lines
18 KiB
Rust
580 lines
18 KiB
Rust
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)"
|
||
);
|
||
}
|
||
}
|