diff --git a/crates/loopal-tui/src/app/mod.rs b/crates/loopal-tui/src/app/mod.rs index a29063e..9b214a8 100644 --- a/crates/loopal-tui/src/app/mod.rs +++ b/crates/loopal-tui/src/app/mod.rs @@ -14,6 +14,7 @@ use loopal_session::SessionController; use loopal_tool_background::BackgroundTaskStore; use crate::command::CommandRegistry; +use crate::input::scroll_debounce::ArrowDebounce; use crate::views::progress::LineCache; /// Main application state — UI-only fields + session controller handle. @@ -49,6 +50,8 @@ pub struct App { pub focused_bg_task: Option, /// Which UI region owns keyboard focus. pub focus_mode: FocusMode, + /// Arrow-key debounce state for mouse-wheel vs keyboard detection. + pub(crate) arrow_debounce: ArrowDebounce, /// Scroll offset for the agent panel (index of first visible agent). pub agent_panel_offset: usize, @@ -94,6 +97,7 @@ impl App { focused_agent: None, focused_bg_task: None, focus_mode: FocusMode::default(), + arrow_debounce: ArrowDebounce::default(), agent_panel_offset: 0, bg_store: BackgroundTaskStore::new(), bg_snapshots: Vec::new(), diff --git a/crates/loopal-tui/src/event.rs b/crates/loopal-tui/src/event.rs index 2b4b3ab..479fd41 100644 --- a/crates/loopal-tui/src/event.rs +++ b/crates/loopal-tui/src/event.rs @@ -17,6 +17,8 @@ pub enum AppEvent { Paste(PasteResult), /// Tick for periodic UI refresh Tick, + /// Arrow-key debounce timer expired — flush pending arrow as history + ArrowDebounceTimeout, } /// Merges crossterm terminal events with agent events into a single stream. diff --git a/crates/loopal-tui/src/input/actions.rs b/crates/loopal-tui/src/input/actions.rs index 301e4e2..79102a7 100644 --- a/crates/loopal-tui/src/input/actions.rs +++ b/crates/loopal-tui/src/input/actions.rs @@ -64,4 +64,6 @@ pub enum InputAction { QuestionCancel, /// User pressed Ctrl+V — caller should spawn async clipboard read PasteRequested, + /// Arrow key deferred — start 30 ms debounce timer for scroll detection + StartArrowDebounce, } diff --git a/crates/loopal-tui/src/input/mod.rs b/crates/loopal-tui/src/input/mod.rs index 98417a1..ce13486 100644 --- a/crates/loopal-tui/src/input/mod.rs +++ b/crates/loopal-tui/src/input/mod.rs @@ -6,6 +6,7 @@ mod modal; pub(crate) mod multiline; mod navigation; pub(crate) mod paste; +pub(crate) mod scroll_debounce; mod status_page_keys; mod sub_page; mod sub_page_rewind; @@ -20,18 +21,22 @@ use editing::{handle_backspace, handle_ctrl_c, handle_enter}; use navigation::{ DEFAULT_WRAP_WIDTH, handle_down, handle_esc, handle_up, move_cursor_left, move_cursor_right, }; +use scroll_debounce::{ScrollDirection, handle_arrow_with_debounce, resolve_pending_arrow}; /// Process a key event and update the app's input state. pub fn handle_key(app: &mut App, key: KeyEvent) -> InputAction { if let Some(action) = modal::handle_modal_keys(app, &key) { + scroll_debounce::discard_pending(app); return action; } if let Some(action) = handle_global_keys(app, &key) { + scroll_debounce::discard_pending(app); return action; } if app.autocomplete.is_some() && let Some(action) = handle_autocomplete_key(app, &key) { + scroll_debounce::discard_pending(app); return action; } @@ -40,6 +45,14 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> InputAction { action } +/// Flush any pending arrow-key debounce as history navigation. +/// +/// Called by the event loop when the 30 ms debounce timer expires and by +/// tests to simulate the timeout deterministically. +pub fn resolve_arrow_debounce(app: &mut App) { + scroll_debounce::resolve_pending_arrow(app); +} + /// Handle global shortcuts: Ctrl combos, Shift+Tab. fn handle_global_keys(app: &mut App, key: &KeyEvent) -> Option { if key.modifiers.contains(KeyModifiers::CONTROL) { @@ -110,10 +123,20 @@ fn handle_panel_key(app: &mut App, key: &KeyEvent) -> InputAction { /// Keys in Input mode: typing, navigation, submit. fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction { - // Auto-scroll to bottom on input interaction (except scroll/panel/escape keys) + // Flush any pending arrow debounce on non-arrow key input. + if !matches!(key.code, KeyCode::Up | KeyCode::Down) { + resolve_pending_arrow(app); + } + // Auto-scroll to bottom on input interaction (except scroll/panel/escape/arrow keys). + // Arrow keys are exempt because they may become scroll via debounce. if !matches!( key.code, - KeyCode::PageUp | KeyCode::PageDown | KeyCode::Tab | KeyCode::Esc + KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Tab + | KeyCode::Esc + | KeyCode::Up + | KeyCode::Down ) { app.scroll_offset = 0; } @@ -154,8 +177,10 @@ fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction { multiline::line_end(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH); InputAction::None } - KeyCode::Up => handle_up(app), - KeyCode::Down => handle_down(app), + KeyCode::Up | KeyCode::Down => { + let dir = ScrollDirection::from_key(key.code).unwrap(); + handle_arrow_with_debounce(app, dir) + } KeyCode::Tab => InputAction::EnterPanel, KeyCode::Esc => handle_esc(app), KeyCode::PageUp => { diff --git a/crates/loopal-tui/src/input/scroll_debounce.rs b/crates/loopal-tui/src/input/scroll_debounce.rs new file mode 100644 index 0000000..5cb5b98 --- /dev/null +++ b/crates/loopal-tui/src/input/scroll_debounce.rs @@ -0,0 +1,197 @@ +//! Arrow-key debounce: distinguishes mouse-wheel bursts from keyboard presses. +//! +//! xterm alternate scroll (`\x1b[?1007h`) translates mouse wheel into Up/Down +//! arrow keys. This module uses timing to tell them apart: +//! - Rapid-fire (< 30 ms gap) → mouse wheel → content scroll +//! - Isolated (> 30 ms) → keyboard → history navigation +//! +//! State: `Idle → Pending (30 ms) → Scrolling (150 ms idle → Idle)` +//! Second arrow within window → burst → Scrolling. +//! Other key or timer expiry → flush Pending as history. + +use std::time::{Duration, Instant}; + +use crossterm::event::KeyCode; + +use super::InputAction; +use super::multiline; +use super::navigation::{DEFAULT_WRAP_WIDTH, handle_down, handle_up}; +use crate::app::App; + +/// Window within which a second arrow event is considered a mouse-wheel burst. +const BURST_DETECT_MS: u64 = 30; + +/// After this idle period the scroll burst ends and state returns to Idle. +const SCROLL_IDLE_MS: u64 = 150; + +/// Scroll direction derived from arrow key code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScrollDirection { + Up, + Down, +} + +impl ScrollDirection { + pub(crate) fn from_key(code: KeyCode) -> Option { + match code { + KeyCode::Up => Some(Self::Up), + KeyCode::Down => Some(Self::Down), + _ => None, + } + } +} + +/// Arrow-key debounce state. +#[derive(Debug, Default)] +pub(crate) enum ArrowDebounce { + /// No pending arrow event. + #[default] + Idle, + /// First arrow received; waiting to see if a burst follows. + Pending { + direction: ScrollDirection, + time: Instant, + }, + /// Mouse-wheel burst confirmed; subsequent arrows scroll content. + Scrolling { last_time: Instant }, +} + +/// Called by `handle_input_mode_key` when Up or Down is pressed. +/// +/// Multiline cursor navigation bypasses debounce entirely (immediate). +/// Otherwise returns `StartArrowDebounce` when the first arrow is deferred, +/// or `None` when handled inline (scroll / burst continuation). +pub(super) fn handle_arrow_with_debounce(app: &mut App, direction: ScrollDirection) -> InputAction { + // Multiline cursor navigation is always immediate — never debounced. + // This keeps multiline editing responsive and avoids burst misfires + // from fast keyboard repeat in multi-line input fields. + if try_multiline_nav(app, direction) { + app.arrow_debounce = ArrowDebounce::Idle; + return InputAction::None; + } + + match app.arrow_debounce { + ArrowDebounce::Idle => { + app.arrow_debounce = ArrowDebounce::Pending { + direction, + time: Instant::now(), + }; + InputAction::StartArrowDebounce + } + ArrowDebounce::Pending { + direction: old_dir, + time, + } => { + if time.elapsed() < burst_detect_duration() { + // Second event within burst window → mouse-wheel burst → scroll. + app.arrow_debounce = ArrowDebounce::Scrolling { + last_time: Instant::now(), + }; + apply_scroll(app, old_dir); + apply_scroll(app, direction); + InputAction::None + } else { + // Timer was delayed. Flush stale pending as history, then + // start a new debounce for this event. + process_as_history(app, old_dir); + app.arrow_debounce = ArrowDebounce::Pending { + direction, + time: Instant::now(), + }; + InputAction::StartArrowDebounce + } + } + ArrowDebounce::Scrolling { last_time } => { + if last_time.elapsed() > Duration::from_millis(SCROLL_IDLE_MS) { + // Tick was dropped or delayed. Treat as fresh Idle state. + app.arrow_debounce = ArrowDebounce::Pending { + direction, + time: Instant::now(), + }; + InputAction::StartArrowDebounce + } else { + app.arrow_debounce = ArrowDebounce::Scrolling { + last_time: Instant::now(), + }; + apply_scroll(app, direction); + InputAction::None + } + } + } +} + +/// Discard pending debounce without processing as history. +/// +/// Used by modal/global/autocomplete handlers that supersede the pending +/// arrow event. The stale 30 ms timer will see `Idle` and become a no-op. +pub(crate) fn discard_pending(app: &mut App) { + app.arrow_debounce = ArrowDebounce::Idle; +} + +/// Flush any pending arrow as a history navigation action. +/// +/// Called when a non-arrow key arrives or when the debounce timer expires. +pub(crate) fn resolve_pending_arrow(app: &mut App) { + match std::mem::replace(&mut app.arrow_debounce, ArrowDebounce::Idle) { + ArrowDebounce::Pending { direction, .. } => { + process_as_history(app, direction); + } + ArrowDebounce::Scrolling { .. } | ArrowDebounce::Idle => {} + } +} + +/// Expire stale Scrolling state (called from Tick handler). +pub(crate) fn tick_debounce(app: &mut App) { + if let ArrowDebounce::Scrolling { last_time } = app.arrow_debounce + && last_time.elapsed() > Duration::from_millis(SCROLL_IDLE_MS) + { + app.arrow_debounce = ArrowDebounce::Idle; + } +} + +/// Burst detection window. +pub(crate) fn burst_detect_duration() -> Duration { + Duration::from_millis(BURST_DETECT_MS) +} + +fn try_multiline_nav(app: &mut App, direction: ScrollDirection) -> bool { + if !multiline::is_multiline(&app.input, DEFAULT_WRAP_WIDTH) { + return false; + } + let new_cursor = match direction { + ScrollDirection::Up => { + multiline::cursor_up(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH) + } + ScrollDirection::Down => { + multiline::cursor_down(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH) + } + }; + if let Some(pos) = new_cursor { + app.input_cursor = pos; + true + } else { + false + } +} + +fn process_as_history(app: &mut App, direction: ScrollDirection) { + match direction { + ScrollDirection::Up => { + handle_up(app); + } + ScrollDirection::Down => { + handle_down(app); + } + } +} + +fn apply_scroll(app: &mut App, direction: ScrollDirection) { + match direction { + ScrollDirection::Up => { + app.scroll_offset = app.scroll_offset.saturating_add(3); + } + ScrollDirection::Down => { + app.scroll_offset = app.scroll_offset.saturating_sub(3); + } + } +} diff --git a/crates/loopal-tui/src/key_dispatch.rs b/crates/loopal-tui/src/key_dispatch.rs index ab32484..0e3e621 100644 --- a/crates/loopal-tui/src/key_dispatch.rs +++ b/crates/loopal-tui/src/key_dispatch.rs @@ -187,5 +187,14 @@ pub(crate) async fn handle_key_action( false } InputAction::None => false, + InputAction::StartArrowDebounce => { + let tx = events.sender(); + let wait = crate::input::scroll_debounce::burst_detect_duration(); + tokio::spawn(async move { + tokio::time::sleep(wait).await; + let _ = tx.send(crate::event::AppEvent::ArrowDebounceTimeout).await; + }); + false + } } } diff --git a/crates/loopal-tui/src/tui_loop.rs b/crates/loopal-tui/src/tui_loop.rs index 8c8fc23..66720fb 100644 --- a/crates/loopal-tui/src/tui_loop.rs +++ b/crates/loopal-tui/src/tui_loop.rs @@ -84,7 +84,15 @@ where AppEvent::Paste(result) => { paste::apply_paste_result(app, result); } - AppEvent::Resize(_, _) | AppEvent::Tick => {} + AppEvent::ArrowDebounceTimeout => { + // Flush pending arrow as history if still pending; stale + // timeouts (Idle/Scrolling) are ignored by resolve. + crate::input::scroll_debounce::resolve_pending_arrow(app); + } + AppEvent::Resize(_, _) => {} + AppEvent::Tick => { + crate::input::scroll_debounce::tick_debounce(app); + } } } diff --git a/crates/loopal-tui/tests/suite.rs b/crates/loopal-tui/tests/suite.rs index 496e0cf..8055c01 100644 --- a/crates/loopal-tui/tests/suite.rs +++ b/crates/loopal-tui/tests/suite.rs @@ -60,6 +60,8 @@ mod message_lines_test; mod panel_tab_test; #[path = "suite/render_guard_test.rs"] mod render_guard_test; +#[path = "suite/scroll_burst_test.rs"] +mod scroll_burst_test; #[path = "suite/skill_render_test.rs"] mod skill_render_test; #[path = "suite/styled_wrap_test.rs"] diff --git a/crates/loopal-tui/tests/suite/input_scroll_edge_test.rs b/crates/loopal-tui/tests/suite/input_scroll_edge_test.rs index 5e4e571..731eb5c 100644 --- a/crates/loopal-tui/tests/suite/input_scroll_edge_test.rs +++ b/crates/loopal-tui/tests/suite/input_scroll_edge_test.rs @@ -6,7 +6,7 @@ use loopal_protocol::ControlCommand; use loopal_protocol::UserQuestionResponse; use loopal_session::SessionController; use loopal_tui::app::App; -use loopal_tui::input::handle_key; +use loopal_tui::input::{handle_key, resolve_arrow_debounce}; use tokio::sync::mpsc; fn make_app() -> App { @@ -33,6 +33,11 @@ fn ctrl(c: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) } +fn arrow(app: &mut App, code: KeyCode) { + handle_key(app, key(code)); + resolve_arrow_debounce(app); +} + // --- Auto-reset scroll: per-key verification --- #[test] @@ -73,14 +78,14 @@ fn test_esc_preserves_scroll_offset() { assert_eq!(app.scroll_offset, 3, "Esc should not reset scroll"); } -// --- Agent busy: Up/Down still navigates history --- +// --- Agent busy: Up/Down still navigates history (after debounce) --- #[test] fn test_up_navigates_history_when_agent_busy() { let mut app = make_app(); app.session.lock().active_conversation_mut().agent_idle = false; app.input_history.push("prev".into()); - handle_key(&mut app, key(KeyCode::Up)); + arrow(&mut app, KeyCode::Up); assert_eq!( app.input, "prev", "Up should navigate history even when agent busy" @@ -107,7 +112,7 @@ fn test_up_at_first_line_falls_through_to_history() { app.input = "line1\nline2".into(); app.input_cursor = 2; // middle of line1 (already at first visual line) app.input_history.push("old command".into()); - handle_key(&mut app, key(KeyCode::Up)); + arrow(&mut app, KeyCode::Up); assert_eq!( app.input, "old command", "Up at first line should fall through to history" @@ -120,15 +125,53 @@ fn test_down_at_last_line_falls_through_to_history() { // First enter history via Up app.input_history.push("first".into()); app.input_history.push("second".into()); - handle_key(&mut app, key(KeyCode::Up)); // "second" - handle_key(&mut app, key(KeyCode::Up)); // "first" + arrow(&mut app, KeyCode::Up); // "second" + arrow(&mut app, KeyCode::Up); // "first" // Now set multiline content and cursor at last line app.input = "line1\nline2".into(); app.input_cursor = app.input.len(); // end of line2 - handle_key(&mut app, key(KeyCode::Down)); + arrow(&mut app, KeyCode::Down); // cursor_down returns None (at last line), falls through to history forward assert_eq!( app.input, "second", "Down at last line should fall through to history" ); } + +// --- Debounce resolution on non-arrow key --- + +#[test] +fn test_typing_after_up_resolves_pending_as_history() { + let mut app = make_app(); + app.input_history.push("hist".into()); + // Press Up — starts debounce + handle_key(&mut app, key(KeyCode::Up)); + assert!(app.input.is_empty(), "Up is pending, not yet resolved"); + // Type 'x' — resolves the pending Up as history, then inserts 'x' + handle_key(&mut app, key(KeyCode::Char('x'))); + assert_eq!( + app.input, "histx", + "pending Up resolves as history, then 'x' appends" + ); +} + +// --- Global shortcut discards pending debounce --- + +#[test] +fn test_ctrl_c_discards_pending_debounce() { + let mut app = make_app(); + app.input = "some text".into(); + app.input_cursor = 9; + app.input_history.push("hist".into()); + // Press Up — starts debounce + handle_key(&mut app, key(KeyCode::Up)); + // Ctrl+C — clears input AND discards pending debounce + handle_key(&mut app, ctrl('c')); + assert!(app.input.is_empty(), "Ctrl+C should clear input"); + // Stale timer fires — should be a no-op, not load history + resolve_arrow_debounce(&mut app); + assert!( + app.input.is_empty(), + "stale timer after Ctrl+C must not load history" + ); +} diff --git a/crates/loopal-tui/tests/suite/input_scroll_test.rs b/crates/loopal-tui/tests/suite/input_scroll_test.rs index a7eef8c..40b405f 100644 --- a/crates/loopal-tui/tests/suite/input_scroll_test.rs +++ b/crates/loopal-tui/tests/suite/input_scroll_test.rs @@ -1,20 +1,13 @@ /// Tests for Up/Down key routing, Ctrl+P/N history, and multiline priority. -/// -/// Priority chain for Up/Down in Input mode: -/// 1. Multiline cursor navigation (Shift+Enter input) -/// 2. History navigation -/// -/// Content scrolling is exclusively handled by PageUp/PageDown. -/// All input-mode keys (except PageUp/PageDown/Tab/Esc) auto-reset scroll_offset to 0. -/// -/// Ctrl+P/N always navigate history regardless of scroll state. +/// Up/Down goes through arrow-key debounce (30 ms window); multiline bypasses it. +/// Ctrl+P/N always navigate history. Burst detection tests in edge test file. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use loopal_protocol::ControlCommand; use loopal_protocol::UserQuestionResponse; use loopal_session::SessionController; use loopal_tui::app::App; -use loopal_tui::input::{InputAction, handle_key}; +use loopal_tui::input::{InputAction, handle_key, resolve_arrow_debounce}; use tokio::sync::mpsc; fn make_app() -> App { @@ -41,6 +34,13 @@ fn ctrl(c: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) } +/// Simulate a single keyboard arrow press: sends the key then resolves the +/// debounce timer (equivalent to 30 ms passing with no burst). +fn arrow(app: &mut App, code: KeyCode) { + handle_key(app, key(code)); + resolve_arrow_debounce(app); +} + // --- PageUp / PageDown --- #[test] @@ -52,13 +52,13 @@ fn test_page_up_down_scroll() { assert_eq!(app.scroll_offset, 0); } -// --- Up/Down navigate history --- +// --- Up/Down navigate history (after debounce resolves as keyboard) --- #[test] fn test_up_navigates_history_with_content() { let mut app = make_app(); app.input_history.push("previous".into()); - handle_key(&mut app, key(KeyCode::Up)); + arrow(&mut app, KeyCode::Up); assert_eq!( app.input, "previous", "Up should browse history, not scroll" @@ -72,10 +72,10 @@ fn test_down_resets_scroll_and_navigates_history() { app.scroll_offset = 5; app.input_history.push("first".into()); app.input_history.push("second".into()); - handle_key(&mut app, key(KeyCode::Up)); - handle_key(&mut app, key(KeyCode::Up)); + arrow(&mut app, KeyCode::Up); + arrow(&mut app, KeyCode::Up); assert_eq!(app.input, "first"); - handle_key(&mut app, key(KeyCode::Down)); + arrow(&mut app, KeyCode::Down); assert_eq!(app.input, "second", "Down should navigate history forward"); assert_eq!(app.scroll_offset, 0, "scroll_offset should be 0"); } @@ -86,7 +86,7 @@ fn test_up_navigates_history_when_idle() { app.session.lock().active_conversation_mut().agent_idle = true; app.input_history.push("older".into()); app.input_history.push("recent".into()); - handle_key(&mut app, key(KeyCode::Up)); + arrow(&mut app, KeyCode::Up); assert_eq!(app.input, "recent", "Up should browse history"); assert_eq!(app.scroll_offset, 0); } @@ -97,10 +97,10 @@ fn test_down_navigates_history_forward() { app.session.lock().active_conversation_mut().agent_idle = true; app.input_history.push("first".into()); app.input_history.push("second".into()); - handle_key(&mut app, key(KeyCode::Up)); - handle_key(&mut app, key(KeyCode::Up)); + arrow(&mut app, KeyCode::Up); + arrow(&mut app, KeyCode::Up); assert_eq!(app.input, "first"); - handle_key(&mut app, key(KeyCode::Down)); + arrow(&mut app, KeyCode::Down); assert_eq!(app.input, "second", "Down should navigate history forward"); } @@ -110,7 +110,8 @@ fn test_up_navigates_history_when_content_fits() { app.session.lock().active_conversation_mut().agent_idle = true; app.input_history.push("previous command".into()); let action = handle_key(&mut app, key(KeyCode::Up)); - assert!(matches!(action, InputAction::None)); + assert!(matches!(action, InputAction::StartArrowDebounce)); + resolve_arrow_debounce(&mut app); assert_eq!(app.input, "previous command", "Up browses history"); assert_eq!(app.scroll_offset, 0); } @@ -141,14 +142,16 @@ fn test_ctrl_n_navigates_history_forward() { assert_eq!(app.input, "second", "Ctrl+N browses history forward"); } -// --- Multiline cursor priority over history --- +// --- Multiline cursor priority (bypasses debounce) --- #[test] fn test_up_multiline_cursor_beats_history() { let mut app = make_app(); app.input = "line1\nline2".into(); app.input_cursor = app.input.len(); - handle_key(&mut app, key(KeyCode::Up)); + let action = handle_key(&mut app, key(KeyCode::Up)); + // Multiline nav is immediate — no debounce + assert!(matches!(action, InputAction::None)); assert_eq!(app.scroll_offset, 0, "should move cursor, not scroll"); assert!( app.input_cursor < "line1\n".len(), @@ -162,7 +165,8 @@ fn test_down_multiline_cursor_beats_history() { let mut app = make_app(); app.input = "line1\nline2".into(); app.input_cursor = 0; - handle_key(&mut app, key(KeyCode::Down)); + let action = handle_key(&mut app, key(KeyCode::Down)); + assert!(matches!(action, InputAction::None)); assert_eq!(app.scroll_offset, 0, "should move cursor, not scroll"); assert!( app.input_cursor >= "line1\n".len(), @@ -187,7 +191,7 @@ fn test_up_resets_scroll_offset() { let mut app = make_app(); app.scroll_offset = 3; app.input_history.push("cmd".into()); - handle_key(&mut app, key(KeyCode::Up)); + arrow(&mut app, KeyCode::Up); assert_eq!(app.scroll_offset, 0, "Up should reset scroll to bottom"); assert_eq!(app.input, "cmd"); } diff --git a/crates/loopal-tui/tests/suite/scroll_burst_test.rs b/crates/loopal-tui/tests/suite/scroll_burst_test.rs new file mode 100644 index 0000000..a0743eb --- /dev/null +++ b/crates/loopal-tui/tests/suite/scroll_burst_test.rs @@ -0,0 +1,180 @@ +/// Tests for arrow-key debounce: mouse-wheel burst detection and stale state. +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use loopal_protocol::ControlCommand; +use loopal_protocol::UserQuestionResponse; +use loopal_session::SessionController; +use loopal_tui::app::App; +use loopal_tui::input::{InputAction, handle_key, resolve_arrow_debounce}; +use tokio::sync::mpsc; + +fn make_app() -> App { + let (control_tx, _) = mpsc::channel::(16); + let (perm_tx, _) = mpsc::channel::(16); + let (question_tx, _) = mpsc::channel::(16); + let session = SessionController::new( + "test-model".into(), + "act".into(), + control_tx, + perm_tx, + question_tx, + Default::default(), + std::sync::Arc::new(tokio::sync::watch::channel(0u64).0), + ); + App::new(session, std::env::temp_dir()) +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) +} + +fn arrow(app: &mut App, code: KeyCode) { + handle_key(app, key(code)); + resolve_arrow_debounce(app); +} + +// --- Mouse wheel burst detection --- + +#[test] +fn test_rapid_up_burst_scrolls_content() { + let mut app = make_app(); + app.input_history.push("should not appear".into()); + handle_key(&mut app, key(KeyCode::Up)); + handle_key(&mut app, key(KeyCode::Up)); + assert!(app.scroll_offset > 0, "burst should scroll content"); + assert!(app.input.is_empty(), "burst should NOT navigate history"); +} + +#[test] +fn test_rapid_down_burst_scrolls_content() { + let mut app = make_app(); + app.scroll_offset = 20; + handle_key(&mut app, key(KeyCode::Down)); + handle_key(&mut app, key(KeyCode::Down)); + assert!(app.scroll_offset < 20, "burst should scroll down"); +} + +#[test] +fn test_continuous_scroll_burst() { + let mut app = make_app(); + handle_key(&mut app, key(KeyCode::Up)); + handle_key(&mut app, key(KeyCode::Up)); + handle_key(&mut app, key(KeyCode::Up)); + // 1st+2nd: Pending→Scrolling (scroll 2×3=6), 3rd: continues (+3) + assert_eq!(app.scroll_offset, 9, "3 rapid Up events should scroll 9"); +} + +#[test] +fn test_down_burst_exact_offset() { + let mut app = make_app(); + app.scroll_offset = 30; + handle_key(&mut app, key(KeyCode::Down)); + handle_key(&mut app, key(KeyCode::Down)); + // Pending→Scrolling: scroll down 2×3=6 + assert_eq!(app.scroll_offset, 24, "2 rapid Down should reduce by 6"); +} + +// --- Mixed direction burst --- + +#[test] +fn test_mixed_direction_burst_applies_both() { + let mut app = make_app(); + app.scroll_offset = 10; + handle_key(&mut app, key(KeyCode::Up)); // Pending(Up) + handle_key(&mut app, key(KeyCode::Down)); // burst: scroll Up+3 then Down-3 + // Net effect: +3 - 3 = 0 change + assert_eq!(app.scroll_offset, 10, "Up then Down burst should net zero"); +} + +// --- Stale Scrolling degrades --- + +#[test] +fn test_stale_scrolling_degrades_to_debounce() { + let mut app = make_app(); + app.input_history.push("hist".into()); + handle_key(&mut app, key(KeyCode::Up)); + handle_key(&mut app, key(KeyCode::Up)); + assert!(app.scroll_offset > 0); + std::thread::sleep(std::time::Duration::from_millis(200)); + let action = handle_key(&mut app, key(KeyCode::Up)); + assert!( + matches!(action, InputAction::StartArrowDebounce), + "stale Scrolling should degrade to new debounce" + ); + resolve_arrow_debounce(&mut app); + assert_eq!(app.input, "hist"); +} + +// --- Empty history + debounce --- + +#[test] +fn test_up_with_empty_history_does_nothing() { + let mut app = make_app(); + // No history entries + arrow(&mut app, KeyCode::Up); + assert!( + app.input.is_empty(), + "Up with no history should leave input empty" + ); + assert_eq!(app.scroll_offset, 0); +} + +// --- Ctrl+P during Pending state discards pending --- + +#[test] +fn test_ctrl_p_during_pending_discards_deferred() { + let mut app = make_app(); + app.input_history.push("first".into()); + app.input_history.push("second".into()); + // Up → Pending (deferred) + let action = handle_key(&mut app, key(KeyCode::Up)); + assert!(matches!(action, InputAction::StartArrowDebounce)); + assert!(app.input.is_empty(), "Up is deferred, input still empty"); + // Ctrl+P → navigates history; discard pending debounce + handle_key(&mut app, ctrl('p')); + assert_eq!(app.input, "second", "Ctrl+P navigates history"); + // Stale timer fires — discarded, no second navigation + resolve_arrow_debounce(&mut app); + assert_eq!(app.input, "second", "stale timer is no-op after discard"); +} + +// --- Burst then different action --- + +#[test] +fn test_scroll_burst_then_type_resets_debounce() { + let mut app = make_app(); + handle_key(&mut app, key(KeyCode::Up)); + handle_key(&mut app, key(KeyCode::Up)); + assert!(app.scroll_offset > 0, "burst should scroll"); + // Typing resets scroll and clears Scrolling state + handle_key(&mut app, key(KeyCode::Char('x'))); + assert_eq!(app.scroll_offset, 0, "typing resets scroll"); + assert_eq!(app.input, "x"); +} + +// --- Sequential burst: up then down --- + +#[test] +fn test_sequential_up_then_down_bursts() { + let mut app = make_app(); + // Up burst + handle_key(&mut app, key(KeyCode::Up)); + handle_key(&mut app, key(KeyCode::Up)); + handle_key(&mut app, key(KeyCode::Up)); + let after_up = app.scroll_offset; + assert!(after_up > 0); + // Wait for Scrolling to expire + std::thread::sleep(std::time::Duration::from_millis(200)); + // Down burst + handle_key(&mut app, key(KeyCode::Down)); + handle_key(&mut app, key(KeyCode::Down)); + handle_key(&mut app, key(KeyCode::Down)); + assert!( + app.scroll_offset < after_up, + "Down burst should reduce offset" + ); +}