diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 80209e6538..77fa911757 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -8,6 +8,9 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::SkillsToggleItem; use crate::bottom_pane::SkillsToggleView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::mention_codec::is_common_env_var; +use crate::mention_codec::is_mention_name_char; +use crate::mention_codec::parse_linked_tool_mention; use crate::skills_helpers::skill_description; use crate::skills_helpers::skill_display_name; use codex_app_server_protocol::SkillMetadata; @@ -297,8 +300,7 @@ fn extract_tool_mentions_from_text_with_sigil(text: &str, sigil: char) -> ToolMe while index < text_bytes.len() { let byte = text_bytes[index]; if byte == b'[' - && let Some((name, path, end_index)) = - parse_linked_tool_mention(text, text_bytes, index, sigil) + && let Some((name, path, end_index)) = parse_linked_tool_mention(text, index, sigil) { if !is_common_env_var(name) { if is_skill_path(path) { @@ -347,85 +349,6 @@ fn extract_tool_mentions_from_text_with_sigil(text: &str, sigil: char) -> ToolMe } } -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_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_mention_name_char(byte: u8) -> bool { - matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') -} - fn is_skill_path(path: &str) -> bool { !path.starts_with("app://") && !path.starts_with("mcp://") && !path.starts_with("plugin://") } diff --git a/codex-rs/tui/src/mention_codec.rs b/codex-rs/tui/src/mention_codec.rs index 5612ca2646..5b2f304604 100644 --- a/codex-rs/tui/src/mention_codec.rs +++ b/codex-rs/tui/src/mention_codec.rs @@ -1,3 +1,5 @@ +//! Shared tool-mention syntax and history encoding. Callers decide which linked paths to accept. + use std::collections::HashMap; use std::collections::VecDeque; @@ -149,7 +151,7 @@ pub(crate) fn decode_history_mentions_with_at_mentions( } if bytes[index] == b'[' && let Some((sigil, name, path, end_index)) = - parse_history_linked_mention(text, bytes, index, at_mentions_enabled) + parse_history_linked_mention(text, index, at_mentions_enabled) { out.push(sigil); out.push_str(name); @@ -176,16 +178,15 @@ pub(crate) fn decode_history_mentions_with_at_mentions( } } -fn parse_history_linked_mention<'a>( - text: &'a str, - text_bytes: &[u8], +fn parse_history_linked_mention( + text: &str, start: usize, at_mentions_enabled: bool, -) -> Option<(char, &'a str, &'a str, usize)> { +) -> Option<(char, &str, &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) + parse_linked_tool_mention(text, start, TOOL_MENTION_SIGIL) && !is_common_env_var(name) && is_tool_path(path) { @@ -194,14 +195,14 @@ fn parse_history_linked_mention<'a>( if at_mentions_enabled { if let Some((name, path, end_index)) = - parse_linked_tool_mention(text, text_bytes, start, PLUGIN_TEXT_MENTION_SIGIL) + parse_linked_tool_mention(text, 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) + parse_linked_tool_mention(text, start, PLUGIN_TEXT_MENTION_SIGIL) && !is_common_env_var(name) && path.starts_with("plugin://") { @@ -211,12 +212,13 @@ fn parse_history_linked_mention<'a>( None } -fn parse_linked_tool_mention<'a>( - text: &'a str, - text_bytes: &[u8], +/// Parse a linked mention at an opening `[` byte, returning its name, path, and end offset. +pub(crate) fn parse_linked_tool_mention( + text: &str, start: usize, sigil: char, -) -> Option<(&'a str, &'a str, usize)> { +) -> Option<(&str, &str, usize)> { + let text_bytes = text.as_bytes(); let sigil_index = start + 1; if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; @@ -268,7 +270,7 @@ fn parse_linked_tool_mention<'a>( Some((name, path, path_end + 1)) } -fn is_mention_name_char(byte: u8) -> bool { +pub(crate) fn is_mention_name_char(byte: u8) -> bool { matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') } @@ -335,6 +337,45 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn parse_linked_mentions_preserves_byte_offsets_and_trims_paths() { + let prefix = "before 🦀 "; + for sigil in [TOOL_MENTION_SIGIL, PLUGIN_TEXT_MENTION_SIGIL] { + let text = + format!("{prefix}[{sigil}tool_1-name] \n\t( skill://路径/SKILL.md ) trailing"); + assert_eq!( + parse_linked_tool_mention(&text, prefix.len(), sigil), + Some(( + "tool_1-name", + "skill://路径/SKILL.md", + text.len() - " trailing".len() + )) + ); + } + } + + #[test] + fn parse_linked_mentions_rejects_malformed_links() { + for text in [ + "[", + "[$", + "[$](app://tool)", + "[$tool.name](app://tool)", + "[$tool(app://tool)", + "[$tool]app://tool)", + "[$tool](app://tool", + "[$tool]()", + "[$tool]( \t )", + "[@tool](app://tool)", + ] { + assert_eq!( + parse_linked_tool_mention(text, /*start*/ 0, TOOL_MENTION_SIGIL), + None, + "{text}" + ); + } + } + #[test] fn decode_history_mentions_restores_visible_tokens() { let decoded = decode_history_mentions_with_at_mentions(