Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/loopal-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -49,6 +50,8 @@ pub struct App {
pub focused_bg_task: Option<String>,
/// 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,

Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-tui/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-tui/src/input/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
33 changes: 29 additions & 4 deletions crates/loopal-tui/src/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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<InputAction> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 => {
Expand Down
197 changes: 197 additions & 0 deletions crates/loopal-tui/src/input/scroll_debounce.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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);
}
}
}
9 changes: 9 additions & 0 deletions crates/loopal-tui/src/key_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
10 changes: 9 additions & 1 deletion crates/loopal-tui/src/tui_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-tui/tests/suite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading