diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 67cc77e315..eec2b7d765 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4064,6 +4064,12 @@ fn ends_plaintext_at_mention(bytes: &[u8], index: usize) -> bool { }) } +fn ends_plaintext_at_dollar_mention(bytes: &[u8], index: usize) -> bool { + bytes + .get(index) + .is_none_or(|byte| !is_mention_name_char(*byte)) +} + fn starts_plaintext_at_mention(text: &str, index: usize) -> bool { if index == 0 { return true; @@ -4114,9 +4120,7 @@ fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option let ends_plaintext_mention = if sigil == b'@' { ends_plaintext_at_mention(bytes, end) } else { - bytes - .get(end) - .is_none_or(|byte| !is_mention_name_char(*byte)) + ends_plaintext_at_dollar_mention(bytes, end) }; if starts_plaintext_mention && ends_plaintext_mention { @@ -6504,6 +6508,25 @@ mod tests { ); } + #[test] + fn skill_popup_falls_back_from_bound_skill_with_path_suffix_snapshot() { + snapshot_composer_state( + "skill_popup_falls_back_from_bound_skill_with_path_suffix", + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_skill_mentions(Some(vec![test_skill_metadata("left")])); + composer.set_text_content_with_mention_bindings( + "$left $bound/path".to_string(), + Vec::new(), + Vec::new(), + vec![test_skill_binding("bound")], + ); + composer.draft.textarea.set_cursor("$left ".len()); + composer.sync_popups(); + }, + ); + } + #[test] fn skill_popup_closes_after_trailing_space_snapshot() { snapshot_composer_state( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/completion_target.rs b/codex-rs/tui/src/bottom_pane/chat_composer/completion_target.rs index 3966229624..22443e3dce 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/completion_target.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/completion_target.rs @@ -1,5 +1,6 @@ //! Cursor-neighborhood resolution for sigil-prefixed composer completions. +use super::ends_plaintext_at_dollar_mention; use super::ends_plaintext_at_mention; use super::is_mention_name_char; use crate::bottom_pane::textarea::TextArea; @@ -268,7 +269,7 @@ pub(super) fn current_prefixed_token_range_with_dollar_predicate( /// Returns whether a token candidate's sigil and mention name are editable plaintext. /// /// A candidate is bound when either its entire range is atomic or its mention-name prefix is -/// atomic and the remaining text begins with punctuation that terminates the mention. +/// atomic and the remaining text begins with a terminator for that sigil. pub(super) fn prefixed_token_range_is_editable( textarea: &TextArea, prefix: char, @@ -285,9 +286,14 @@ pub(super) fn prefixed_token_range_is_editable( .take_while(|byte| is_mention_name_char(**byte)) .count(); let mention_end = range.start + prefix.len_utf8() + name_len; + let ends_bound_mention = if prefix == '@' { + ends_plaintext_at_mention(textarea.text().as_bytes(), mention_end) + } else { + ends_plaintext_at_dollar_mention(textarea.text().as_bytes(), mention_end) + }; !(name_len > 0 && mention_end < range.end - && ends_plaintext_at_mention(textarea.text().as_bytes(), mention_end) + && ends_bound_mention && textarea .element_id_for_exact_range(range.start..mention_end) .is_some()) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/completion_target_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/completion_target_tests.rs index 0a44d72f3f..095877d59e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/completion_target_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/completion_target_tests.rs @@ -81,6 +81,24 @@ fn current_prefixed_token_falls_back_from_bound_at_token_on_right() { ); } +#[test] +fn current_prefixed_token_falls_back_from_bound_dollar_suffix_on_right() { + for suffix in ["/path", ".config"] { + let text = format!("$left $bound{suffix}"); + let bound_start = text.find("$bound").expect("bound skill mention"); + let bound_end = bound_start + "$bound".len(); + let mut textarea = TextArea::new(); + textarea.insert_str(&text); + textarea.add_element_range(bound_start..bound_end); + textarea.set_cursor("$left ".len()); + + assert_eq!( + current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true), + Some((0.."$left".len(), "left".to_string())) + ); + } +} + #[test] fn current_prefixed_token_targets_later_segment_between_bound_elements() { let text = "$bound1$one$bound2$two$bound3"; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap new file mode 100644 index 0000000000..63cdf4bccc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $left $bound/path " +" " +" " +" " +" " +" left [Skill] Example skill used in tests. " +" " +" Press enter to insert or esc to close "