diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2e57686f9d..56d426bcf0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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 { 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(name: &str, enhanced_keys_supported: bool, setup: F) + pub(super) fn snapshot_composer_state(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] { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs index b219097ee1..dccd860cbf 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -17,6 +17,8 @@ pub(super) struct DraftState { pub(super) input_disabled_placeholder: Option, 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, pub(super) mention_bindings: HashMap, pub(super) recent_submission_mention_bindings: Vec, } @@ -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 { + 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)] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs index 94911cbaa1..e706fec343 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -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(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs index 89f3c2c43a..df7cec2916 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -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() { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/skill_completion_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/skill_completion_tests.rs new file mode 100644 index 0000000000..a0fa28574b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/skill_completion_tests.rs @@ -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(), + }])); + }, + ); +} diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index fb91531250..57c855d098 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -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 { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_completion_attaches_typed_punctuation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_completion_attaches_typed_punctuation.snap new file mode 100644 index 0000000000..9d024f3487 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_completion_attaches_typed_punctuation.snap @@ -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 "