Fix skill completion around bound mentions with suffixes (#33237)

## What changed

- Use `$`-mention boundary rules when deciding whether a skill mention followed by a suffix is already bound.
- Fall back to the editable skill token on the left when the token on the right is a bound mention such as `$bound/path` or `$bound.config`.

## Testing

- Add completion-target coverage for path and configuration suffixes.
- Add a composer snapshot covering the skill popup fallback for a bound path suffix.

GitOrigin-RevId: bf72237dfd56cfe6f590a3fab10c2bfe03b088a8
This commit is contained in:
Charlie Marsh authored and copyberry committed 2026-07-15 04:13:44 +00:00
1 parent b6f1cfa3f3
commit b556ce3fec
4 files changed
+65 -5

No files matched your search

+26 -3
View File
@@ -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(
@@ -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())
@@ -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";
@@ -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 "