Skip to content

Commit fa85fa9

Browse files
fix(tui): decouple mouse scroll from input history via timing-based debounce (#85)
* fix(tui): decouple mouse scroll from input history via timing-based debounce xterm alternate scroll (\x1b[?1007h) converts mouse wheel events into Up/Down arrow keys, creating an unresolvable conflict between content scrolling and history navigation. Previous attempts (#6, #13, #30, #42, #79) toggled priority chains without a lasting fix because the two event sources share the same key codes. Resolve by introducing a 30 ms debounce state machine that exploits the timing difference between mouse wheel bursts (<5 ms between events) and keyboard presses (>20 ms): Idle → Pending (defer 30 ms) → Scrolling (burst confirmed) → History (timer expired / other key) - Mouse wheel: rapid-fire Up/Down detected as burst → scroll content - Keyboard Up/Down: single isolated event → history navigation - Multiline cursor: bypasses debounce entirely (immediate response) - Ctrl+P/N: dedicated history bindings, always immediate - Global/modal/autocomplete keys: discard pending debounce (prevents stale timer from polluting state after Ctrl+C, paste, etc.) Stale timer and dropped-tick resilience: - Pending state checks elapsed time on second arrow to handle delayed timers; stale pending is flushed as history before starting new - Scrolling state has lazy 150 ms expiry check in addition to tick- based cleanup, so dropped ticks don't leave stale scroll mode * fix: rustfmt — alphabetical module order in suite.rs + line-length formatting
1 parent 699e362 commit fa85fa9

11 files changed

Lines changed: 512 additions & 36 deletions

File tree

crates/loopal-tui/src/app/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use loopal_session::SessionController;
1414
use loopal_tool_background::BackgroundTaskStore;
1515

1616
use crate::command::CommandRegistry;
17+
use crate::input::scroll_debounce::ArrowDebounce;
1718
use crate::views::progress::LineCache;
1819

1920
/// Main application state — UI-only fields + session controller handle.
@@ -49,6 +50,8 @@ pub struct App {
4950
pub focused_bg_task: Option<String>,
5051
/// Which UI region owns keyboard focus.
5152
pub focus_mode: FocusMode,
53+
/// Arrow-key debounce state for mouse-wheel vs keyboard detection.
54+
pub(crate) arrow_debounce: ArrowDebounce,
5255
/// Scroll offset for the agent panel (index of first visible agent).
5356
pub agent_panel_offset: usize,
5457

@@ -94,6 +97,7 @@ impl App {
9497
focused_agent: None,
9598
focused_bg_task: None,
9699
focus_mode: FocusMode::default(),
100+
arrow_debounce: ArrowDebounce::default(),
97101
agent_panel_offset: 0,
98102
bg_store: BackgroundTaskStore::new(),
99103
bg_snapshots: Vec::new(),

crates/loopal-tui/src/event.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pub enum AppEvent {
1717
Paste(PasteResult),
1818
/// Tick for periodic UI refresh
1919
Tick,
20+
/// Arrow-key debounce timer expired — flush pending arrow as history
21+
ArrowDebounceTimeout,
2022
}
2123

2224
/// Merges crossterm terminal events with agent events into a single stream.

crates/loopal-tui/src/input/actions.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,6 @@ pub enum InputAction {
6464
QuestionCancel,
6565
/// User pressed Ctrl+V — caller should spawn async clipboard read
6666
PasteRequested,
67+
/// Arrow key deferred — start 30 ms debounce timer for scroll detection
68+
StartArrowDebounce,
6769
}

crates/loopal-tui/src/input/mod.rs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod modal;
66
pub(crate) mod multiline;
77
mod navigation;
88
pub(crate) mod paste;
9+
pub(crate) mod scroll_debounce;
910
mod status_page_keys;
1011
mod sub_page;
1112
mod sub_page_rewind;
@@ -20,18 +21,22 @@ use editing::{handle_backspace, handle_ctrl_c, handle_enter};
2021
use navigation::{
2122
DEFAULT_WRAP_WIDTH, handle_down, handle_esc, handle_up, move_cursor_left, move_cursor_right,
2223
};
24+
use scroll_debounce::{ScrollDirection, handle_arrow_with_debounce, resolve_pending_arrow};
2325

2426
/// Process a key event and update the app's input state.
2527
pub fn handle_key(app: &mut App, key: KeyEvent) -> InputAction {
2628
if let Some(action) = modal::handle_modal_keys(app, &key) {
29+
scroll_debounce::discard_pending(app);
2730
return action;
2831
}
2932
if let Some(action) = handle_global_keys(app, &key) {
33+
scroll_debounce::discard_pending(app);
3034
return action;
3135
}
3236
if app.autocomplete.is_some()
3337
&& let Some(action) = handle_autocomplete_key(app, &key)
3438
{
39+
scroll_debounce::discard_pending(app);
3540
return action;
3641
}
3742

@@ -40,6 +45,14 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> InputAction {
4045
action
4146
}
4247

48+
/// Flush any pending arrow-key debounce as history navigation.
49+
///
50+
/// Called by the event loop when the 30 ms debounce timer expires and by
51+
/// tests to simulate the timeout deterministically.
52+
pub fn resolve_arrow_debounce(app: &mut App) {
53+
scroll_debounce::resolve_pending_arrow(app);
54+
}
55+
4356
/// Handle global shortcuts: Ctrl combos, Shift+Tab.
4457
fn handle_global_keys(app: &mut App, key: &KeyEvent) -> Option<InputAction> {
4558
if key.modifiers.contains(KeyModifiers::CONTROL) {
@@ -110,10 +123,20 @@ fn handle_panel_key(app: &mut App, key: &KeyEvent) -> InputAction {
110123

111124
/// Keys in Input mode: typing, navigation, submit.
112125
fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction {
113-
// Auto-scroll to bottom on input interaction (except scroll/panel/escape keys)
126+
// Flush any pending arrow debounce on non-arrow key input.
127+
if !matches!(key.code, KeyCode::Up | KeyCode::Down) {
128+
resolve_pending_arrow(app);
129+
}
130+
// Auto-scroll to bottom on input interaction (except scroll/panel/escape/arrow keys).
131+
// Arrow keys are exempt because they may become scroll via debounce.
114132
if !matches!(
115133
key.code,
116-
KeyCode::PageUp | KeyCode::PageDown | KeyCode::Tab | KeyCode::Esc
134+
KeyCode::PageUp
135+
| KeyCode::PageDown
136+
| KeyCode::Tab
137+
| KeyCode::Esc
138+
| KeyCode::Up
139+
| KeyCode::Down
117140
) {
118141
app.scroll_offset = 0;
119142
}
@@ -154,8 +177,10 @@ fn handle_input_mode_key(app: &mut App, key: &KeyEvent) -> InputAction {
154177
multiline::line_end(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH);
155178
InputAction::None
156179
}
157-
KeyCode::Up => handle_up(app),
158-
KeyCode::Down => handle_down(app),
180+
KeyCode::Up | KeyCode::Down => {
181+
let dir = ScrollDirection::from_key(key.code).unwrap();
182+
handle_arrow_with_debounce(app, dir)
183+
}
159184
KeyCode::Tab => InputAction::EnterPanel,
160185
KeyCode::Esc => handle_esc(app),
161186
KeyCode::PageUp => {
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
//! Arrow-key debounce: distinguishes mouse-wheel bursts from keyboard presses.
2+
//!
3+
//! xterm alternate scroll (`\x1b[?1007h`) translates mouse wheel into Up/Down
4+
//! arrow keys. This module uses timing to tell them apart:
5+
//! - Rapid-fire (< 30 ms gap) → mouse wheel → content scroll
6+
//! - Isolated (> 30 ms) → keyboard → history navigation
7+
//!
8+
//! State: `Idle → Pending (30 ms) → Scrolling (150 ms idle → Idle)`
9+
//! Second arrow within window → burst → Scrolling.
10+
//! Other key or timer expiry → flush Pending as history.
11+
12+
use std::time::{Duration, Instant};
13+
14+
use crossterm::event::KeyCode;
15+
16+
use super::InputAction;
17+
use super::multiline;
18+
use super::navigation::{DEFAULT_WRAP_WIDTH, handle_down, handle_up};
19+
use crate::app::App;
20+
21+
/// Window within which a second arrow event is considered a mouse-wheel burst.
22+
const BURST_DETECT_MS: u64 = 30;
23+
24+
/// After this idle period the scroll burst ends and state returns to Idle.
25+
const SCROLL_IDLE_MS: u64 = 150;
26+
27+
/// Scroll direction derived from arrow key code.
28+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29+
pub(crate) enum ScrollDirection {
30+
Up,
31+
Down,
32+
}
33+
34+
impl ScrollDirection {
35+
pub(crate) fn from_key(code: KeyCode) -> Option<Self> {
36+
match code {
37+
KeyCode::Up => Some(Self::Up),
38+
KeyCode::Down => Some(Self::Down),
39+
_ => None,
40+
}
41+
}
42+
}
43+
44+
/// Arrow-key debounce state.
45+
#[derive(Debug, Default)]
46+
pub(crate) enum ArrowDebounce {
47+
/// No pending arrow event.
48+
#[default]
49+
Idle,
50+
/// First arrow received; waiting to see if a burst follows.
51+
Pending {
52+
direction: ScrollDirection,
53+
time: Instant,
54+
},
55+
/// Mouse-wheel burst confirmed; subsequent arrows scroll content.
56+
Scrolling { last_time: Instant },
57+
}
58+
59+
/// Called by `handle_input_mode_key` when Up or Down is pressed.
60+
///
61+
/// Multiline cursor navigation bypasses debounce entirely (immediate).
62+
/// Otherwise returns `StartArrowDebounce` when the first arrow is deferred,
63+
/// or `None` when handled inline (scroll / burst continuation).
64+
pub(super) fn handle_arrow_with_debounce(app: &mut App, direction: ScrollDirection) -> InputAction {
65+
// Multiline cursor navigation is always immediate — never debounced.
66+
// This keeps multiline editing responsive and avoids burst misfires
67+
// from fast keyboard repeat in multi-line input fields.
68+
if try_multiline_nav(app, direction) {
69+
app.arrow_debounce = ArrowDebounce::Idle;
70+
return InputAction::None;
71+
}
72+
73+
match app.arrow_debounce {
74+
ArrowDebounce::Idle => {
75+
app.arrow_debounce = ArrowDebounce::Pending {
76+
direction,
77+
time: Instant::now(),
78+
};
79+
InputAction::StartArrowDebounce
80+
}
81+
ArrowDebounce::Pending {
82+
direction: old_dir,
83+
time,
84+
} => {
85+
if time.elapsed() < burst_detect_duration() {
86+
// Second event within burst window → mouse-wheel burst → scroll.
87+
app.arrow_debounce = ArrowDebounce::Scrolling {
88+
last_time: Instant::now(),
89+
};
90+
apply_scroll(app, old_dir);
91+
apply_scroll(app, direction);
92+
InputAction::None
93+
} else {
94+
// Timer was delayed. Flush stale pending as history, then
95+
// start a new debounce for this event.
96+
process_as_history(app, old_dir);
97+
app.arrow_debounce = ArrowDebounce::Pending {
98+
direction,
99+
time: Instant::now(),
100+
};
101+
InputAction::StartArrowDebounce
102+
}
103+
}
104+
ArrowDebounce::Scrolling { last_time } => {
105+
if last_time.elapsed() > Duration::from_millis(SCROLL_IDLE_MS) {
106+
// Tick was dropped or delayed. Treat as fresh Idle state.
107+
app.arrow_debounce = ArrowDebounce::Pending {
108+
direction,
109+
time: Instant::now(),
110+
};
111+
InputAction::StartArrowDebounce
112+
} else {
113+
app.arrow_debounce = ArrowDebounce::Scrolling {
114+
last_time: Instant::now(),
115+
};
116+
apply_scroll(app, direction);
117+
InputAction::None
118+
}
119+
}
120+
}
121+
}
122+
123+
/// Discard pending debounce without processing as history.
124+
///
125+
/// Used by modal/global/autocomplete handlers that supersede the pending
126+
/// arrow event. The stale 30 ms timer will see `Idle` and become a no-op.
127+
pub(crate) fn discard_pending(app: &mut App) {
128+
app.arrow_debounce = ArrowDebounce::Idle;
129+
}
130+
131+
/// Flush any pending arrow as a history navigation action.
132+
///
133+
/// Called when a non-arrow key arrives or when the debounce timer expires.
134+
pub(crate) fn resolve_pending_arrow(app: &mut App) {
135+
match std::mem::replace(&mut app.arrow_debounce, ArrowDebounce::Idle) {
136+
ArrowDebounce::Pending { direction, .. } => {
137+
process_as_history(app, direction);
138+
}
139+
ArrowDebounce::Scrolling { .. } | ArrowDebounce::Idle => {}
140+
}
141+
}
142+
143+
/// Expire stale Scrolling state (called from Tick handler).
144+
pub(crate) fn tick_debounce(app: &mut App) {
145+
if let ArrowDebounce::Scrolling { last_time } = app.arrow_debounce
146+
&& last_time.elapsed() > Duration::from_millis(SCROLL_IDLE_MS)
147+
{
148+
app.arrow_debounce = ArrowDebounce::Idle;
149+
}
150+
}
151+
152+
/// Burst detection window.
153+
pub(crate) fn burst_detect_duration() -> Duration {
154+
Duration::from_millis(BURST_DETECT_MS)
155+
}
156+
157+
fn try_multiline_nav(app: &mut App, direction: ScrollDirection) -> bool {
158+
if !multiline::is_multiline(&app.input, DEFAULT_WRAP_WIDTH) {
159+
return false;
160+
}
161+
let new_cursor = match direction {
162+
ScrollDirection::Up => {
163+
multiline::cursor_up(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH)
164+
}
165+
ScrollDirection::Down => {
166+
multiline::cursor_down(&app.input, app.input_cursor, DEFAULT_WRAP_WIDTH)
167+
}
168+
};
169+
if let Some(pos) = new_cursor {
170+
app.input_cursor = pos;
171+
true
172+
} else {
173+
false
174+
}
175+
}
176+
177+
fn process_as_history(app: &mut App, direction: ScrollDirection) {
178+
match direction {
179+
ScrollDirection::Up => {
180+
handle_up(app);
181+
}
182+
ScrollDirection::Down => {
183+
handle_down(app);
184+
}
185+
}
186+
}
187+
188+
fn apply_scroll(app: &mut App, direction: ScrollDirection) {
189+
match direction {
190+
ScrollDirection::Up => {
191+
app.scroll_offset = app.scroll_offset.saturating_add(3);
192+
}
193+
ScrollDirection::Down => {
194+
app.scroll_offset = app.scroll_offset.saturating_sub(3);
195+
}
196+
}
197+
}

crates/loopal-tui/src/key_dispatch.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,5 +187,14 @@ pub(crate) async fn handle_key_action(
187187
false
188188
}
189189
InputAction::None => false,
190+
InputAction::StartArrowDebounce => {
191+
let tx = events.sender();
192+
let wait = crate::input::scroll_debounce::burst_detect_duration();
193+
tokio::spawn(async move {
194+
tokio::time::sleep(wait).await;
195+
let _ = tx.send(crate::event::AppEvent::ArrowDebounceTimeout).await;
196+
});
197+
false
198+
}
190199
}
191200
}

crates/loopal-tui/src/tui_loop.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,15 @@ where
8282
AppEvent::Paste(result) => {
8383
paste::apply_paste_result(app, result);
8484
}
85-
AppEvent::Resize(_, _) | AppEvent::Tick => {}
85+
AppEvent::ArrowDebounceTimeout => {
86+
// Flush pending arrow as history if still pending; stale
87+
// timeouts (Idle/Scrolling) are ignored by resolve.
88+
crate::input::scroll_debounce::resolve_pending_arrow(app);
89+
}
90+
AppEvent::Resize(_, _) => {}
91+
AppEvent::Tick => {
92+
crate::input::scroll_debounce::tick_debounce(app);
93+
}
8694
}
8795
}
8896

crates/loopal-tui/tests/suite.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ mod message_lines_test;
6060
mod panel_tab_test;
6161
#[path = "suite/render_guard_test.rs"]
6262
mod render_guard_test;
63+
#[path = "suite/scroll_burst_test.rs"]
64+
mod scroll_burst_test;
6365
#[path = "suite/skill_render_test.rs"]
6466
mod skill_render_test;
6567
#[path = "suite/styled_wrap_test.rs"]

0 commit comments

Comments
 (0)