mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Attach typed punctuation to completed skill mentions (#60180)
## Why Skill completion inserts a trailing space, leaving unwanted whitespace when the next typed character is punctuation, such as `$rustdoc ,`. ## What changed Replace the space inserted by skill completion when immediately followed by common closing punctuation, producing `$rustdoc,` instead. Support both skill mention pickers, including full-width punctuation and `》`, while preserving skill bindings. Preserve existing draft whitespace and keep the separator for pasted text or punctuation entered after cursor navigation. Classify buffered punctuation as typing or a paste burst before deciding whether to remove the space. ## Testing Add regression tests for punctuation, existing spacing, cursor navigation, and paste bursts, plus a snapshot covering `》`, recovery state, and preserved skill bindings. GitOrigin-RevId: 8988add8092e8cb5f0618ef834590d52a81ad576
This commit is contained in:
committed by
copyberry
parent
5226f2fb4b
commit
1455fda735
@@ -68,6 +68,9 @@
|
||||
//! horizontal separator. It advances across an existing separator when no suffix would be joined;
|
||||
//! otherwise it inserts a space, preserving the existing separator before a non-whitespace suffix.
|
||||
//! It also inserts a space rather than crossing a line break.
|
||||
//! After selecting a skill, common closing punctuation, including full-width sentence marks and
|
||||
//! the Chinese closing book-title bracket (`》`), takes the place of a newly inserted separator.
|
||||
//! Existing whitespace stays part of the draft.
|
||||
//!
|
||||
//! `Esc` records the active token as dismissed. A completed value that begins with `@` or `$` is
|
||||
//! also re-dismissed before popup synchronization, because separator affinity can still identify
|
||||
@@ -262,8 +265,9 @@
|
||||
//! Some terminals, especially Windows, deliver pastes as rapid `KeyCode::Char`/`Enter`/`Tab` events.
|
||||
//! [`PasteBurst`](super::paste_burst::PasteBurst) buffers text-producing keys (plain, Shift, or
|
||||
//! Windows AltGr) and integrates buffered text through the composer, avoiding transient shortcut UI.
|
||||
//! It briefly holds the first ASCII char to detect a burst. Non-ASCII chars appear immediately
|
||||
//! for IME responsiveness, but still participate in burst detection.
|
||||
//! It briefly holds the first ASCII char or an eligible skill-completion punctuation mark to detect
|
||||
//! a burst. Other non-ASCII chars appear immediately for IME responsiveness, but still participate
|
||||
//! in burst detection.
|
||||
//! Parent views must keep flushing editors that lose focus while input is buffered; a hidden
|
||||
//! editor's pending burst can otherwise keep the shared draw loop waiting indefinitely.
|
||||
//!
|
||||
@@ -1627,6 +1631,7 @@ impl ChatComposer {
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.pending_pastes.clear();
|
||||
self.draft.mention_bindings.clear();
|
||||
self.draft.skill_completion_space = None;
|
||||
|
||||
let (text, text_elements) = match shell_prefix {
|
||||
ShellPrefixHandling::Interpret => self.imported_text_for_textarea(text, text_elements),
|
||||
@@ -1666,6 +1671,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub(crate) fn set_current_cursor(&mut self, cursor: usize) {
|
||||
self.draft.skill_completion_space = None;
|
||||
let visible_cursor = if self.draft.is_bash_mode {
|
||||
cursor.saturating_sub(1)
|
||||
} else {
|
||||
@@ -2027,6 +2033,14 @@ impl ChatComposer {
|
||||
return (InputResult::None, false);
|
||||
}
|
||||
|
||||
let is_char = matches!(key_event.code, KeyCode::Char(_));
|
||||
if !is_char {
|
||||
if self.draft.skill_completion_space.is_some() && self.draft.paste_burst.is_active() {
|
||||
self.handle_paste_burst_flush(tokio::time::Instant::now().into_std());
|
||||
}
|
||||
self.draft.skill_completion_space = None;
|
||||
}
|
||||
|
||||
if self.history_search.is_none()
|
||||
&& !self.popups.active()
|
||||
&& self.config.slash_commands_enabled
|
||||
@@ -2042,6 +2056,9 @@ impl ChatComposer {
|
||||
self.draft.textarea_state.get_mut().follow_cursor();
|
||||
let before = self.before_sparkle_key(key_event);
|
||||
let result = self.handle_key_event_inner(key_event);
|
||||
if is_char && !self.draft.paste_burst.is_active() {
|
||||
self.draft.skill_completion_space = None;
|
||||
}
|
||||
self.after_sparkle_key(before, &result.0);
|
||||
result
|
||||
}
|
||||
@@ -2496,7 +2513,9 @@ impl ChatComposer {
|
||||
/// Another separator is preserved before any non-whitespace suffix so subsequent typing does
|
||||
/// not merge into it. Line breaks are never reused as separators; a space is inserted before
|
||||
/// them so subsequent typing stays on the completed token's line.
|
||||
fn advance_past_completion_separator(&mut self) {
|
||||
///
|
||||
/// Returns the position of the space when completion inserted one.
|
||||
fn advance_past_completion_separator(&mut self) -> Option<usize> {
|
||||
let cursor = self.draft.textarea.cursor();
|
||||
let existing_separator_len = self.draft.textarea.text()[cursor..]
|
||||
.chars()
|
||||
@@ -2520,14 +2539,13 @@ impl ChatComposer {
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| !c.is_whitespace());
|
||||
if separator_precedes_suffix {
|
||||
self.draft.textarea.insert_str_at(cursor, " ");
|
||||
} else {
|
||||
if !separator_precedes_suffix {
|
||||
self.draft.textarea.set_cursor(after_separator);
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
self.draft.textarea.insert_str_at(cursor, " ");
|
||||
}
|
||||
self.draft.textarea.insert_str_at(cursor, " ");
|
||||
Some(cursor)
|
||||
}
|
||||
|
||||
/// Dismisses popup synchronization only for the exact token occurrence just inserted.
|
||||
@@ -2918,7 +2936,13 @@ impl ChatComposer {
|
||||
task_mentions.clear();
|
||||
}
|
||||
|
||||
self.advance_past_completion_separator();
|
||||
let inserted_space = self.advance_past_completion_separator();
|
||||
let is_skill = path.is_some_and(|path| {
|
||||
self.skills
|
||||
.as_deref()
|
||||
.is_some_and(|skills| skills.iter().any(|skill| skill.path.as_str() == path))
|
||||
});
|
||||
self.draft.skill_completion_space = inserted_space.filter(|_| is_skill);
|
||||
if let Some(sigil) = insert_text.chars().next()
|
||||
&& matches!(sigil, '$' | '@')
|
||||
{
|
||||
@@ -3747,6 +3771,15 @@ impl ChatComposer {
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META);
|
||||
|
||||
let completion_punctuation = match input.code {
|
||||
KeyCode::Char(ch) if !has_non_text_modifier => self
|
||||
.draft
|
||||
.skill_completion_space_for_char(ch)
|
||||
.map(|space| (space, ch)),
|
||||
_ => None,
|
||||
};
|
||||
self.draft.skill_completion_space = None;
|
||||
|
||||
// Intercept text-producing Char inputs to optionally accumulate into a burst buffer.
|
||||
//
|
||||
// This preserves plain, Shift, and Windows AltGr input while keeping shortcut modifiers
|
||||
@@ -3760,9 +3793,9 @@ impl ChatComposer {
|
||||
&& !self.draft.disable_paste_burst
|
||||
&& self.draft.textarea.allows_paste_burst()
|
||||
{
|
||||
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
|
||||
// holding the first char while still allowing burst detection for paste input.
|
||||
if !ch.is_ascii() {
|
||||
// Keep IME input responsive. Skill-completion punctuation needs classification
|
||||
// before it can replace the inserted separator.
|
||||
if !ch.is_ascii() && completion_punctuation.is_none() {
|
||||
return self.handle_non_ascii_char(input, now);
|
||||
}
|
||||
|
||||
@@ -3799,6 +3832,8 @@ impl ChatComposer {
|
||||
}
|
||||
CharDecision::RetainFirstChar => {
|
||||
// Keep the first fast char pending momentarily.
|
||||
self.draft.skill_completion_space =
|
||||
completion_punctuation.map(|(space, _)| space);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
@@ -3870,7 +3905,20 @@ impl ChatComposer {
|
||||
self.draft.textarea.finish_vim_repeat();
|
||||
}
|
||||
} else {
|
||||
self.draft.textarea.input(input);
|
||||
let textarea = &mut self.draft.textarea;
|
||||
let before_len = textarea.text().len();
|
||||
textarea.input(input);
|
||||
// Check that the key inserted punctuation, since an editor binding can consume it.
|
||||
if let Some((space, ch)) = completion_punctuation
|
||||
&& textarea.text().len() == before_len + ch.len_utf8()
|
||||
&& textarea.cursor() == space + 1 + ch.len_utf8()
|
||||
&& textarea
|
||||
.text()
|
||||
.get(space + 1..)
|
||||
.is_some_and(|suffix| suffix.starts_with(ch))
|
||||
{
|
||||
textarea.replace_range(space..space + 1, "");
|
||||
}
|
||||
vim_register::sync(self);
|
||||
}
|
||||
if self.draft.textarea.prefix_insertion_revision() != prefix_insertion_revision {
|
||||
@@ -5039,6 +5087,10 @@ mod snapshot_tests;
|
||||
#[path = "chat_composer/mentions_layout_tests.rs"]
|
||||
mod mentions_layout_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "chat_composer/skill_completion_tests.rs"]
|
||||
mod skill_completion_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -5271,7 +5323,7 @@ mod tests {
|
||||
insta::assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
|
||||
pub(super) fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
|
||||
where
|
||||
F: FnOnce(&mut ChatComposer),
|
||||
{
|
||||
@@ -6963,7 +7015,7 @@ mod tests {
|
||||
assert_eq!(mention.path, Some("plugin://sample@test".to_string()));
|
||||
}
|
||||
|
||||
fn test_skill_metadata(name: &str) -> SkillMetadata {
|
||||
pub(super) fn test_skill_metadata(name: &str) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: name.to_string(),
|
||||
description: "Example skill used in tests.".to_string(),
|
||||
@@ -6977,7 +7029,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_skill_binding(name: &str) -> MentionBinding {
|
||||
pub(super) fn test_skill_binding(name: &str) -> MentionBinding {
|
||||
MentionBinding {
|
||||
sigil: '$',
|
||||
mention: name.to_string(),
|
||||
@@ -7795,50 +7847,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_unified_mention_popup_snapshot() {
|
||||
snapshot_composer_state(
|
||||
"default_unified_mention_popup",
|
||||
/*enhanced_keys_supported*/ false,
|
||||
|composer| {
|
||||
let features = codex_features::Features::with_defaults();
|
||||
composer
|
||||
.set_mentions_v2_enabled(features.enabled(codex_features::Feature::MentionsV2));
|
||||
composer.set_skill_mentions(Some(vec![
|
||||
SkillMetadata {
|
||||
name: "sample-tools:search".to_string(),
|
||||
plugin_id: Some("sample@test".to_string()),
|
||||
..test_skill_metadata("sample-search")
|
||||
},
|
||||
SkillMetadata {
|
||||
name: "sample:summarize".to_string(),
|
||||
plugin_id: Some("sample@test".to_string()),
|
||||
..test_skill_metadata("sample-summarize")
|
||||
},
|
||||
SkillMetadata {
|
||||
name: "sample:notes".to_string(),
|
||||
..test_skill_metadata("sample-notes")
|
||||
},
|
||||
SkillMetadata {
|
||||
name: "sample:other".to_string(),
|
||||
plugin_id: Some("sample@other-marketplace".to_string()),
|
||||
..test_skill_metadata("sample-other")
|
||||
},
|
||||
]));
|
||||
composer.set_text_content("@sa".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "Sample Plugin".to_string(),
|
||||
plugin_namespace: Some("sample".to_string()),
|
||||
description: Some("Plugin with skills and an MCP server".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample".to_string()],
|
||||
app_connector_ids: Vec::new(),
|
||||
}]));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_mention_popup_and_selected_composer_snapshots() {
|
||||
for selected in [false, true] {
|
||||
|
||||
@@ -17,6 +17,8 @@ pub(super) struct DraftState {
|
||||
pub(super) input_disabled_placeholder: Option<String>,
|
||||
pub(super) paste_burst: PasteBurst,
|
||||
pub(super) disable_paste_burst: bool,
|
||||
/// Byte offset of the space added by the last skill completion, until the next key is resolved.
|
||||
pub(super) skill_completion_space: Option<usize>,
|
||||
pub(super) mention_bindings: HashMap<u64, ComposerMentionBinding>,
|
||||
pub(super) recent_submission_mention_bindings: Vec<MentionBinding>,
|
||||
}
|
||||
@@ -32,10 +34,22 @@ impl DraftState {
|
||||
input_disabled_placeholder: None,
|
||||
paste_burst: PasteBurst::default(),
|
||||
disable_paste_burst: false,
|
||||
skill_completion_space: None,
|
||||
mention_bindings: HashMap::new(),
|
||||
recent_submission_mention_bindings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn skill_completion_space_for_char(&self, ch: char) -> Option<usize> {
|
||||
self.skill_completion_space.filter(|&space| {
|
||||
",.;:!?)]}。,、!?》".contains(ch)
|
||||
&& self.textarea.allows_paste_burst()
|
||||
&& !self.textarea.is_vim_replace_mode()
|
||||
&& self.textarea.cursor() == space + 1
|
||||
&& self.textarea.mouse_selection_range().is_none()
|
||||
&& self.textarea.text().get(space..space + 1) == Some(" ")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -154,6 +154,7 @@ impl ChatComposer {
|
||||
.textarea
|
||||
.handle_mouse(event, *self.draft.textarea_state.borrow());
|
||||
if handled {
|
||||
self.draft.skill_completion_space = None;
|
||||
self.draft.textarea_state.borrow_mut().follow_cursor();
|
||||
self.attachments.clear_remote_image_selection();
|
||||
self.sync_popups();
|
||||
|
||||
@@ -26,6 +26,7 @@ impl ChatComposer {
|
||||
text: &str,
|
||||
shell_prefix: ShellPrefixHandling,
|
||||
) {
|
||||
self.draft.skill_completion_space = None;
|
||||
if !text.is_empty() && self.sparkle.draft.get() == sparkle::SparkleDraft::Untouched {
|
||||
self.dismiss_sparkle();
|
||||
}
|
||||
@@ -71,7 +72,15 @@ impl ChatComposer {
|
||||
textarea.replace_vim_text(&ch.to_string());
|
||||
}
|
||||
}
|
||||
FlushResult::Typed(ch) => textarea.insert_str_at(snapshot.cursor, &ch.to_string()),
|
||||
FlushResult::Typed(ch) => {
|
||||
if let Some(space) = self.draft.skill_completion_space_for_char(ch) {
|
||||
let space = space + usize::from(self.draft.is_bash_mode);
|
||||
textarea.replace_range(space..space + 1, &ch.to_string());
|
||||
snapshot.cursor = space + ch.len_utf8();
|
||||
} else {
|
||||
textarea.insert_str_at(snapshot.cursor, &ch.to_string());
|
||||
}
|
||||
}
|
||||
FlushResult::Paste(pasted) => textarea.insert_str_at(snapshot.cursor, &pasted),
|
||||
FlushResult::None => {}
|
||||
}
|
||||
@@ -154,7 +163,15 @@ impl ChatComposer {
|
||||
true
|
||||
}
|
||||
FlushResult::Typed(ch) => {
|
||||
let completion_space = self.draft.skill_completion_space_for_char(ch);
|
||||
let started_vim_edit = completion_space.is_some() && self.begin_direct_vim_edit();
|
||||
if let Some(space) = completion_space {
|
||||
self.draft.textarea.replace_range(space..space + 1, "");
|
||||
}
|
||||
self.insert_str(ch.to_string().as_str());
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
true
|
||||
}
|
||||
FlushResult::None => false,
|
||||
@@ -189,6 +206,7 @@ impl ChatComposer {
|
||||
pasted: String,
|
||||
shell_prefix: ShellPrefixHandling,
|
||||
) -> bool {
|
||||
self.draft.skill_completion_space = None;
|
||||
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let pasted = sanitize_user_text(pasted.into());
|
||||
if self.history_search.is_some() {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#![cfg(test)]
|
||||
//! Skill completion and its rendering in both mention pickers.
|
||||
|
||||
use super::tests::new_test_composer;
|
||||
use super::tests::snapshot_composer_state;
|
||||
use super::tests::test_skill_binding;
|
||||
use super::tests::test_skill_metadata;
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn skill_completion_attaches_typed_punctuation_snapshot() {
|
||||
snapshot_composer_state(
|
||||
"skill_completion_attaches_typed_punctuation",
|
||||
/*enhanced_keys_supported*/ false,
|
||||
|composer| {
|
||||
composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")]));
|
||||
composer.insert_str("请用 $ru");
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('》'), KeyModifiers::NONE));
|
||||
let recovered = composer.recovery_snapshot();
|
||||
let expected = "请用 $rustdoc》";
|
||||
assert_eq!(
|
||||
(recovered.text.as_str(), recovered.cursor),
|
||||
(expected, expected.len())
|
||||
);
|
||||
composer.handle_paste_burst_flush(
|
||||
tokio::time::Instant::now().into_std() + PasteBurst::recommended_flush_delay(),
|
||||
);
|
||||
assert_eq!(
|
||||
composer.current_text_elements(),
|
||||
vec![TextElement::new((7..15).into(), Some("$rustdoc".into()))]
|
||||
);
|
||||
assert_eq!(
|
||||
composer.mention_bindings(),
|
||||
vec![test_skill_binding("rustdoc")]
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_skill_completion_keeps_draft_spacing_after_punctuation() {
|
||||
for (suffix, ch, modifiers, expected) in [
|
||||
("", ',', KeyModifiers::NONE, "$rustdoc,"),
|
||||
("", '!', KeyModifiers::SHIFT, "$rustdoc!"),
|
||||
(
|
||||
" then continue",
|
||||
'!',
|
||||
KeyModifiers::SHIFT,
|
||||
"$rustdoc! then continue",
|
||||
),
|
||||
(" ", '!', KeyModifiers::SHIFT, "$rustdoc !"),
|
||||
("", '。', KeyModifiers::NONE, "$rustdoc。"),
|
||||
("", ',', KeyModifiers::NONE, "$rustdoc,"),
|
||||
("", '、', KeyModifiers::NONE, "$rustdoc、"),
|
||||
("", '!', KeyModifiers::NONE, "$rustdoc!"),
|
||||
("", '?', KeyModifiers::NONE, "$rustdoc?"),
|
||||
("", '》', KeyModifiers::NONE, "$rustdoc》"),
|
||||
("", '/', KeyModifiers::NONE, "$rustdoc /"),
|
||||
("", '\\', KeyModifiers::NONE, "$rustdoc \\"),
|
||||
] {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
composer.set_mentions_v2_enabled(/*enabled*/ true);
|
||||
composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")]));
|
||||
composer.set_text_content(format!("@ru{suffix}"), Vec::new(), Vec::new());
|
||||
composer.set_current_cursor("@ru".len());
|
||||
composer.sync_popups();
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), modifiers));
|
||||
composer.handle_paste_burst_flush(
|
||||
tokio::time::Instant::now().into_std() + PasteBurst::recommended_flush_delay(),
|
||||
);
|
||||
|
||||
assert_eq!(composer.current_text(), expected);
|
||||
assert_eq!(
|
||||
composer.mention_bindings(),
|
||||
vec![test_skill_binding("rustdoc")]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_completion_keeps_space_after_cursor_navigation() {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
composer.set_disable_paste_burst(/*disabled*/ true);
|
||||
composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")]));
|
||||
composer.insert_str("$ru");
|
||||
for key in [
|
||||
KeyCode::Tab,
|
||||
KeyCode::Left,
|
||||
KeyCode::Right,
|
||||
KeyCode::Char(','),
|
||||
] {
|
||||
composer.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
assert_eq!(composer.current_text(), "$rustdoc ,");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn skill_completion_keeps_space_before_a_punctuation_paste_burst() {
|
||||
for (ch, expected) in [(',', "$rustdoc ,x\n"), ('。', "$rustdoc 。x\n")] {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")]));
|
||||
composer.insert_str("$ru");
|
||||
for key in [
|
||||
KeyCode::Tab,
|
||||
KeyCode::Char(ch),
|
||||
KeyCode::Char('x'),
|
||||
KeyCode::Enter,
|
||||
] {
|
||||
let (result, _) = composer.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
assert!(matches!(result, InputResult::None));
|
||||
}
|
||||
tokio::time::advance(PasteBurst::recommended_active_flush_delay()).await;
|
||||
composer.flush_paste_burst_if_due();
|
||||
|
||||
assert_eq!(composer.current_text(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_unified_mention_popup_snapshot() {
|
||||
snapshot_composer_state(
|
||||
"default_unified_mention_popup",
|
||||
/*enhanced_keys_supported*/ false,
|
||||
|composer| {
|
||||
let features = codex_features::Features::with_defaults();
|
||||
composer.set_mentions_v2_enabled(features.enabled(codex_features::Feature::MentionsV2));
|
||||
composer.set_skill_mentions(Some(vec![
|
||||
SkillMetadata {
|
||||
name: "sample-tools:search".to_string(),
|
||||
plugin_id: Some("sample@test".to_string()),
|
||||
..test_skill_metadata("sample-search")
|
||||
},
|
||||
SkillMetadata {
|
||||
name: "sample:summarize".to_string(),
|
||||
plugin_id: Some("sample@test".to_string()),
|
||||
..test_skill_metadata("sample-summarize")
|
||||
},
|
||||
SkillMetadata {
|
||||
name: "sample:notes".to_string(),
|
||||
..test_skill_metadata("sample-notes")
|
||||
},
|
||||
SkillMetadata {
|
||||
name: "sample:other".to_string(),
|
||||
plugin_id: Some("sample@other-marketplace".to_string()),
|
||||
..test_skill_metadata("sample-other")
|
||||
},
|
||||
]));
|
||||
composer.set_text_content("@sa".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "Sample Plugin".to_string(),
|
||||
plugin_namespace: Some("sample".to_string()),
|
||||
description: Some("Plugin with skills and an MCP server".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample".to_string()],
|
||||
app_connector_ids: Vec::new(),
|
||||
}]));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
//! text-producing character events (plain, Shift, or Windows AltGr) and uses the full buffering
|
||||
//! decisions to either:
|
||||
//!
|
||||
//! - briefly hold a first ASCII char (flicker suppression),
|
||||
//! - briefly hold a first char (flicker suppression),
|
||||
//! - buffer a burst as a single pasted string, or
|
||||
//! - let input flow through as normal typing.
|
||||
//!
|
||||
@@ -22,8 +22,9 @@
|
||||
//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds
|
||||
//! it events and then applies the chosen action:
|
||||
//!
|
||||
//! - For each text-producing `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or
|
||||
//! [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME).
|
||||
//! - For each text-producing `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII and
|
||||
//! eligible skill-completion punctuation) or [`PasteBurst::on_plain_char_no_hold`] (other
|
||||
//! non-ASCII/IME input).
|
||||
//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via
|
||||
//! [`PasteBurst::append_char_to_buffer`].
|
||||
//! - On Enter or Tab, [`PasteBurst::append_control_char_if_active`] promotes a held first character
|
||||
@@ -48,7 +49,7 @@
|
||||
//! - `active`: true while we are still *actively* accepting characters into the current burst.
|
||||
//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`.
|
||||
//! A non-empty buffer is treated as "in burst context" even if `active` has been cleared.
|
||||
//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must
|
||||
//! - `pending_first_char`: a single held char used for flicker suppression. The caller must
|
||||
//! not render this char until it joins a burst through `BeginBufferFromPending` or
|
||||
//! `append_control_char_if_active`, or flushes as a normal typed char (`FlushResult::Typed`).
|
||||
//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for
|
||||
@@ -91,7 +92,7 @@
|
||||
//!
|
||||
//! There are two ways callers end burst handling, and they are not interchangeable:
|
||||
//!
|
||||
//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char)
|
||||
//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first char)
|
||||
//! so the caller can apply it through the normal paste path before handling an unrelated input.
|
||||
//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does
|
||||
//! not get grouped into the previous burst. It assumes the caller has already flushed any buffer
|
||||
@@ -101,7 +102,7 @@
|
||||
//! # States (Conceptually)
|
||||
//!
|
||||
//! - **Idle**: no buffered text, no pending char.
|
||||
//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to
|
||||
//! - **Pending first char**: `pending_first_char` holds one char for up to
|
||||
//! `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows.
|
||||
//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes.
|
||||
//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after
|
||||
@@ -110,7 +111,7 @@
|
||||
//! # ASCII vs Non-ASCII
|
||||
//!
|
||||
//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first
|
||||
//! ASCII char and avoid flicker.
|
||||
//! char and avoid flicker.
|
||||
//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since
|
||||
//! holding a non-ASCII character can feel like dropped input.
|
||||
//!
|
||||
@@ -119,7 +120,7 @@
|
||||
//! `PasteBurst` does not mutate the UI text buffer on its own. Callers must interpret decisions
|
||||
//! and apply the corresponding UI edits. `ChatComposer` uses the full buffering contract:
|
||||
//!
|
||||
//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`].
|
||||
//! - For plain ASCII input and eligible skill-completion punctuation, call [`PasteBurst::on_plain_char`].
|
||||
//! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet.
|
||||
//! - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the
|
||||
//! current char (the previously-held char is already in the burst buffer).
|
||||
@@ -130,7 +131,7 @@
|
||||
//! to normal insertion.
|
||||
//! - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`].
|
||||
//!
|
||||
//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then:
|
||||
//! - For other plain non-ASCII input, call [`PasteBurst::on_plain_char_no_hold`] and then:
|
||||
//! - If it returns `Some(CharDecision::BufferAppend)`, call
|
||||
//! [`PasteBurst::append_char_to_buffer`].
|
||||
//! - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call
|
||||
@@ -292,7 +293,7 @@ impl PasteBurst {
|
||||
///
|
||||
/// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one
|
||||
/// pasted string.
|
||||
/// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker
|
||||
/// - [`FlushResult::Typed`] when a single fast first char was being held (flicker
|
||||
/// suppression) and no burst followed before the timeout elapsed.
|
||||
/// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush.
|
||||
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› 请用 $rustdoc》 " Hidden by multi-width symbols: [(3, " "), (5, " "), (16, " ")]
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
Reference in New Issue
Block a user