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:
Benjamin Carlsson
2026-11-07 23:00:32 +00:00
committed by copyberry
parent 5226f2fb4b
commit 1455fda735
7 changed files with 290 additions and 71 deletions
+68 -60
View File
@@ -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(),
}]));
},
);
}
+11 -10
View File
@@ -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 {
@@ -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 "