Files
codex/codex-rs/tui/src/mention_codec.rs
Charlie Marsh c888e8e75a Improve composer completion target resolution (#32628)
## What changed

- Resolve `@` and `$` completion targets on either side of the cursor while treating atomic text elements and line breaks as boundaries.
- Prefer the nearest editable mention when file, skill, and plugin candidates compete, and avoid treating common uppercase environment variables as skill queries.
- Insert a separator before completions adjacent to atomic elements and keep dismissed popup state scoped to the matching editable token occurrence.

## Testing

- Add unit and snapshot coverage for adjacent and partially bound mentions, whitespace boundaries, shell variables, popup dismissal, and file, image, skill, and plugin insertion.

GitOrigin-RevId: 352a42c7bcdbf4f6cd35368ced25f71926fdbc1b
2026-07-12 19:18:37 +00:00

580 lines
18 KiB
Rust
Raw 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, '_' | '-')
}
pub(crate) 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)"
);
}
}