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
71 changes: 48 additions & 23 deletions crates/loopal-tui/src/event.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEventKind};
use loopal_protocol::AgentEvent;
use tokio::sync::mpsc;

Expand All @@ -9,6 +9,10 @@ use crate::input::paste::PasteResult;
pub enum AppEvent {
/// Keyboard / terminal event
Key(KeyEvent),
/// Mouse scroll up (wheel toward user → content scrolls toward top)
ScrollUp,
/// Mouse scroll down (wheel away from user → content scrolls toward bottom)
ScrollDown,
/// Resize event
Resize(u16, u16),
/// Agent event from the runtime
Expand Down Expand Up @@ -38,37 +42,58 @@ impl EventHandler {
// blocks the agent-side `event_tx.send().await` — deadlock.
let (tx, rx) = mpsc::channel(4096);

// Spawn crossterm event polling task
// Spawn crossterm event polling task.
//
// Reads ALL buffered events in a single `spawn_blocking` call so
// that rapid events (e.g. paste sequences) land in the channel
// together, improving batch processing in `tui_loop.rs`.
let term_tx = tx.clone();
tokio::spawn(async move {
loop {
// Poll crossterm with a 50ms timeout to yield periodically
match tokio::task::spawn_blocking(|| {
if event::poll(std::time::Duration::from_millis(50)).unwrap_or(false) {
event::read().ok()
} else {
None
let result = tokio::task::spawn_blocking(|| {
// Wait up to 50ms for the first event.
if !event::poll(std::time::Duration::from_millis(50)).unwrap_or(false) {
return Vec::new();
}
})
.await
{
Ok(Some(CrosstermEvent::Key(key))) => {
if term_tx.send(AppEvent::Key(key)).await.is_err() {
break;
}
let mut events = Vec::new();
if let Ok(ev) = event::read() {
events.push(ev);
}
Ok(Some(CrosstermEvent::Resize(w, h))) => {
if term_tx.send(AppEvent::Resize(w, h)).await.is_err() {
break;
// Drain any additional buffered events without waiting.
while event::poll(std::time::Duration::ZERO).unwrap_or(false) {
match event::read() {
Ok(ev) => events.push(ev),
Err(_) => break,
}
}
Ok(Some(CrosstermEvent::Paste(text))) => {
let result = PasteResult::Text(text);
if term_tx.send(AppEvent::Paste(result)).await.is_err() {
break;
events
})
.await;

match result {
Ok(events) if events.is_empty() => continue,
Ok(events) => {
for ev in events {
let app_event = match ev {
CrosstermEvent::Key(key) => Some(AppEvent::Key(key)),
CrosstermEvent::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => Some(AppEvent::ScrollUp),
MouseEventKind::ScrollDown => Some(AppEvent::ScrollDown),
_ => None,
},
CrosstermEvent::Resize(w, h) => Some(AppEvent::Resize(w, h)),
CrosstermEvent::Paste(text) => {
Some(AppEvent::Paste(PasteResult::Text(text)))
}
_ => None,
};
if let Some(app_event) = app_event
&& term_tx.send(app_event).await.is_err()
{
return;
}
}
}
Ok(_) => {}
Err(_) => break,
}
}
Expand Down
40 changes: 3 additions & 37 deletions crates/loopal-tui/src/terminal.rs
Original file line number Diff line number Diff line change
@@ -1,45 +1,11 @@
use std::fmt;
use std::io;

use crossterm::{
Command,
event::{DisableBracketedPaste, EnableBracketedPaste},
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};

/// Enable xterm alternate scroll mode (`\x1b[?1007h`).
///
/// In alternate screen the terminal translates mouse wheel events into
/// Up/Down arrow key sequences. This preserves terminal-native text
/// selection (click + drag) while providing scroll wheel support.
struct EnableAlternateScroll;

impl Command for EnableAlternateScroll {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
write!(f, "\x1b[?1007h")
}

#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Ok(())
}
}

/// Disable xterm alternate scroll mode (`\x1b[?1007l`).
struct DisableAlternateScroll;

impl Command for DisableAlternateScroll {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
write!(f, "\x1b[?1007l")
}

#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Ok(())
}
}

/// RAII guard that ensures raw mode and alternate screen are cleaned up on drop,
/// even if the TUI panics or returns early via `?`.
pub struct TerminalGuard;
Expand All @@ -51,7 +17,7 @@ impl TerminalGuard {
execute!(
stdout,
EnterAlternateScreen,
EnableAlternateScroll,
EnableMouseCapture,
EnableBracketedPaste
)?;
Ok(Self)
Expand All @@ -64,7 +30,7 @@ impl Drop for TerminalGuard {
let _ = execute!(
io::stdout(),
DisableBracketedPaste,
DisableAlternateScroll,
DisableMouseCapture,
LeaveAlternateScreen
);
}
Expand Down
33 changes: 5 additions & 28 deletions crates/loopal-tui/src/tui_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use std::io;
use std::path::PathBuf;
use std::sync::Arc;

use crossterm::event::KeyCode;
use ratatui::prelude::*;

use loopal_protocol::{AgentEvent, AgentEventPayload};
Expand Down Expand Up @@ -61,36 +60,14 @@ where
batch.push(event);
}

// Pre-scan: count Up/Down arrow key events in this batch.
// Mouse wheel (via \x1b[?1007h]) generates ≥2 arrows per tick;
// a single keyboard press generates exactly 1.
let arrow_count = batch
.iter()
.filter(|e| {
matches!(
e,
AppEvent::Key(k) if matches!(k.code, KeyCode::Up | KeyCode::Down)
)
})
.count();
let is_scroll_burst = arrow_count >= 2;

let mut should_quit = false;
for event in batch {
match event {
AppEvent::Key(key)
if is_scroll_burst && matches!(key.code, KeyCode::Up | KeyCode::Down) =>
{
// Mouse-wheel burst → scroll content, bypass key handler.
match key.code {
KeyCode::Up => {
app.content_scroll.scroll_up(3);
}
KeyCode::Down => {
app.content_scroll.scroll_down(3);
}
_ => unreachable!(),
}
AppEvent::ScrollUp => {
app.content_scroll.scroll_up(3);
}
AppEvent::ScrollDown => {
app.content_scroll.scroll_down(3);
}
AppEvent::Key(key) => {
should_quit = handle_key_action(app, key, &events).await;
Expand Down
4 changes: 2 additions & 2 deletions crates/loopal-tui/tests/suite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ 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/scroll_compensation_test.rs"]
mod scroll_compensation_test;
#[path = "suite/scroll_test.rs"]
mod scroll_test;
#[path = "suite/skill_render_test.rs"]
mod skill_render_test;
#[path = "suite/styled_wrap_test.rs"]
Expand Down
55 changes: 43 additions & 12 deletions crates/loopal-tui/tests/suite/e2e_scroll_test.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/// E2E integration tests for scroll behavior through the full TUI event loop.
///
/// Mouse scroll uses `AppEvent::ScrollUp/ScrollDown` (deterministic, no heuristic).
/// Keyboard Up/Down always navigates history via `handle_key_action`.
///
/// Regression tests for:
/// - Bug 1: Mouse wheel (≥2 arrow events in batch) scrolls content, not history
/// - Bug 1: Mouse scroll events scroll content, not history
/// - Bug 2: Single keyboard arrow navigates history, not scroll
/// - Bug 3: Scroll offset is compensated when content grows during streaming
use std::sync::Arc;
Expand Down Expand Up @@ -65,19 +68,18 @@ fn stream_event(text: &str) -> AppEvent {
}))
}

// --- Bug 1 regression: burst arrows → scroll, not history ---
// --- Bug 1 regression: scroll events → scroll content, not history ---

#[tokio::test]
async fn test_burst_arrows_scroll_not_history() {
async fn test_scroll_events_scroll_not_history() {
let (mut terminal, mut app, events, tx) = build_scroll_rig();

tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
// Send 3 Up events in rapid succession (mouse wheel burst).
// They should all land in the same batch via try_next().
let _ = tx.send(AppEvent::Key(up_key())).await;
let _ = tx.send(AppEvent::Key(up_key())).await;
let _ = tx.send(AppEvent::Key(up_key())).await;
// Mouse wheel fires ScrollUp events (deterministic, no batch detection).
let _ = tx.send(AppEvent::ScrollUp).await;
let _ = tx.send(AppEvent::ScrollUp).await;
let _ = tx.send(AppEvent::ScrollUp).await;
tokio::time::sleep(Duration::from_millis(30)).await;
let _ = tx.send(AppEvent::Key(ctrl_d())).await;
});
Expand All @@ -90,17 +92,46 @@ async fn test_burst_arrows_scroll_not_history() {

assert!(
app.content_scroll.offset > 0,
"burst should scroll content, got offset={}",
"ScrollUp should scroll content, got offset={}",
app.content_scroll.offset
);
assert!(
app.input.is_empty(),
"burst should NOT navigate history, got input={:?}",
"ScrollUp should NOT navigate history, got input={:?}",
app.input
);
}

// --- Bug 1 regression: single arrow → history, not scroll ---
#[tokio::test]
async fn test_scroll_down_decreases_offset() {
let (mut terminal, mut app, events, tx) = build_scroll_rig();

tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
// Scroll up first to create an offset
let _ = tx.send(AppEvent::ScrollUp).await;
let _ = tx.send(AppEvent::ScrollUp).await;
let _ = tx.send(AppEvent::ScrollUp).await;
let _ = tx.send(AppEvent::ScrollUp).await;
// Then scroll down
let _ = tx.send(AppEvent::ScrollDown).await;
tokio::time::sleep(Duration::from_millis(30)).await;
let _ = tx.send(AppEvent::Key(ctrl_d())).await;
});

let _ = tokio::time::timeout(
Duration::from_secs(3),
run_tui_loop(&mut terminal, events, &mut app),
)
.await;

assert_eq!(
app.content_scroll.offset, 9,
"4 ScrollUp (12) - 1 ScrollDown (3) = 9"
);
}

// --- Bug 2 regression: single arrow → history, not scroll ---

#[tokio::test]
async fn test_single_arrow_navigates_history() {
Expand Down Expand Up @@ -130,7 +161,7 @@ async fn test_single_arrow_navigates_history() {
);
}

// --- Bug 2 regression: scroll offset compensated during streaming ---
// --- Bug 3 regression: scroll offset compensated during streaming ---

#[tokio::test]
async fn test_scroll_offset_stable_during_streaming() {
Expand Down
Loading
Loading