From 56308bb5d7fc2aa5132198f350e45f3dd867c642 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 26 Apr 2026 00:45:35 -0500 Subject: [PATCH] refactor(tui): extract paste-burst handlers into tui/paste.rs (P1.2) Lifts `handle_paste_burst_key`, `handle_paste_burst_decision`, `apply_paste_burst_retro_capture`, and the local `in_command_context` helper out of `tui/ui.rs` into a sibling module. The state machine (`PasteBurst`) and its tests stay in `paste_burst.rs`; only the keymap- side wiring moves. Drops the now-unused `CharDecision` import from `ui.rs`. Workspace tests: 1011/1011 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/paste.rs | 112 ++++++++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 94 +----------------------------- 3 files changed, 114 insertions(+), 93 deletions(-) create mode 100644 crates/tui/src/tui/paste.rs diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 201da5dd..0b50e925 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -15,6 +15,7 @@ pub mod markdown_render; pub mod model_picker; pub mod onboarding; pub mod pager; +pub mod paste; pub mod paste_burst; pub mod plan_prompt; pub mod scrolling; diff --git a/crates/tui/src/tui/paste.rs b/crates/tui/src/tui/paste.rs new file mode 100644 index 00000000..d8004c96 --- /dev/null +++ b/crates/tui/src/tui/paste.rs @@ -0,0 +1,112 @@ +//! Paste-burst handling — turn rapid keystrokes (terminals without bracketed +//! paste) into a single committed buffer instead of N individual chars. +//! +//! Extracted from `tui/ui.rs` (P1.2). The owning state machine lives on +//! `App.paste_burst` (`tui::paste_burst`); these helpers wire it to the key +//! event loop and the composer's text buffer. + +use std::time::Instant; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::app::App; +use super::paste_burst::CharDecision; + +/// Process a key in the context of paste-burst detection. Returns `true` +/// when the key was fully handled by the paste machinery (caller skips +/// further input handling); `false` when the key still needs the normal +/// composer path. +pub fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bool { + let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT) + || key.modifiers.contains(KeyModifiers::SUPER); + + match key.code { + KeyCode::Enter => { + if !in_command_context(app) && app.paste_burst.append_newline_if_active(now) { + return true; + } + if !in_command_context(app) + && app.paste_burst.newline_should_insert_instead_of_submit(now) + { + app.insert_char('\n'); + app.paste_burst.extend_window(now); + return true; + } + } + KeyCode::Char(c) if !has_ctrl_alt_or_super => { + if !c.is_ascii() { + if let Some(pending) = app.paste_burst.flush_before_modified_input() { + app.insert_str(&pending); + } + if app.paste_burst.try_append_char_if_active(c, now) { + return true; + } + if let Some(decision) = app.paste_burst.on_plain_char_no_hold(now) { + return handle_paste_burst_decision(app, decision, c, now); + } + app.insert_char(c); + return true; + } + + let decision = app.paste_burst.on_plain_char(c, now); + return handle_paste_burst_decision(app, decision, c, now); + } + _ => {} + } + + false +} + +/// Apply a paste-burst decision to the composer buffer. Some decisions +/// retroactively grab the last few chars from the input back into the +/// pending paste buffer (when the heuristic decides the recent typing was +/// actually a paste). +pub fn handle_paste_burst_decision( + app: &mut App, + decision: CharDecision, + c: char, + now: Instant, +) -> bool { + match decision { + CharDecision::RetainFirstChar => true, + CharDecision::BeginBufferFromPending | CharDecision::BufferAppend => { + app.paste_burst.append_char_to_buffer(c, now); + true + } + CharDecision::BeginBuffer { retro_chars } => { + if apply_paste_burst_retro_capture(app, retro_chars as usize, c, now) { + return true; + } + app.insert_char(c); + true + } + } +} + +fn apply_paste_burst_retro_capture( + app: &mut App, + retro_chars: usize, + c: char, + now: Instant, +) -> bool { + let cursor_byte = app.cursor_byte_index(); + let before = &app.input[..cursor_byte]; + let Some(grab) = app + .paste_burst + .decide_begin_buffer(now, before, retro_chars) + else { + return false; + }; + if !grab.grabbed.is_empty() { + app.input.replace_range(grab.start_byte..cursor_byte, ""); + let removed = grab.grabbed.chars().count(); + app.cursor_position = app.cursor_position.saturating_sub(removed); + } + app.paste_burst.append_char_to_buffer(c, now); + true +} + +fn in_command_context(app: &App) -> bool { + app.input.starts_with('/') +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2a9a1b1c..8b9cb042 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -55,7 +55,6 @@ use crate::tui::command_palette::{ use crate::tui::event_broker::EventBroker; use crate::tui::onboarding; use crate::tui::pager::PagerView; -use crate::tui::paste_burst::CharDecision; use crate::tui::plan_prompt::PlanPromptView; use crate::tui::scrolling::{ScrollDirection, TranscriptScroll}; use crate::tui::selection::TranscriptSelectionPoint; @@ -1119,7 +1118,7 @@ async fn run_event_loop( app.insert_str(&pending); } - if (is_plain_char || is_enter) && handle_paste_burst_key(app, &key, now) { + if (is_plain_char || is_enter) && super::paste::handle_paste_burst_key(app, &key, now) { continue; } @@ -1523,48 +1522,6 @@ async fn run_event_loop( } } -fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bool { - let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL) - || key.modifiers.contains(KeyModifiers::ALT) - || key.modifiers.contains(KeyModifiers::SUPER); - - match key.code { - KeyCode::Enter => { - if !in_command_context(app) && app.paste_burst.append_newline_if_active(now) { - return true; - } - if !in_command_context(app) - && app.paste_burst.newline_should_insert_instead_of_submit(now) - { - app.insert_char('\n'); - app.paste_burst.extend_window(now); - return true; - } - } - KeyCode::Char(c) if !has_ctrl_alt_or_super => { - if !c.is_ascii() { - if let Some(pending) = app.paste_burst.flush_before_modified_input() { - app.insert_str(&pending); - } - if app.paste_burst.try_append_char_if_active(c, now) { - return true; - } - if let Some(decision) = app.paste_burst.on_plain_char_no_hold(now) { - return handle_paste_burst_decision(app, decision, c, now); - } - app.insert_char(c); - return true; - } - - let decision = app.paste_burst.on_plain_char(c, now); - return handle_paste_burst_decision(app, decision, c, now); - } - _ => {} - } - - false -} - fn apply_alt_4_shortcut(app: &mut App, modifiers: KeyModifiers) { if modifiers.contains(KeyModifiers::CONTROL) { app.set_sidebar_focus(SidebarFocus::Agents); @@ -1574,55 +1531,6 @@ fn apply_alt_4_shortcut(app: &mut App, modifiers: KeyModifiers) { } } -fn handle_paste_burst_decision( - app: &mut App, - decision: CharDecision, - c: char, - now: Instant, -) -> bool { - match decision { - CharDecision::RetainFirstChar => true, - CharDecision::BeginBufferFromPending | CharDecision::BufferAppend => { - app.paste_burst.append_char_to_buffer(c, now); - true - } - CharDecision::BeginBuffer { retro_chars } => { - if apply_paste_burst_retro_capture(app, retro_chars as usize, c, now) { - return true; - } - app.insert_char(c); - true - } - } -} - -fn apply_paste_burst_retro_capture( - app: &mut App, - retro_chars: usize, - c: char, - now: Instant, -) -> bool { - let cursor_byte = app.cursor_byte_index(); - let before = &app.input[..cursor_byte]; - let Some(grab) = app - .paste_burst - .decide_begin_buffer(now, before, retro_chars) - else { - return false; - }; - if !grab.grabbed.is_empty() { - app.input.replace_range(grab.start_byte..cursor_byte, ""); - let removed = grab.grabbed.chars().count(); - app.cursor_position = app.cursor_position.saturating_sub(removed); - } - app.paste_burst.append_char_to_buffer(c, now); - true -} - -fn in_command_context(app: &App) -> bool { - app.input.starts_with('/') -} - fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec { if app.slash_menu_hidden { return Vec::new();