diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9aba46ec..26a7074b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2,6 +2,7 @@ use crate::app_event::AppEvent; use crate::chatwidget::ChatWidget; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; +use crate::scroll_event_helper::ScrollEventHelper; use crate::tui; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; @@ -10,6 +11,8 @@ use codex_core::protocol::SandboxPolicy; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use std::sync::mpsc::channel; use std::sync::mpsc::Receiver; use std::sync::mpsc::Sender; @@ -39,6 +42,7 @@ impl App<'_> { model: Option, ) -> Self { let (app_event_tx, app_event_rx) = channel(); + let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone()); // Spawn a dedicated thread for reading the crossterm event loop and // re-publishing the events as AppEvents, as appropriate. @@ -49,10 +53,21 @@ impl App<'_> { let app_event = match event { crossterm::event::Event::Key(key_event) => AppEvent::KeyEvent(key_event), crossterm::event::Event::Resize(_, _) => AppEvent::Redraw, - crossterm::event::Event::FocusGained - | crossterm::event::Event::FocusLost - | crossterm::event::Event::Mouse(_) - | crossterm::event::Event::Paste(_) => { + crossterm::event::Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + .. + }) => { + scroll_event_helper.scroll_up(); + continue; + } + crossterm::event::Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + .. + }) => { + scroll_event_helper.scroll_down(); + continue; + } + _ => { continue; } }; @@ -125,6 +140,9 @@ impl App<'_> { } }; } + AppEvent::Scroll(scroll_delta) => { + self.dispatch_scroll_event(scroll_delta); + } AppEvent::CodexEvent(event) => { self.dispatch_codex_event(event); } @@ -184,6 +202,14 @@ impl App<'_> { } } + fn dispatch_scroll_event(&mut self, scroll_delta: i32) { + if matches!(self.app_state, AppState::Chat) { + if let Err(e) = self.chat_widget.handle_scroll_delta(scroll_delta) { + tracing::error!("SendError: {e}"); + } + } + } + fn dispatch_codex_event(&mut self, event: Event) { if matches!(self.app_state, AppState::Chat) { if let Err(e) = self.chat_widget.handle_codex_event(event) { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bb8efb8e..2b320375 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -3,8 +3,15 @@ use crossterm::event::KeyEvent; pub(crate) enum AppEvent { CodexEvent(Event), + Redraw, + KeyEvent(KeyEvent), + + /// Scroll event with a value representing the "scroll delta" as the net + /// scroll up/down events within a short time window. + Scroll(i32), + /// Request to exit the application gracefully. ExitRequest, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 149cea42..64cb896d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -364,6 +364,23 @@ impl ChatWidget<'_> { Ok(()) } + pub(crate) fn handle_scroll_delta( + &mut self, + scroll_delta: i32, + ) -> std::result::Result<(), std::sync::mpsc::SendError> { + // If the user is trying to scroll exactly one line, we let them, but + // otherwise we assume they are trying to scroll in larger increments. + let magnified_scroll_delta = if scroll_delta == 1 { + 1 + } else { + // Play with this: perhaps it should be non-linear? + scroll_delta * 2 + }; + self.conversation_history.scroll(magnified_scroll_delta); + self.request_redraw()?; + Ok(()) + } + /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { if let Err(e) = self.codex_op_tx.send(op) { diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index c8f69061..27b5e9b3 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -40,11 +40,11 @@ impl ConversationHistoryWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> bool { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - self.scroll_up(); + self.scroll_up(1); true } KeyCode::Down | KeyCode::Char('j') => { - self.scroll_down(); + self.scroll_down(1); true } KeyCode::PageUp | KeyCode::Char('b') | KeyCode::Char('u') | KeyCode::Char('U') => { @@ -59,9 +59,18 @@ impl ConversationHistoryWidget { } } - fn scroll_up(&mut self) { - // If a user is scrolling up from the "stick to bottom" mode, we - // need to scroll them back such that they move just one line up. + /// Negative delta scrolls up; positive delta scrolls down. + pub(crate) fn scroll(&mut self, delta: i32) { + match delta.cmp(&0) { + std::cmp::Ordering::Less => self.scroll_up(-delta as u32), + std::cmp::Ordering::Greater => self.scroll_down(delta as u32), + std::cmp::Ordering::Equal => {} + } + } + + fn scroll_up(&mut self, num_lines: u32) { + // If a user is scrolling up from the "stick to bottom" mode, we need to + // map this to a specific scroll position so we can caluate the delta. // This requires us to care about how tall the screen is. if self.scroll_position == usize::MAX { self.scroll_position = self @@ -70,24 +79,26 @@ impl ConversationHistoryWidget { .saturating_sub(self.last_viewport_height.get()); } - self.scroll_position = self.scroll_position.saturating_sub(1); + self.scroll_position = self.scroll_position.saturating_sub(num_lines as usize); } - fn scroll_down(&mut self) { + fn scroll_down(&mut self, num_lines: u32) { // If we're already pinned to the bottom there's nothing to do. if self.scroll_position == usize::MAX { return; } let viewport_height = self.last_viewport_height.get().max(1); - let num_lines = self.num_rendered_lines.get(); + let num_rendered_lines = self.num_rendered_lines.get(); // Compute the maximum explicit scroll offset that still shows a full // viewport. This mirrors the calculation in `scroll_page_down()` and // in the render path. - let max_scroll = num_lines.saturating_sub(viewport_height).saturating_add(1); + let max_scroll = num_rendered_lines + .saturating_sub(viewport_height) + .saturating_add(1); - let new_pos = self.scroll_position.saturating_add(1); + let new_pos = self.scroll_position.saturating_add(num_lines as usize); if new_pos >= max_scroll { // Reached (or passed) the bottom – switch to stick‑to‑bottom mode diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 598d3eaf..7361663b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -21,6 +21,7 @@ mod exec_command; mod git_warning_screen; mod history_cell; mod log_layer; +mod scroll_event_helper; mod status_indicator_widget; mod tui; mod user_approval_widget; diff --git a/codex-rs/tui/src/scroll_event_helper.rs b/codex-rs/tui/src/scroll_event_helper.rs new file mode 100644 index 00000000..7c358157 --- /dev/null +++ b/codex-rs/tui/src/scroll_event_helper.rs @@ -0,0 +1,77 @@ +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicI32; +use std::sync::atomic::Ordering; +use std::sync::mpsc::Sender; +use std::sync::Arc; + +use tokio::runtime::Handle; +use tokio::time::sleep; +use tokio::time::Duration; + +use crate::app_event::AppEvent; + +pub(crate) struct ScrollEventHelper { + app_event_tx: Sender, + scroll_delta: Arc, + timer_scheduled: Arc, + runtime: Handle, +} + +/// How long to wait after the first scroll event before sending the +/// accumulated scroll delta to the main thread. +const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100); + +/// Utility to debounce scroll events so we can determine the **magnitude** of +/// each scroll burst by accumulating individual wheel events over a short +/// window. The debounce timer now runs on Tokio so we avoid spinning up a new +/// operating-system thread for every burst. +impl ScrollEventHelper { + pub(crate) fn new(app_event_tx: Sender) -> Self { + Self { + app_event_tx, + scroll_delta: Arc::new(AtomicI32::new(0)), + timer_scheduled: Arc::new(AtomicBool::new(false)), + runtime: Handle::current(), + } + } + + pub(crate) fn scroll_up(&self) { + self.scroll_delta.fetch_sub(1, Ordering::Relaxed); + self.schedule_notification(); + } + + pub(crate) fn scroll_down(&self) { + self.scroll_delta.fetch_add(1, Ordering::Relaxed); + self.schedule_notification(); + } + + /// Starts a one-shot timer **only once** per burst of wheel events. + fn schedule_notification(&self) { + // If the timer is already scheduled, do nothing. + if self + .timer_scheduled + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + + // Otherwise, schedule a new timer. + let tx = self.app_event_tx.clone(); + let delta = Arc::clone(&self.scroll_delta); + let timer_flag = Arc::clone(&self.timer_scheduled); + + // Use self.runtime instead of tokio::spawn() because the calling thread + // in app.rs is not part of the Tokio runtime: it is a plain OS thread. + self.runtime.spawn(async move { + sleep(DEBOUNCE_WINDOW).await; + + let accumulated = delta.swap(0, Ordering::SeqCst); + if accumulated != 0 { + let _ = tx.send(AppEvent::Scroll(accumulated)); + } + + timer_flag.store(false, Ordering::SeqCst); + }); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 0753dcb0..8cc54460 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -2,6 +2,8 @@ use std::io::stdout; use std::io::Stdout; use std::io::{self}; +use crossterm::event::DisableMouseCapture; +use crossterm::event::EnableMouseCapture; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; @@ -16,6 +18,7 @@ pub type Tui = Terminal>; /// Initialize the terminal pub fn init() -> io::Result { execute!(stdout(), EnterAlternateScreen)?; + execute!(stdout(), EnableMouseCapture)?; enable_raw_mode()?; set_panic_hook(); Terminal::new(CrosstermBackend::new(stdout())) @@ -31,6 +34,7 @@ fn set_panic_hook() { /// Restore the terminal to its original state pub fn restore() -> io::Result<()> { + execute!(stdout(), DisableMouseCapture)?; execute!(stdout(), LeaveAlternateScreen)?; disable_raw_mode()?; Ok(())