diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 76a5760658fb..6826dfe94b67 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2320,6 +2320,11 @@ impl ChatComposer { /// second `@` in `@scope/pkg@latest`), keep treating the surrounding /// whitespace-delimited token as the active token rather than starting a /// new token at that nested prefix. + /// - At an adjacent sigil, a bound left mention gives way to the editable token on the right. + /// Editable `$` fragments keep left affinity, while editable `@` fragments retain the + /// surrounding whitespace-delimited token so nested package prefixes keep working. + /// - On same-line separator whitespace, an `@` token on the right wins. `$` tokens keep left + /// affinity unless that mention is already bound. /// - If the token under the cursor starts with `prefix`, its byte range and /// text without the leading prefix are returned. When `allow_empty` is /// true, a lone prefix character yields `Some(String::new())` to surface hints. @@ -2347,6 +2352,14 @@ impl ChatComposer { // Split the line around the (now safe) cursor position. let before_cursor = &text[..safe_cursor]; let after_cursor = &text[safe_cursor..]; + let prefix_len = prefix.len_utf8(); + let is_horizontal_whitespace = |c: char| { + c.is_whitespace() + && !matches!( + c, + '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}' + ) + }; // Detect whether we're on whitespace at the cursor boundary. let at_whitespace = if safe_cursor < text.len() { @@ -2359,28 +2372,35 @@ impl ChatComposer { false }; - // Left candidate: token containing the cursor position. - let start_left = before_cursor + // Left candidate: token containing the cursor position. When the cursor is on separator + // whitespace, use the token immediately to the left of the same-line whitespace run. + let end_left = if at_whitespace { + before_cursor + .trim_end_matches(is_horizontal_whitespace) + .len() + } else { + let end_left_rel = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + safe_cursor + end_left_rel + }; + let start_left = text[..end_left] .char_indices() .rfind(|(_, c)| c.is_whitespace()) .map(|(idx, c)| idx + c.len_utf8()) .unwrap_or(0); - let end_left_rel = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_left = safe_cursor + end_left_rel; let token_left = if start_left < end_left { Some(&text[start_left..end_left]) } else { None }; - // Right candidate: token immediately after any whitespace from the cursor. + // Right candidate: token immediately after any same-line whitespace from the cursor. let ws_len_right: usize = after_cursor .chars() - .take_while(|c| c.is_whitespace()) + .take_while(|c| is_horizontal_whitespace(*c)) .map(char::len_utf8) .sum(); let start_right = safe_cursor + ws_len_right; @@ -2400,19 +2420,47 @@ impl ChatComposer { let left_match = token_left.filter(|t| t.starts_with(prefix)); let right_match = token_right.filter(|t| t.starts_with(prefix)); - let left_prefixed = - left_match.map(|t| (start_left..end_left, t[prefix.len_utf8()..].to_string())); + let left_prefixed = left_match.map(|t| (start_left..end_left, t[prefix_len..].to_string())); let right_prefixed = - right_match.map(|t| (start_right..end_right, t[prefix.len_utf8()..].to_string())); + right_match.map(|t| (start_right..end_right, t[prefix_len..].to_string())); + + if allow_empty && after_cursor.starts_with(prefix) { + let left_fragment = &text[start_left..safe_cursor]; + if let Some(left_token) = left_fragment.strip_prefix(prefix) + && left_token + .as_bytes() + .iter() + .all(|byte| is_mention_name_char(*byte) || prefix == '$' && *byte == b':') + { + let left_range = start_left..safe_cursor; + let left_is_editable = Self::prefixed_token_range_is_editable( + textarea, + prefix, + &left_range, + left_token, + ); + if left_token.is_empty() || prefix == '$' && left_is_editable { + return Some((left_range, left_token.to_string())); + } + if !left_is_editable { + return right_prefixed; + } + } + } if at_whitespace { - if right_prefixed.is_some() { + if prefix == '@' && right_prefixed.is_some() { return right_prefixed; } - if token_left.is_some_and(|t| t == prefix_str) { - return allow_empty.then(|| (start_left..end_left, String::new())); + if left_prefixed.as_ref().is_some_and(|(range, token)| { + !Self::prefixed_token_range_is_editable(textarea, prefix, range, token) + }) { + return right_prefixed.or(left_prefixed); + } + if token_left.is_some_and(|t| t == prefix_str) && !allow_empty { + return right_prefixed; } - return left_prefixed; + return left_prefixed.or(right_prefixed); } if after_cursor.starts_with(prefix) { let prefix_starts_token = before_cursor @@ -2443,19 +2491,14 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false) } - fn current_editable_at_token_range_with_options( - &self, - allow_empty: bool, - ) -> Option<(Range, String)> { - let (range, token) = - Self::current_prefixed_token_range(&self.draft.textarea, '@', allow_empty)?; - if self - .draft - .textarea - .element_id_for_exact_range(range.clone()) - .is_some() - { - return None; + fn prefixed_token_range_is_editable( + textarea: &TextArea, + prefix: char, + range: &Range, + token: &str, + ) -> bool { + if textarea.element_id_for_exact_range(range.clone()).is_some() { + return false; } let name_len = token @@ -2463,20 +2506,35 @@ impl ChatComposer { .iter() .take_while(|byte| is_mention_name_char(**byte)) .count(); - let mention_end = range.start + '@'.len_utf8() + name_len; - if name_len > 0 + let mention_end = range.start + prefix.len_utf8() + name_len; + !(name_len > 0 && mention_end < range.end - && ends_plaintext_at_mention(self.draft.textarea.text().as_bytes(), mention_end) - && self - .draft - .textarea + && ends_plaintext_at_mention(textarea.text().as_bytes(), mention_end) + && textarea .element_id_for_exact_range(range.start..mention_end) - .is_some() - { - return None; - } + .is_some()) + } - Some((range, token)) + /// Returns the active prefixed token only when its sigil and name remain editable plaintext. + /// + /// Atomic elements and tokens whose mention prefix is already atomic are excluded so bound + /// mentions are not offered for completion again. + fn current_editable_prefixed_token_range( + &self, + prefix: char, + allow_empty: bool, + ) -> Option<(Range, String)> { + let (range, token) = + Self::current_prefixed_token_range(&self.draft.textarea, prefix, allow_empty)?; + Self::prefixed_token_range_is_editable(&self.draft.textarea, prefix, &range, &token) + .then_some((range, token)) + } + + fn current_editable_at_token_range_with_options( + &self, + allow_empty: bool, + ) -> Option<(Range, String)> { + self.current_editable_prefixed_token_range('@', allow_empty) } fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { @@ -2504,7 +2562,7 @@ impl ChatComposer { if !self.mentions_enabled() { return None; } - Self::current_prefixed_token_range(&self.draft.textarea, '$', /*allow_empty*/ true) + self.current_editable_prefixed_token_range('$', /*allow_empty*/ true) } /// Replace the active `@token` (the one under the cursor) with `path`. @@ -6312,6 +6370,322 @@ mod tests { assert_eq!(mention.path, Some("plugin://sample@test".to_string())); } + fn test_skill_metadata(name: &str, description: &str, path: &str) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: description.to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf(path).abs(), + scope: crate::test_support::skill_scope_user(), + plugin_id: None, + } + } + + fn configure_partially_bound_skill_mentions(composer: &mut ChatComposer) { + let bound_skill_path = test_path_buf("/tmp/bound-skill/SKILL.md").abs(); + composer.set_skill_mentions(Some(vec![test_skill_metadata( + "unbound-skill", + "Example skill used in tests.", + "/tmp/unbound-skill/SKILL.md", + )])); + + composer.set_text_content_with_mention_bindings( + "$unbound-skill $bound-skill continue".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '$', + mention: "bound-skill".to_string(), + path: bound_skill_path.display().to_string(), + }], + ); + composer.draft.textarea.set_cursor("$unbound-skill ".len()); + composer.sync_popups(); + } + + fn configure_bound_skill_left_of_unbound_skill(composer: &mut ChatComposer) { + composer.set_skill_mentions(Some(vec![test_skill_metadata( + "unbound-skill", + "Example skill used in tests.", + "/tmp/unbound-skill/SKILL.md", + )])); + composer.set_text_content_with_mention_bindings( + "$bound-skill$unbound-skill".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '$', + mention: "bound-skill".to_string(), + path: test_path_buf("/tmp/bound-skill/SKILL.md") + .abs() + .display() + .to_string(), + }], + ); + composer.draft.textarea.set_cursor("$bound-skill".len()); + composer.sync_popups(); + } + + fn configure_colon_qualified_skill_left_of_adjacent_skill(composer: &mut ChatComposer) { + composer.set_skill_mentions(Some(vec![test_skill_metadata( + "google-calendar:availability", + "Find availability and plan event changes.", + "/tmp/google-calendar/availability/SKILL.md", + )])); + let text = "$google-calendar:availability$unbound-skill"; + composer.set_text_content(text.to_string(), Vec::new(), Vec::new()); + composer + .draft + .textarea + .set_cursor("$google-calendar:availability".len()); + composer.sync_popups(); + } + + fn configure_bound_plugin_left_of_unbound_plugin(composer: &mut ChatComposer) { + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "other@test".to_string(), + display_name: "other".to_string(), + description: Some("Plugin used to test adjacent mention targeting.".to_string()), + has_skills: false, + mcp_server_names: vec!["other".to_string()], + app_connector_ids: Vec::new(), + }])); + composer.set_text_content_with_mention_bindings( + "@sample@other".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + composer.draft.textarea.set_cursor("@sample".len()); + composer.sync_popups(); + } + + fn configure_whitespace_separated_plugin_mentions(composer: &mut ChatComposer) { + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "new@test".to_string(), + display_name: "new".to_string(), + description: Some("Plugin to the right of the cursor.".to_string()), + has_skills: false, + mcp_server_names: vec!["new".to_string()], + app_connector_ids: Vec::new(), + }])); + composer.set_text_content("@old @new".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@old".len()); + composer.sync_popups(); + } + + #[test] + fn skill_popup_targets_unbound_mention_left_of_bound_mention() { + let (mut composer, _rx) = new_test_composer(); + configure_partially_bound_skill_mentions(&mut composer); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + composer.current_text(), + "$unbound-skill $bound-skill continue" + ); + assert_eq!( + composer.mention_bindings(), + vec![ + MentionBinding { + sigil: '$', + mention: "unbound-skill".to_string(), + path: test_path_buf("/tmp/unbound-skill/SKILL.md") + .abs() + .display() + .to_string(), + }, + MentionBinding { + sigil: '$', + mention: "bound-skill".to_string(), + path: test_path_buf("/tmp/bound-skill/SKILL.md") + .abs() + .display() + .to_string(), + }, + ] + ); + } + + #[test] + fn skill_popup_targets_expected_adjacent_mention() { + for (setup, expected) in [ + ( + configure_bound_skill_left_of_unbound_skill as fn(&mut ChatComposer), + "$unbound-skill", + ), + ( + configure_colon_qualified_skill_left_of_adjacent_skill, + "$google-calendar:availability", + ), + ] { + let (mut composer, _rx) = new_test_composer(); + setup(&mut composer); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected skill popup for {expected}"); + }; + assert_eq!( + popup + .selected_mention() + .expect("expected adjacent skill mention to be selected") + .insert_text, + expected + ); + } + } + + #[test] + fn skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention_snapshot() { + snapshot_composer_state( + "skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention", + /*enhanced_keys_supported*/ false, + configure_bound_skill_left_of_unbound_skill, + ); + } + + #[test] + fn skill_popup_targets_colon_qualified_mention_left_of_adjacent_skill_snapshot() { + snapshot_composer_state( + "skill_popup_targets_colon_qualified_mention_left_of_adjacent_skill", + /*enhanced_keys_supported*/ false, + configure_colon_qualified_skill_left_of_adjacent_skill, + ); + } + + #[test] + fn unified_mention_popup_targets_expected_plugin() { + for (setup, expected) in [ + ( + configure_bound_plugin_left_of_unbound_plugin as fn(&mut ChatComposer), + ("@other", "plugin://other@test"), + ), + ( + configure_whitespace_separated_plugin_mentions, + ("@new", "plugin://new@test"), + ), + ] { + let (mut composer, _rx) = new_test_composer(); + setup(&mut composer); + + let ActivePopup::MentionV2(popup) = &composer.popups.active else { + panic!("expected unified mention popup for {}", expected.0); + }; + let Some(MentionV2Selection::Tool { insert_text, path }) = popup.selected() else { + panic!("expected plugin mention {} to be selected", expected.0); + }; + assert_eq!( + (insert_text.as_str(), path.as_deref()), + (expected.0, Some(expected.1)) + ); + } + } + + #[test] + fn unified_mention_popup_targets_unbound_plugin_right_of_adjacent_bound_plugin_snapshot() { + snapshot_composer_state( + "unified_mention_popup_targets_unbound_plugin_right_of_adjacent_bound_plugin", + /*enhanced_keys_supported*/ false, + configure_bound_plugin_left_of_unbound_plugin, + ); + } + + #[test] + fn unified_mention_popup_targets_plugin_right_of_whitespace_snapshot() { + snapshot_composer_state( + "unified_mention_popup_targets_plugin_right_of_whitespace", + /*enhanced_keys_supported*/ false, + configure_whitespace_separated_plugin_mentions, + ); + } + + #[test] + fn skill_completion_advances_past_existing_separator() { + let (mut composer, _rx) = new_test_composer(); + configure_partially_bound_skill_mentions(&mut composer); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + composer.insert_str("foo"); + + assert_eq!( + composer.current_text(), + "$unbound-skill foo $bound-skill continue" + ); + } + + #[test] + fn skill_completion_does_not_dismiss_identical_next_mention() { + let (mut composer, _rx) = new_test_composer(); + composer.set_skill_mentions(Some(vec![test_skill_metadata( + "skill", + "Example skill used in tests.", + "/tmp/skill/SKILL.md", + )])); + composer.set_text_content("$skill $skill".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("$skill".len()); + composer.sync_popups(); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(composer.draft.textarea.cursor(), "$skill ".len()); + assert!(matches!(composer.popups.active, ActivePopup::Skill(_))); + } + + #[test] + fn typing_skill_prefix_before_existing_skill_starts_new_mention() { + for inserted in ["$", "$r"] { + let (mut composer, _rx) = new_test_composer(); + let rustdoc_path = test_path_buf("/tmp/rustdoc/SKILL.md").abs(); + composer.set_skill_mentions(Some(vec![test_skill_metadata( + "rustdoc", + "Add focused RustDoc comments.", + "/tmp/rustdoc/SKILL.md", + )])); + composer.set_text_content("$simplify-code".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(/*pos*/ 0); + + composer.insert_str(inserted); + + let ActivePopup::Skill(popup) = &composer.popups.active else { + panic!("expected skill popup for new mention fragment {inserted:?}"); + }; + let mention = popup + .selected_mention() + .expect("expected skill mention to be selected"); + assert_eq!(mention.insert_text, "$rustdoc".to_string()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(composer.current_text(), "$rustdoc $simplify-code"); + assert_eq!( + composer.mention_bindings(), + vec![MentionBinding { + sigil: '$', + mention: "rustdoc".to_string(), + path: rustdoc_path.display().to_string(), + }] + ); + } + } + + #[test] + fn skill_popup_targets_unbound_mention_left_of_bound_mention_snapshot() { + snapshot_composer_state( + "skill_popup_targets_unbound_mention_left_of_bound_mention", + /*enhanced_keys_supported*/ false, + configure_partially_bound_skill_mentions, + ); + } + #[test] fn set_skill_mentions_refreshes_open_mention_popup() { let (tx, _rx) = unbounded_channel::(); @@ -6741,6 +7115,57 @@ mod tests { } } + #[test] + fn current_prefixed_token_affinity_does_not_cross_line_break() { + for (text, prefix, allow_empty) in [ + ("@file\n continue", '@', false), + ("continue \n@file", '@', false), + ("$skill\n continue", '$', true), + ("continue \n$skill", '$', true), + ] { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(text.find(" ").expect("indentation present") + 1); + + assert_eq!( + ChatComposer::current_prefixed_token_range(&textarea, prefix, allow_empty), + None + ); + } + } + + #[test] + fn current_prefixed_token_prefers_lone_prefix_before_adjacent_token() { + for (text, cursor, expected_query) in [ + ("$$simplify-code", "$".len(), ""), + ("$r$simplify-code", "$r".len(), "r"), + ] { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(cursor); + + assert_eq!( + ChatComposer::current_prefixed_token_range( + &textarea, '$', /*allow_empty*/ true + ), + Some((0..cursor, expected_query.to_string())) + ); + } + } + + #[test] + fn current_prefixed_token_keeps_nested_dollar_prefix_in_same_token() { + let text = "$HOME/$USER"; + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(text.find("$USER").expect("nested prefix present")); + + assert_eq!( + ChatComposer::current_prefixed_token_range(&textarea, '$', /*allow_empty*/ true), + Some((0..text.len(), "HOME/$USER".to_string())) + ); + } + #[test] fn test_current_at_token_tracks_tokens_with_second_at() { let input = "npx -y @kaeawc/auto-mobile@latest"; @@ -9144,6 +9569,21 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); } + #[test] + fn file_completion_advances_past_existing_separator() { + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@ma next", + /*cursor*/ "@ma ".len(), + "ma", + PathBuf::from("src/main.rs"), + ); + composer.insert_str("foo"); + + assert_eq!(composer.current_text(), "src/main.rs foo next"); + } + #[test] fn file_completion_preserves_separator_before_existing_suffix() { let (mut composer, _rx) = new_test_composer(); @@ -9266,6 +9706,28 @@ mod tests { assert!(matches!(composer.popups.active, ActivePopup::None)); } + #[test] + fn image_completion_advances_past_existing_separator() { + let tmp = tempdir().expect("create TempDir"); + let image_path = tmp.path().join("image.png"); + let image: ImageBuffer, Vec> = + ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255])); + image.save(&image_path).expect("write temp png"); + + let (mut composer, _rx) = new_test_composer(); + complete_file( + &mut composer, + "@image next", + /*cursor*/ "@image ".len(), + "image", + image_path.clone(), + ); + composer.insert_str("foo"); + + assert_eq!(composer.current_text(), "[Image #1] foo next"); + assert_eq!(composer.local_image_paths(), vec![image_path]); + } + /// Behavior: multiple paste operations can coexist; placeholders should be expanded to their /// original content on submission. #[test] diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_colon_qualified_mention_left_of_adjacent_skill.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_colon_qualified_mention_left_of_adjacent_skill.snap new file mode 100644 index 000000000000..b906b43266ab --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_colon_qualified_mention_left_of_adjacent_skill.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $google-calendar:availability$unbound-skill " +" " +" " +" " +" " +" availability (google-cale... [Skill] Find availability and plan event changes. " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap new file mode 100644 index 000000000000..d2fe3b11e030 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $unbound-skill $bound-skill continue " +" " +" " +" " +" " +" unbound-skill [Skill] Example skill used in tests. " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap new file mode 100644 index 000000000000..a3423c8bba77 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $bound-skill$unbound-skill " +" " +" " +" " +" " +" unbound-skill [Skill] Example skill used in tests. " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_targets_plugin_right_of_whitespace.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_targets_plugin_right_of_whitespace.snap new file mode 100644 index 000000000000..d4ce196b057f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_targets_plugin_right_of_whitespace.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @old @new " +" " +"> new Plugin to the right of the cursor. Plugin" +" " +" " +" " +" " +" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_targets_unbound_plugin_right_of_adjacent_bound_plugin.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_targets_unbound_plugin_right_of_adjacent_bound_plugin.snap new file mode 100644 index 000000000000..56e9b90f9f52 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_targets_unbound_plugin_right_of_adjacent_bound_plugin.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @sample@other " +" " +"> other Plugin used to test adjacent mention targeting. Plugin" +" " +" " +" " +" " +" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins "