burst paste edge cases (#2683)
This PR fixes two edge cases in managing burst paste (mainly on power shell). Bugs: - Needs an event key after paste to render the pasted items > ChatComposer::flush_paste_burst_if_due() flushes on timeout. Called: > - Pre-render in App on TuiEvent::Draw. > - Via a delayed frame > BottomPane::request_redraw_in(ChatComposer::recommended_paste_flush_delay()). - Parses two key events separately before starting parsing burst paste > When threshold is crossed, pull preceding burst chars out of the textarea and prepend to paste_burst_buffer, then keep buffering. - Integrates with #2567 to bring image pasting to windows.
This commit is contained in:
@@ -181,6 +181,10 @@ pub struct Config {
|
|||||||
|
|
||||||
/// Include the `view_image` tool that lets the agent attach a local image path to context.
|
/// Include the `view_image` tool that lets the agent attach a local image path to context.
|
||||||
pub include_view_image_tool: bool,
|
pub include_view_image_tool: bool,
|
||||||
|
/// When true, disables burst-paste detection for typed input entirely.
|
||||||
|
/// All characters are inserted as they are received, and no buffering
|
||||||
|
/// or placeholder replacement will occur for fast keypress bursts.
|
||||||
|
pub disable_paste_burst: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -488,6 +492,11 @@ pub struct ConfigToml {
|
|||||||
|
|
||||||
/// Nested tools section for feature toggles
|
/// Nested tools section for feature toggles
|
||||||
pub tools: Option<ToolsToml>,
|
pub tools: Option<ToolsToml>,
|
||||||
|
|
||||||
|
/// When true, disables burst-paste detection for typed input entirely.
|
||||||
|
/// All characters are inserted as they are received, and no buffering
|
||||||
|
/// or placeholder replacement will occur for fast keypress bursts.
|
||||||
|
pub disable_paste_burst: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -798,6 +807,7 @@ impl Config {
|
|||||||
.experimental_use_exec_command_tool
|
.experimental_use_exec_command_tool
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
include_view_image_tool,
|
include_view_image_tool,
|
||||||
|
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
|
||||||
};
|
};
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -1167,6 +1177,7 @@ disable_response_storage = true
|
|||||||
preferred_auth_method: AuthMode::ChatGPT,
|
preferred_auth_method: AuthMode::ChatGPT,
|
||||||
use_experimental_streamable_shell_tool: false,
|
use_experimental_streamable_shell_tool: false,
|
||||||
include_view_image_tool: true,
|
include_view_image_tool: true,
|
||||||
|
disable_paste_burst: false,
|
||||||
},
|
},
|
||||||
o3_profile_config
|
o3_profile_config
|
||||||
);
|
);
|
||||||
@@ -1224,6 +1235,7 @@ disable_response_storage = true
|
|||||||
preferred_auth_method: AuthMode::ChatGPT,
|
preferred_auth_method: AuthMode::ChatGPT,
|
||||||
use_experimental_streamable_shell_tool: false,
|
use_experimental_streamable_shell_tool: false,
|
||||||
include_view_image_tool: true,
|
include_view_image_tool: true,
|
||||||
|
disable_paste_burst: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||||
@@ -1296,6 +1308,7 @@ disable_response_storage = true
|
|||||||
preferred_auth_method: AuthMode::ChatGPT,
|
preferred_auth_method: AuthMode::ChatGPT,
|
||||||
use_experimental_streamable_shell_tool: false,
|
use_experimental_streamable_shell_tool: false,
|
||||||
include_view_image_tool: true,
|
include_view_image_tool: true,
|
||||||
|
disable_paste_burst: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ impl App {
|
|||||||
self.chat_widget.handle_paste(pasted);
|
self.chat_widget.handle_paste(pasted);
|
||||||
}
|
}
|
||||||
TuiEvent::Draw => {
|
TuiEvent::Draw => {
|
||||||
|
if self
|
||||||
|
.chat_widget
|
||||||
|
.handle_paste_burst_tick(tui.frame_requester())
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
tui.draw(
|
tui.draw(
|
||||||
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
||||||
|frame| {
|
|frame| {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ mod tests {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
|
disable_paste_burst: false,
|
||||||
});
|
});
|
||||||
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane));
|
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane));
|
||||||
assert!(view.queue.is_empty());
|
assert!(view.queue.is_empty());
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ use ratatui::widgets::WidgetRef;
|
|||||||
use super::chat_composer_history::ChatComposerHistory;
|
use super::chat_composer_history::ChatComposerHistory;
|
||||||
use super::command_popup::CommandPopup;
|
use super::command_popup::CommandPopup;
|
||||||
use super::file_search_popup::FileSearchPopup;
|
use super::file_search_popup::FileSearchPopup;
|
||||||
|
use super::paste_burst::CharDecision;
|
||||||
|
use super::paste_burst::PasteBurst;
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
|
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
@@ -40,11 +42,6 @@ use std::path::PathBuf;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
// Heuristic thresholds for detecting paste-like input bursts.
|
|
||||||
const PASTE_BURST_MIN_CHARS: u16 = 3;
|
|
||||||
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
|
|
||||||
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
|
|
||||||
|
|
||||||
/// If the pasted content exceeds this number of characters, replace it with a
|
/// If the pasted content exceeds this number of characters, replace it with a
|
||||||
/// placeholder in the UI.
|
/// placeholder in the UI.
|
||||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||||
@@ -93,13 +90,10 @@ pub(crate) struct ChatComposer {
|
|||||||
has_focus: bool,
|
has_focus: bool,
|
||||||
attached_images: Vec<AttachedImage>,
|
attached_images: Vec<AttachedImage>,
|
||||||
placeholder_text: String,
|
placeholder_text: String,
|
||||||
// Heuristic state to detect non-bracketed paste bursts.
|
// Non-bracketed paste burst tracker.
|
||||||
last_plain_char_time: Option<Instant>,
|
paste_burst: PasteBurst,
|
||||||
consecutive_plain_char_burst: u16,
|
// When true, disables paste-burst logic and inserts characters immediately.
|
||||||
paste_burst_until: Option<Instant>,
|
disable_paste_burst: bool,
|
||||||
// Buffer to accumulate characters during a detected non-bracketed paste burst.
|
|
||||||
paste_burst_buffer: String,
|
|
||||||
in_paste_burst_mode: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Popup state – at most one can be visible at any time.
|
/// Popup state – at most one can be visible at any time.
|
||||||
@@ -115,10 +109,11 @@ impl ChatComposer {
|
|||||||
app_event_tx: AppEventSender,
|
app_event_tx: AppEventSender,
|
||||||
enhanced_keys_supported: bool,
|
enhanced_keys_supported: bool,
|
||||||
placeholder_text: String,
|
placeholder_text: String,
|
||||||
|
disable_paste_burst: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let use_shift_enter_hint = enhanced_keys_supported;
|
let use_shift_enter_hint = enhanced_keys_supported;
|
||||||
|
|
||||||
Self {
|
let mut this = Self {
|
||||||
textarea: TextArea::new(),
|
textarea: TextArea::new(),
|
||||||
textarea_state: RefCell::new(TextAreaState::default()),
|
textarea_state: RefCell::new(TextAreaState::default()),
|
||||||
active_popup: ActivePopup::None,
|
active_popup: ActivePopup::None,
|
||||||
@@ -134,12 +129,12 @@ impl ChatComposer {
|
|||||||
has_focus: has_input_focus,
|
has_focus: has_input_focus,
|
||||||
attached_images: Vec::new(),
|
attached_images: Vec::new(),
|
||||||
placeholder_text,
|
placeholder_text,
|
||||||
last_plain_char_time: None,
|
paste_burst: PasteBurst::default(),
|
||||||
consecutive_plain_char_burst: 0,
|
disable_paste_burst: false,
|
||||||
paste_burst_until: None,
|
};
|
||||||
paste_burst_buffer: String::new(),
|
// Apply configuration via the setter to keep side-effects centralized.
|
||||||
in_paste_burst_mode: false,
|
this.set_disable_paste_burst(disable_paste_burst);
|
||||||
}
|
this
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn desired_height(&self, width: u16) -> u16 {
|
pub fn desired_height(&self, width: u16) -> u16 {
|
||||||
@@ -229,11 +224,15 @@ impl ChatComposer {
|
|||||||
self.textarea.insert_str(&pasted);
|
self.textarea.insert_str(&pasted);
|
||||||
}
|
}
|
||||||
// Explicit paste events should not trigger Enter suppression.
|
// Explicit paste events should not trigger Enter suppression.
|
||||||
self.last_plain_char_time = None;
|
self.paste_burst.clear_after_explicit_paste();
|
||||||
self.consecutive_plain_char_burst = 0;
|
// Keep popup sync consistent with key handling: prefer slash popup; only
|
||||||
self.paste_burst_until = None;
|
// sync file popup when slash popup is NOT active.
|
||||||
self.sync_command_popup();
|
self.sync_command_popup();
|
||||||
self.sync_file_search_popup();
|
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||||||
|
self.dismissed_file_popup_token = None;
|
||||||
|
} else {
|
||||||
|
self.sync_file_search_popup();
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +255,14 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
|
||||||
|
let was_disabled = self.disable_paste_burst;
|
||||||
|
self.disable_paste_burst = disabled;
|
||||||
|
if disabled && !was_disabled {
|
||||||
|
self.paste_burst.clear_window_after_non_char();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace the entire composer content with `text` and reset cursor.
|
/// Replace the entire composer content with `text` and reset cursor.
|
||||||
pub(crate) fn set_text_content(&mut self, text: String) {
|
pub(crate) fn set_text_content(&mut self, text: String) {
|
||||||
self.textarea.set_text(&text);
|
self.textarea.set_text(&text);
|
||||||
@@ -270,6 +277,7 @@ impl ChatComposer {
|
|||||||
self.textarea.text().to_string()
|
self.textarea.text().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
|
||||||
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
|
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
|
||||||
let placeholder = format!("[image {width}x{height} {format_label}]");
|
let placeholder = format!("[image {width}x{height} {format_label}]");
|
||||||
// Insert as an element to match large paste placeholder behavior:
|
// Insert as an element to match large paste placeholder behavior:
|
||||||
@@ -284,6 +292,23 @@ impl ChatComposer {
|
|||||||
images.into_iter().map(|img| img.path).collect()
|
images.into_iter().map(|img| img.path).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
|
||||||
|
let _ = self.handle_paste(pasted);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||||
|
self.paste_burst.is_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn recommended_paste_flush_delay() -> Duration {
|
||||||
|
PasteBurst::recommended_flush_delay()
|
||||||
|
}
|
||||||
|
|
||||||
/// Integrate results from an asynchronous file search.
|
/// Integrate results from an asynchronous file search.
|
||||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||||
// Only apply if user is still editing a token starting with `query`.
|
// Only apply if user is still editing a token starting with `query`.
|
||||||
@@ -423,9 +448,7 @@ impl ChatComposer {
|
|||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||||
if !self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode {
|
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
||||||
let pasted = std::mem::take(&mut self.paste_burst_buffer);
|
|
||||||
self.in_paste_burst_mode = false;
|
|
||||||
self.handle_paste(pasted);
|
self.handle_paste(pasted);
|
||||||
}
|
}
|
||||||
self.textarea.input(input);
|
self.textarea.input(input);
|
||||||
@@ -740,14 +763,11 @@ impl ChatComposer {
|
|||||||
.next()
|
.next()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.starts_with('/');
|
.starts_with('/');
|
||||||
if (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
|
if self.paste_burst.is_active() && !in_slash_context {
|
||||||
&& !in_slash_context
|
|
||||||
{
|
|
||||||
self.paste_burst_buffer.push('\n');
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
// Keep the window alive so subsequent lines are captured too.
|
if self.paste_burst.append_newline_if_active(now) {
|
||||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
return (InputResult::None, true);
|
||||||
return (InputResult::None, true);
|
}
|
||||||
}
|
}
|
||||||
// If we have pending placeholder pastes, submit immediately to expand them.
|
// If we have pending placeholder pastes, submit immediately to expand them.
|
||||||
if !self.pending_pastes.is_empty() {
|
if !self.pending_pastes.is_empty() {
|
||||||
@@ -768,19 +788,12 @@ impl ChatComposer {
|
|||||||
|
|
||||||
// During a paste-like burst, treat Enter as a newline instead of submit.
|
// During a paste-like burst, treat Enter as a newline instead of submit.
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let tight_after_char = self
|
if self
|
||||||
.last_plain_char_time
|
.paste_burst
|
||||||
.is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL);
|
.newline_should_insert_instead_of_submit(now)
|
||||||
let recent_after_char = self
|
{
|
||||||
.last_plain_char_time
|
|
||||||
.is_some_and(|t| now.duration_since(t) <= PASTE_ENTER_SUPPRESS_WINDOW);
|
|
||||||
let burst_by_count =
|
|
||||||
recent_after_char && self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS;
|
|
||||||
let in_burst_window = self.paste_burst_until.is_some_and(|until| now <= until);
|
|
||||||
|
|
||||||
if tight_after_char || burst_by_count || in_burst_window {
|
|
||||||
self.textarea.insert_str("\n");
|
self.textarea.insert_str("\n");
|
||||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
self.paste_burst.extend_window(now);
|
||||||
return (InputResult::None, true);
|
return (InputResult::None, true);
|
||||||
}
|
}
|
||||||
let mut text = self.textarea.text().to_string();
|
let mut text = self.textarea.text().to_string();
|
||||||
@@ -810,22 +823,16 @@ impl ChatComposer {
|
|||||||
// If we have a buffered non-bracketed paste burst and enough time has
|
// If we have a buffered non-bracketed paste burst and enough time has
|
||||||
// elapsed since the last char, flush it before handling a new input.
|
// elapsed since the last char, flush it before handling a new input.
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let timed_out = self
|
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
|
||||||
.last_plain_char_time
|
|
||||||
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
|
||||||
if timed_out && (!self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode) {
|
|
||||||
let pasted = std::mem::take(&mut self.paste_burst_buffer);
|
|
||||||
self.in_paste_burst_mode = false;
|
|
||||||
// Reuse normal paste path (handles large-paste placeholders).
|
// Reuse normal paste path (handles large-paste placeholders).
|
||||||
self.handle_paste(pasted);
|
self.handle_paste(pasted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
||||||
if matches!(input.code, KeyCode::Enter)
|
if matches!(input.code, KeyCode::Enter)
|
||||||
&& (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
|
&& self.paste_burst.is_active()
|
||||||
|
&& self.paste_burst.append_newline_if_active(now)
|
||||||
{
|
{
|
||||||
self.paste_burst_buffer.push('\n');
|
|
||||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
|
||||||
return (InputResult::None, true);
|
return (InputResult::None, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,65 +847,50 @@ impl ChatComposer {
|
|||||||
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT);
|
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT);
|
||||||
if !has_ctrl_or_alt {
|
if !has_ctrl_or_alt {
|
||||||
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be
|
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be
|
||||||
// misclassified by our non-bracketed paste heuristic. To avoid leaving
|
// misclassified by paste heuristics. Flush any active burst buffer and insert
|
||||||
// residual buffered content or misdetecting a paste, flush any burst buffer
|
// non-ASCII characters directly.
|
||||||
// and insert non-ASCII characters directly.
|
|
||||||
if !ch.is_ascii() {
|
if !ch.is_ascii() {
|
||||||
return self.handle_non_ascii_char(input);
|
return self.handle_non_ascii_char(input);
|
||||||
}
|
}
|
||||||
// Update burst heuristics.
|
|
||||||
match self.last_plain_char_time {
|
|
||||||
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
|
|
||||||
self.consecutive_plain_char_burst =
|
|
||||||
self.consecutive_plain_char_burst.saturating_add(1);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
self.consecutive_plain_char_burst = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.last_plain_char_time = Some(now);
|
|
||||||
|
|
||||||
// If we're already buffering, capture the char into the buffer.
|
match self.paste_burst.on_plain_char(ch, now) {
|
||||||
if self.in_paste_burst_mode {
|
CharDecision::BufferAppend => {
|
||||||
self.paste_burst_buffer.push(ch);
|
self.paste_burst.append_char_to_buffer(ch, now);
|
||||||
// Keep the window alive while we receive the burst.
|
return (InputResult::None, true);
|
||||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
}
|
||||||
return (InputResult::None, true);
|
CharDecision::BeginBuffer { retro_chars } => {
|
||||||
} else if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
|
let cur = self.textarea.cursor();
|
||||||
// Do not start burst buffering while typing a slash command (first line starts with '/').
|
let txt = self.textarea.text();
|
||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
|
||||||
if first_line.starts_with('/') {
|
let before = &txt[..safe_cur];
|
||||||
// Keep heuristics but do not buffer.
|
if let Some(grab) =
|
||||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
self.paste_burst
|
||||||
// Insert normally.
|
.decide_begin_buffer(now, before, retro_chars as usize)
|
||||||
self.textarea.input(input);
|
{
|
||||||
let text_after = self.textarea.text();
|
if !grab.grabbed.is_empty() {
|
||||||
self.pending_pastes
|
self.textarea.replace_range(grab.start_byte..safe_cur, "");
|
||||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
}
|
||||||
|
self.paste_burst.begin_with_retro_grabbed(grab.grabbed, now);
|
||||||
|
self.paste_burst.append_char_to_buffer(ch, now);
|
||||||
|
return (InputResult::None, true);
|
||||||
|
}
|
||||||
|
// If decide_begin_buffer opted not to start buffering,
|
||||||
|
// fall through to normal insertion below.
|
||||||
|
}
|
||||||
|
CharDecision::BeginBufferFromPending => {
|
||||||
|
// First char was held; now append the current one.
|
||||||
|
self.paste_burst.append_char_to_buffer(ch, now);
|
||||||
|
return (InputResult::None, true);
|
||||||
|
}
|
||||||
|
CharDecision::RetainFirstChar => {
|
||||||
|
// Keep the first fast char pending momentarily.
|
||||||
return (InputResult::None, true);
|
return (InputResult::None, true);
|
||||||
}
|
}
|
||||||
// Begin buffering from this character onward.
|
|
||||||
self.paste_burst_buffer.push(ch);
|
|
||||||
self.in_paste_burst_mode = true;
|
|
||||||
// Keep the window alive to continue capturing.
|
|
||||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
|
||||||
return (InputResult::None, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not buffering: insert normally and continue.
|
|
||||||
self.textarea.input(input);
|
|
||||||
let text_after = self.textarea.text();
|
|
||||||
self.pending_pastes
|
|
||||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
|
||||||
return (InputResult::None, true);
|
|
||||||
} else {
|
|
||||||
// Modified char ends any burst: flush buffered content before applying.
|
|
||||||
if !self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode {
|
|
||||||
let pasted = std::mem::take(&mut self.paste_burst_buffer);
|
|
||||||
self.in_paste_burst_mode = false;
|
|
||||||
self.handle_paste(pasted);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
||||||
|
self.handle_paste(pasted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-char inputs (or after flushing), handle normally.
|
// For non-char inputs (or after flushing), handle normally.
|
||||||
@@ -925,25 +917,15 @@ impl ChatComposer {
|
|||||||
let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL)
|
let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|| modifiers.contains(KeyModifiers::ALT);
|
|| modifiers.contains(KeyModifiers::ALT);
|
||||||
if has_ctrl_or_alt {
|
if has_ctrl_or_alt {
|
||||||
// Modified char: clear burst window.
|
self.paste_burst.clear_window_after_non_char();
|
||||||
self.consecutive_plain_char_burst = 0;
|
|
||||||
self.last_plain_char_time = None;
|
|
||||||
self.paste_burst_until = None;
|
|
||||||
self.in_paste_burst_mode = false;
|
|
||||||
self.paste_burst_buffer.clear();
|
|
||||||
}
|
}
|
||||||
// Plain chars handled above.
|
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
// Keep burst window alive (supports blank lines in paste).
|
// Keep burst window alive (supports blank lines in paste).
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Other keys: clear burst window and any buffer (after flushing earlier).
|
// Other keys: clear burst window (buffer should have been flushed above if needed).
|
||||||
self.consecutive_plain_char_burst = 0;
|
self.paste_burst.clear_window_after_non_char();
|
||||||
self.last_plain_char_time = None;
|
|
||||||
self.paste_burst_until = None;
|
|
||||||
self.in_paste_burst_mode = false;
|
|
||||||
// Do not clear paste_burst_buffer here; it should have been flushed above.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1480,8 +1462,13 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
let needs_redraw = composer.handle_paste("hello".to_string());
|
let needs_redraw = composer.handle_paste("hello".to_string());
|
||||||
assert!(needs_redraw);
|
assert!(needs_redraw);
|
||||||
@@ -1504,8 +1491,13 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
||||||
let needs_redraw = composer.handle_paste(large.clone());
|
let needs_redraw = composer.handle_paste(large.clone());
|
||||||
@@ -1534,8 +1526,13 @@ mod tests {
|
|||||||
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
composer.handle_paste(large);
|
composer.handle_paste(large);
|
||||||
assert_eq!(composer.pending_pastes.len(), 1);
|
assert_eq!(composer.pending_pastes.len(), 1);
|
||||||
@@ -1576,6 +1573,7 @@ mod tests {
|
|||||||
sender.clone(),
|
sender.clone(),
|
||||||
false,
|
false,
|
||||||
"Ask Codex to do anything".to_string(),
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(text) = input {
|
if let Some(text) = input {
|
||||||
@@ -1605,6 +1603,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
|
||||||
|
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
|
for &ch in chars {
|
||||||
|
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||||
|
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
||||||
|
let _ = composer.flush_paste_burst_if_due();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
|
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
@@ -1613,15 +1623,16 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// Type the slash command.
|
// Type the slash command.
|
||||||
for ch in [
|
type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']);
|
||||||
'/', 'i', 'n', 'i', 't', // "/init"
|
|
||||||
] {
|
|
||||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Press Enter to dispatch the selected command.
|
// Press Enter to dispatch the selected command.
|
||||||
let (result, _needs_redraw) =
|
let (result, _needs_redraw) =
|
||||||
@@ -1649,12 +1660,15 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
for ch in ['/', 'c'] {
|
type_chars_humanlike(&mut composer, &['/', 'c']);
|
||||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (_result, _needs_redraw) =
|
let (_result, _needs_redraw) =
|
||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||||
@@ -1671,12 +1685,15 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] {
|
type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']);
|
||||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (result, _needs_redraw) =
|
let (result, _needs_redraw) =
|
||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
@@ -1703,8 +1720,13 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// Define test cases: (paste content, is_large)
|
// Define test cases: (paste content, is_large)
|
||||||
let test_cases = [
|
let test_cases = [
|
||||||
@@ -1777,8 +1799,13 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// Define test cases: (content, is_large)
|
// Define test cases: (content, is_large)
|
||||||
let test_cases = [
|
let test_cases = [
|
||||||
@@ -1844,8 +1871,13 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// Define test cases: (cursor_position_from_end, expected_pending_count)
|
// Define test cases: (cursor_position_from_end, expected_pending_count)
|
||||||
let test_cases = [
|
let test_cases = [
|
||||||
@@ -1887,8 +1919,13 @@ mod tests {
|
|||||||
fn attach_image_and_submit_includes_image_paths() {
|
fn attach_image_and_submit_includes_image_paths() {
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
let path = PathBuf::from("/tmp/image1.png");
|
let path = PathBuf::from("/tmp/image1.png");
|
||||||
composer.attach_image(path.clone(), 32, 16, "PNG");
|
composer.attach_image(path.clone(), 32, 16, "PNG");
|
||||||
composer.handle_paste(" hi".into());
|
composer.handle_paste(" hi".into());
|
||||||
@@ -1906,8 +1943,13 @@ mod tests {
|
|||||||
fn attach_image_without_text_submits_empty_text_and_images() {
|
fn attach_image_without_text_submits_empty_text_and_images() {
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
let path = PathBuf::from("/tmp/image2.png");
|
let path = PathBuf::from("/tmp/image2.png");
|
||||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||||
let (result, _) =
|
let (result, _) =
|
||||||
@@ -1926,8 +1968,13 @@ mod tests {
|
|||||||
fn image_placeholder_backspace_behaves_like_text_placeholder() {
|
fn image_placeholder_backspace_behaves_like_text_placeholder() {
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
let path = PathBuf::from("/tmp/image3.png");
|
let path = PathBuf::from("/tmp/image3.png");
|
||||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||||
@@ -1962,8 +2009,13 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// Insert an image placeholder at the start
|
// Insert an image placeholder at the start
|
||||||
let path = PathBuf::from("/tmp/image_multibyte.png");
|
let path = PathBuf::from("/tmp/image_multibyte.png");
|
||||||
@@ -1983,8 +2035,13 @@ mod tests {
|
|||||||
fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() {
|
fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() {
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||||||
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
||||||
@@ -2025,8 +2082,13 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer =
|
let mut composer = ChatComposer::new(
|
||||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
||||||
assert!(needs_redraw);
|
assert!(needs_redraw);
|
||||||
@@ -2035,4 +2097,104 @@ mod tests {
|
|||||||
let imgs = composer.take_recent_submission_images();
|
let imgs = composer.take_recent_submission_images();
|
||||||
assert_eq!(imgs, vec![tmp_path.clone()]);
|
assert_eq!(imgs, vec![tmp_path.clone()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
|
|
||||||
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
|
let sender = AppEventSender::new(tx);
|
||||||
|
let mut composer = ChatComposer::new(
|
||||||
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = 32;
|
||||||
|
for _ in 0..count {
|
||||||
|
let _ =
|
||||||
|
composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||||
|
assert!(
|
||||||
|
composer.is_in_paste_burst(),
|
||||||
|
"expected active paste burst during fast typing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
composer.textarea.text().is_empty(),
|
||||||
|
"text should not appear during burst"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
composer.textarea.text().is_empty(),
|
||||||
|
"text should remain empty until flush"
|
||||||
|
);
|
||||||
|
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
||||||
|
let flushed = composer.flush_paste_burst_if_due();
|
||||||
|
assert!(flushed, "expected buffered text to flush after stop");
|
||||||
|
assert_eq!(composer.textarea.text(), "a".repeat(count));
|
||||||
|
assert!(
|
||||||
|
composer.pending_pastes.is_empty(),
|
||||||
|
"no placeholder for small burst"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn burst_paste_fast_large_inserts_placeholder_on_flush() {
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
|
|
||||||
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
|
let sender = AppEventSender::new(tx);
|
||||||
|
let mut composer = ChatComposer::new(
|
||||||
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder
|
||||||
|
for _ in 0..count {
|
||||||
|
let _ =
|
||||||
|
composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing should appear until we stop and flush
|
||||||
|
assert!(composer.textarea.text().is_empty());
|
||||||
|
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
||||||
|
let flushed = composer.flush_paste_burst_if_due();
|
||||||
|
assert!(flushed, "expected flush after stopping fast input");
|
||||||
|
|
||||||
|
let expected_placeholder = format!("[Pasted Content {count} chars]");
|
||||||
|
assert_eq!(composer.textarea.text(), expected_placeholder);
|
||||||
|
assert_eq!(composer.pending_pastes.len(), 1);
|
||||||
|
assert_eq!(composer.pending_pastes[0].0, expected_placeholder);
|
||||||
|
assert_eq!(composer.pending_pastes[0].1.len(), count);
|
||||||
|
assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanlike_typing_1000_chars_appears_live_no_placeholder() {
|
||||||
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
|
let sender = AppEventSender::new(tx);
|
||||||
|
let mut composer = ChatComposer::new(
|
||||||
|
true,
|
||||||
|
sender,
|
||||||
|
false,
|
||||||
|
"Ask Codex to do anything".to_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config
|
||||||
|
let chars: Vec<char> = vec!['z'; count];
|
||||||
|
type_chars_humanlike(&mut composer, &chars);
|
||||||
|
|
||||||
|
assert_eq!(composer.textarea.text(), "z".repeat(count));
|
||||||
|
assert!(composer.pending_pastes.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use ratatui::layout::Constraint;
|
|||||||
use ratatui::layout::Layout;
|
use ratatui::layout::Layout;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
mod approval_modal_view;
|
mod approval_modal_view;
|
||||||
mod bottom_pane_view;
|
mod bottom_pane_view;
|
||||||
@@ -21,6 +22,7 @@ mod chat_composer_history;
|
|||||||
mod command_popup;
|
mod command_popup;
|
||||||
mod file_search_popup;
|
mod file_search_popup;
|
||||||
mod list_selection_view;
|
mod list_selection_view;
|
||||||
|
mod paste_burst;
|
||||||
mod popup_consts;
|
mod popup_consts;
|
||||||
mod scroll_state;
|
mod scroll_state;
|
||||||
mod selection_popup_common;
|
mod selection_popup_common;
|
||||||
@@ -69,6 +71,7 @@ pub(crate) struct BottomPaneParams {
|
|||||||
pub(crate) has_input_focus: bool,
|
pub(crate) has_input_focus: bool,
|
||||||
pub(crate) enhanced_keys_supported: bool,
|
pub(crate) enhanced_keys_supported: bool,
|
||||||
pub(crate) placeholder_text: String,
|
pub(crate) placeholder_text: String,
|
||||||
|
pub(crate) disable_paste_burst: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BottomPane {
|
impl BottomPane {
|
||||||
@@ -81,6 +84,7 @@ impl BottomPane {
|
|||||||
params.app_event_tx.clone(),
|
params.app_event_tx.clone(),
|
||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
params.placeholder_text,
|
params.placeholder_text,
|
||||||
|
params.disable_paste_burst,
|
||||||
),
|
),
|
||||||
active_view: None,
|
active_view: None,
|
||||||
app_event_tx: params.app_event_tx,
|
app_event_tx: params.app_event_tx,
|
||||||
@@ -182,6 +186,9 @@ impl BottomPane {
|
|||||||
if needs_redraw {
|
if needs_redraw {
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
if self.composer.is_in_paste_burst() {
|
||||||
|
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
|
||||||
|
}
|
||||||
input_result
|
input_result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,12 +389,24 @@ impl BottomPane {
|
|||||||
self.frame_requester.schedule_frame();
|
self.frame_requester.schedule_frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request_redraw_in(&self, dur: Duration) {
|
||||||
|
self.frame_requester.schedule_frame_in(dur);
|
||||||
|
}
|
||||||
|
|
||||||
// --- History helpers ---
|
// --- History helpers ---
|
||||||
|
|
||||||
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
||||||
self.composer.set_history_metadata(log_id, entry_count);
|
self.composer.set_history_metadata(log_id, entry_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||||
|
self.composer.flush_paste_burst_if_due()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||||
|
self.composer.is_in_paste_burst()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn on_history_entry_response(
|
pub(crate) fn on_history_entry_response(
|
||||||
&mut self,
|
&mut self,
|
||||||
log_id: u64,
|
log_id: u64,
|
||||||
@@ -473,6 +492,7 @@ mod tests {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
|
disable_paste_burst: false,
|
||||||
});
|
});
|
||||||
pane.push_approval_request(exec_request());
|
pane.push_approval_request(exec_request());
|
||||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||||
@@ -492,6 +512,7 @@ mod tests {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
|
disable_paste_burst: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create an approval modal (active view).
|
// Create an approval modal (active view).
|
||||||
@@ -522,6 +543,7 @@ mod tests {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
|
disable_paste_burst: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start a running task so the status indicator is active above the composer.
|
// Start a running task so the status indicator is active above the composer.
|
||||||
@@ -589,6 +611,7 @@ mod tests {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
|
disable_paste_burst: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Begin a task: show initial status.
|
// Begin a task: show initial status.
|
||||||
@@ -619,6 +642,7 @@ mod tests {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
|
disable_paste_burst: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate spinner (status view replaces composer) with no live ring.
|
// Activate spinner (status view replaces composer) with no live ring.
|
||||||
@@ -669,6 +693,7 @@ mod tests {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
|
disable_paste_burst: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
pane.set_task_running(true);
|
pane.set_task_running(true);
|
||||||
|
|||||||
246
codex-rs/tui/src/bottom_pane/paste_burst.rs
Normal file
246
codex-rs/tui/src/bottom_pane/paste_burst.rs
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
// Heuristic thresholds for detecting paste-like input bursts.
|
||||||
|
// Detect quickly to avoid showing typed prefix before paste is recognized
|
||||||
|
const PASTE_BURST_MIN_CHARS: u16 = 3;
|
||||||
|
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
|
||||||
|
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct PasteBurst {
|
||||||
|
last_plain_char_time: Option<Instant>,
|
||||||
|
consecutive_plain_char_burst: u16,
|
||||||
|
burst_window_until: Option<Instant>,
|
||||||
|
buffer: String,
|
||||||
|
active: bool,
|
||||||
|
// Hold first fast char briefly to avoid rendering flicker
|
||||||
|
pending_first_char: Option<(char, Instant)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum CharDecision {
|
||||||
|
/// Start buffering and retroactively capture some already-inserted chars.
|
||||||
|
BeginBuffer { retro_chars: u16 },
|
||||||
|
/// We are currently buffering; append the current char into the buffer.
|
||||||
|
BufferAppend,
|
||||||
|
/// Do not insert/render this char yet; temporarily save the first fast
|
||||||
|
/// char while we wait to see if a paste-like burst follows.
|
||||||
|
RetainFirstChar,
|
||||||
|
/// Begin buffering using the previously saved first char (no retro grab needed).
|
||||||
|
BeginBufferFromPending,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RetroGrab {
|
||||||
|
pub start_byte: usize,
|
||||||
|
pub grabbed: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasteBurst {
|
||||||
|
/// Recommended delay to wait between simulated keypresses (or before
|
||||||
|
/// scheduling a UI tick) so that a pending fast keystroke is flushed
|
||||||
|
/// out of the burst detector as normal typed input.
|
||||||
|
///
|
||||||
|
/// Primarily used by tests and by the TUI to reliably cross the
|
||||||
|
/// paste-burst timing threshold.
|
||||||
|
pub fn recommended_flush_delay() -> Duration {
|
||||||
|
PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point: decide how to treat a plain char with current timing.
|
||||||
|
pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
|
||||||
|
match self.last_plain_char_time {
|
||||||
|
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
|
||||||
|
self.consecutive_plain_char_burst =
|
||||||
|
self.consecutive_plain_char_burst.saturating_add(1)
|
||||||
|
}
|
||||||
|
_ => self.consecutive_plain_char_burst = 1,
|
||||||
|
}
|
||||||
|
self.last_plain_char_time = Some(now);
|
||||||
|
|
||||||
|
if self.active {
|
||||||
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||||
|
return CharDecision::BufferAppend;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already held a first char and receive a second fast char,
|
||||||
|
// start buffering without retro-grabbing (we never rendered the first).
|
||||||
|
if let Some((held, held_at)) = self.pending_first_char
|
||||||
|
&& now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL
|
||||||
|
{
|
||||||
|
self.active = true;
|
||||||
|
// take() to clear pending; we already captured the held char above
|
||||||
|
let _ = self.pending_first_char.take();
|
||||||
|
self.buffer.push(held);
|
||||||
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||||
|
return CharDecision::BeginBufferFromPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
|
||||||
|
return CharDecision::BeginBuffer {
|
||||||
|
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the first fast char very briefly to see if a burst follows.
|
||||||
|
self.pending_first_char = Some((ch, now));
|
||||||
|
CharDecision::RetainFirstChar
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush the buffered burst if the inter-key timeout has elapsed.
|
||||||
|
///
|
||||||
|
/// Returns Some(String) when either:
|
||||||
|
/// - We were actively buffering paste-like input and the buffer is now
|
||||||
|
/// emitted as a single pasted string; or
|
||||||
|
/// - We had saved a single fast first-char with no subsequent burst and we
|
||||||
|
/// now emit that char as normal typed input.
|
||||||
|
///
|
||||||
|
/// Returns None if the timeout has not elapsed or there is nothing to flush.
|
||||||
|
pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
|
||||||
|
let timed_out = self
|
||||||
|
.last_plain_char_time
|
||||||
|
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
||||||
|
if timed_out && self.is_active_internal() {
|
||||||
|
self.active = false;
|
||||||
|
let out = std::mem::take(&mut self.buffer);
|
||||||
|
Some(out)
|
||||||
|
} else if timed_out {
|
||||||
|
// If we were saving a single fast char and no burst followed,
|
||||||
|
// flush it as normal typed input.
|
||||||
|
if let Some((ch, _at)) = self.pending_first_char.take() {
|
||||||
|
Some(ch.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// While bursting: accumulate a newline into the buffer instead of
|
||||||
|
/// submitting the textarea.
|
||||||
|
///
|
||||||
|
/// Returns true if a newline was appended (we are in a burst context),
|
||||||
|
/// false otherwise.
|
||||||
|
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
|
||||||
|
if self.is_active() {
|
||||||
|
self.buffer.push('\n');
|
||||||
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide if Enter should insert a newline (burst context) vs submit.
|
||||||
|
pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
|
||||||
|
let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
|
||||||
|
self.is_active() || in_burst_window
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the burst window alive.
|
||||||
|
pub fn extend_window(&mut self, now: Instant) {
|
||||||
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin buffering with retroactively grabbed text.
|
||||||
|
pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
|
||||||
|
if !grabbed.is_empty() {
|
||||||
|
self.buffer.push_str(&grabbed);
|
||||||
|
}
|
||||||
|
self.active = true;
|
||||||
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a char into the burst buffer.
|
||||||
|
pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
|
||||||
|
self.buffer.push(ch);
|
||||||
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether to begin buffering by retroactively capturing recent
|
||||||
|
/// chars from the slice before the cursor.
|
||||||
|
///
|
||||||
|
/// Heuristic: if the retro-grabbed slice contains any whitespace or is
|
||||||
|
/// sufficiently long (>= 16 characters), treat it as paste-like to avoid
|
||||||
|
/// rendering the typed prefix momentarily before the paste is recognized.
|
||||||
|
/// This favors responsiveness and prevents flicker for typical pastes
|
||||||
|
/// (URLs, file paths, multiline text) while not triggering on short words.
|
||||||
|
///
|
||||||
|
/// Returns Some(RetroGrab) with the start byte and grabbed text when we
|
||||||
|
/// decide to buffer retroactively; otherwise None.
|
||||||
|
pub fn decide_begin_buffer(
|
||||||
|
&mut self,
|
||||||
|
now: Instant,
|
||||||
|
before: &str,
|
||||||
|
retro_chars: usize,
|
||||||
|
) -> Option<RetroGrab> {
|
||||||
|
let start_byte = retro_start_index(before, retro_chars);
|
||||||
|
let grabbed = before[start_byte..].to_string();
|
||||||
|
let looks_pastey =
|
||||||
|
grabbed.chars().any(|c| c.is_whitespace()) || grabbed.chars().count() >= 16;
|
||||||
|
if looks_pastey {
|
||||||
|
// Note: caller is responsible for removing this slice from UI text.
|
||||||
|
self.begin_with_retro_grabbed(grabbed.clone(), now);
|
||||||
|
Some(RetroGrab {
|
||||||
|
start_byte,
|
||||||
|
grabbed,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before applying modified/non-char input: flush buffered burst immediately.
|
||||||
|
pub fn flush_before_modified_input(&mut self) -> Option<String> {
|
||||||
|
if self.is_active() {
|
||||||
|
self.active = false;
|
||||||
|
Some(std::mem::take(&mut self.buffer))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear only the timing window and any pending first-char.
|
||||||
|
///
|
||||||
|
/// Does not emit or clear the buffered text itself; callers should have
|
||||||
|
/// already flushed (if needed) via one of the flush methods above.
|
||||||
|
pub fn clear_window_after_non_char(&mut self) {
|
||||||
|
self.consecutive_plain_char_burst = 0;
|
||||||
|
self.last_plain_char_time = None;
|
||||||
|
self.burst_window_until = None;
|
||||||
|
self.active = false;
|
||||||
|
self.pending_first_char = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if we are in any paste-burst related transient state
|
||||||
|
/// (actively buffering, have a non-empty buffer, or have saved the first
|
||||||
|
/// fast char while waiting for a potential burst).
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.is_active_internal() || self.pending_first_char.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active_internal(&self) -> bool {
|
||||||
|
self.active || !self.buffer.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_after_explicit_paste(&mut self) {
|
||||||
|
self.last_plain_char_time = None;
|
||||||
|
self.consecutive_plain_char_burst = 0;
|
||||||
|
self.burst_window_until = None;
|
||||||
|
self.active = false;
|
||||||
|
self.buffer.clear();
|
||||||
|
self.pending_first_char = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize {
|
||||||
|
if retro_chars == 0 {
|
||||||
|
return before.len();
|
||||||
|
}
|
||||||
|
before
|
||||||
|
.char_indices()
|
||||||
|
.rev()
|
||||||
|
.nth(retro_chars.saturating_sub(1))
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
@@ -604,6 +604,7 @@ impl ChatWidget {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
placeholder_text: placeholder,
|
placeholder_text: placeholder,
|
||||||
|
disable_paste_burst: config.disable_paste_burst,
|
||||||
}),
|
}),
|
||||||
active_exec_cell: None,
|
active_exec_cell: None,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
@@ -652,6 +653,7 @@ impl ChatWidget {
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
placeholder_text: placeholder,
|
placeholder_text: placeholder,
|
||||||
|
disable_paste_burst: config.disable_paste_burst,
|
||||||
}),
|
}),
|
||||||
active_exec_cell: None,
|
active_exec_cell: None,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
@@ -858,6 +860,24 @@ impl ChatWidget {
|
|||||||
self.bottom_pane.handle_paste(text);
|
self.bottom_pane.handle_paste(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
|
||||||
|
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
|
||||||
|
if self.bottom_pane.flush_paste_burst_if_due() {
|
||||||
|
// A paste just flushed; request an immediate redraw and skip this frame.
|
||||||
|
self.request_redraw();
|
||||||
|
true
|
||||||
|
} else if self.bottom_pane.is_in_paste_burst() {
|
||||||
|
// While capturing a burst, schedule a follow-up tick and skip this frame
|
||||||
|
// to avoid redundant renders between ticks.
|
||||||
|
frame_requester.schedule_frame_in(
|
||||||
|
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(),
|
||||||
|
);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn flush_active_exec_cell(&mut self) {
|
fn flush_active_exec_cell(&mut self) {
|
||||||
if let Some(active) = self.active_exec_cell.take() {
|
if let Some(active) = self.active_exec_cell.take() {
|
||||||
self.last_history_was_exec = true;
|
self.last_history_was_exec = true;
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ fn make_chatwidget_manual() -> (
|
|||||||
has_input_focus: true,
|
has_input_focus: true,
|
||||||
enhanced_keys_supported: false,
|
enhanced_keys_supported: false,
|
||||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||||
|
disable_paste_burst: false,
|
||||||
});
|
});
|
||||||
let widget = ChatWidget {
|
let widget = ChatWidget {
|
||||||
app_event_tx,
|
app_event_tx,
|
||||||
|
|||||||
Reference in New Issue
Block a user