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 { fn starts_plaintext_at_mention(text: &str, index: usize) -> bool {
if index == 0 { if index == 0 {
return true; 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'@' { let ends_plaintext_mention = if sigil == b'@' {
ends_plaintext_at_mention(bytes, end) ends_plaintext_at_mention(bytes, end)
} else { } else {
bytes ends_plaintext_at_dollar_mention(bytes, end)
.get(end)
.is_none_or(|byte| !is_mention_name_char(*byte))
}; };
if starts_plaintext_mention && ends_plaintext_mention { 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] #[test]
fn skill_popup_closes_after_trailing_space_snapshot() { fn skill_popup_closes_after_trailing_space_snapshot() {
snapshot_composer_state( snapshot_composer_state(
@@ -1,5 +1,6 @@
//! Cursor-neighborhood resolution for sigil-prefixed composer completions. //! Cursor-neighborhood resolution for sigil-prefixed composer completions.
use super::ends_plaintext_at_dollar_mention;
use super::ends_plaintext_at_mention; use super::ends_plaintext_at_mention;
use super::is_mention_name_char; use super::is_mention_name_char;
use crate::bottom_pane::textarea::TextArea; 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. /// 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 /// 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( pub(super) fn prefixed_token_range_is_editable(
textarea: &TextArea, textarea: &TextArea,
prefix: char, prefix: char,
@@ -285,9 +286,14 @@ pub(super) fn prefixed_token_range_is_editable(
.take_while(|byte| is_mention_name_char(**byte)) .take_while(|byte| is_mention_name_char(**byte))
.count(); .count();
let mention_end = range.start + prefix.len_utf8() + name_len; 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 !(name_len > 0
&& mention_end < range.end && mention_end < range.end
&& ends_plaintext_at_mention(textarea.text().as_bytes(), mention_end) && ends_bound_mention
&& textarea && textarea
.element_id_for_exact_range(range.start..mention_end) .element_id_for_exact_range(range.start..mention_end)
.is_some()) .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] #[test]
fn current_prefixed_token_targets_later_segment_between_bound_elements() { fn current_prefixed_token_targets_later_segment_between_bound_elements() {
let text = "$bound1$one$bound2$two$bound3"; 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 "