From bcafbd142c81cd6eca881c59eb6ff5630dfe24bc Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 4 Jul 2026 13:24:36 -0300 Subject: [PATCH] feat(tui): add owned screen wheel scrolling --- codex-rs/tui/src/app.rs | 15 ++ codex-rs/tui/src/app/owned_screen.rs | 32 +++ codex-rs/tui/src/app/owned_screen_tests.rs | 59 +++++ codex-rs/tui/src/conversation_viewport.rs | 5 + .../tui/src/conversation_viewport_tests.rs | 54 +++++ codex-rs/tui/src/cwd_prompt.rs | 2 +- .../src/external_agent_config_migration.rs | 2 +- codex-rs/tui/src/lib.rs | 2 +- codex-rs/tui/src/model_migration.rs | 2 +- .../tui/src/onboarding/onboarding_screen.rs | 1 + codex-rs/tui/src/pager_overlay.rs | 156 +++++++++++-- codex-rs/tui/src/resume_picker.rs | 1 + codex-rs/tui/src/startup_hooks_review.rs | 2 +- codex-rs/tui/src/tui.rs | 25 ++- codex-rs/tui/src/tui/event_stream.rs | 91 +++++++- codex-rs/tui/src/tui/screen_session.rs | 206 +++++++++++++++--- codex-rs/tui/src/tui/screen_session_tests.rs | 122 ++++++++++- codex-rs/tui/src/update_prompt.rs | 2 +- 18 files changed, 716 insertions(+), 63 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5a7999af6e..4c0f016fc1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1298,6 +1298,21 @@ See the Codex keymap documentation for supported actions and examples." let pasted = pasted.replace("\r", "\n"); self.chat_widget.handle_paste(pasted); } + TuiEvent::MouseScroll(event) => { + let handled = self.handle_owned_screen_mouse_scroll(tui, event); + if !handled && !self.chat_widget.no_modal_or_popup_active() { + let key_code = match event.direction { + tui::MouseScrollDirection::Up => KeyCode::Up, + tui::MouseScrollDirection::Down => KeyCode::Down, + }; + self.handle_key_event( + tui, + app_server, + KeyEvent::new(key_code, KeyModifiers::NONE), + ) + .await; + } + } TuiEvent::Draw | TuiEvent::Resize => { if self.backtrack_render_pending { self.rebuild_transcript_after_backtrack(tui)?; diff --git a/codex-rs/tui/src/app/owned_screen.rs b/codex-rs/tui/src/app/owned_screen.rs index 148908ebe8..f80b93ddcf 100644 --- a/codex-rs/tui/src/app/owned_screen.rs +++ b/codex-rs/tui/src/app/owned_screen.rs @@ -8,12 +8,14 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; +use ratatui::layout::Position; use ratatui::layout::Rect; use ratatui::widgets::Clear; use ratatui::widgets::Widget; use super::*; use crate::AltScreenBehavior; +use crate::tui::MouseScrollEvent; pub(super) struct OwnedScreen { viewport: ConversationViewport, @@ -92,6 +94,17 @@ impl OwnedScreen { self.viewport .handle_navigation_key(self.last_conversation_area, key_event) } + + fn handle_mouse_scroll(&mut self, event: MouseScrollEvent) -> bool { + if !self + .last_conversation_area + .contains(Position::new(event.column, event.row)) + { + return false; + } + self.viewport.handle_mouse_scroll(event.direction); + true + } } impl App { @@ -153,6 +166,25 @@ impl App { handled } + pub(super) fn handle_owned_screen_mouse_scroll( + &mut self, + tui: &mut tui::Tui, + event: MouseScrollEvent, + ) -> bool { + if !self.chat_widget.no_modal_or_popup_active() { + return false; + } + let handled = self + .owned_screen + .as_mut() + .is_some_and(|screen| screen.handle_mouse_scroll(event)); + if handled { + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + } + handled + } + pub(crate) fn sync_owned_screen_cells(&mut self) { if let Some(screen) = &mut self.owned_screen { screen.viewport.replace_cells(self.transcript_cells.clone()); diff --git a/codex-rs/tui/src/app/owned_screen_tests.rs b/codex-rs/tui/src/app/owned_screen_tests.rs index 56b99b84a3..9908b0ec64 100644 --- a/codex-rs/tui/src/app/owned_screen_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_tests.rs @@ -10,6 +10,8 @@ use tokio::sync::broadcast::error::TryRecvError; use super::*; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; +use crate::tui::MouseScrollDirection; +use crate::tui::MouseScrollEvent; #[derive(Debug)] struct TestCell(&'static str); @@ -147,3 +149,60 @@ async fn navigation_does_not_steal_printable_or_draft_input() { KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), )); } + +#[tokio::test] +async fn mouse_wheel_scrolls_transcript_without_changing_draft() { + let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; + chat_widget.set_composer_text("draft sentinel".to_string(), Vec::new(), Vec::new()); + let mut screen = OwnedScreen::new(&chat_widget, crate::keymap::RuntimeKeymap::defaults().pager); + for text in ["oldest", "older", "middle", "newer", "LATEST"] { + screen.viewport.push_cell(Arc::new(TestCell(text))); + } + let mut terminal = + Terminal::new(TestBackend::new(/*width*/ 40, /*height*/ 8)).expect("create terminal"); + terminal + .draw(|frame| { + screen.render(&chat_widget, frame.area(), frame.buffer_mut()); + }) + .expect("render bottom"); + + assert!(screen.handle_mouse_scroll(MouseScrollEvent { + direction: MouseScrollDirection::Up, + column: 2, + row: 2, + })); + terminal + .draw(|frame| { + screen.render(&chat_widget, frame.area(), frame.buffer_mut()); + }) + .expect("render scrolled"); + + assert_snapshot!(terminal.backend(), @r###" +" " +"middle " +" " +" " +" " +"› draft sentinel " +" " +" gpt-5.5 default · /tmp/project " +"###); + assert!(!screen.viewport.is_following_bottom()); + assert!(!screen.handle_mouse_scroll(MouseScrollEvent { + direction: MouseScrollDirection::Up, + column: 2, + row: 7, + })); + + assert!(screen.handle_mouse_scroll(MouseScrollEvent { + direction: MouseScrollDirection::Down, + column: 2, + row: 2, + })); + terminal + .draw(|frame| { + screen.render(&chat_widget, frame.area(), frame.buffer_mut()); + }) + .expect("render restored bottom"); + assert!(screen.viewport.is_following_bottom()); +} diff --git a/codex-rs/tui/src/conversation_viewport.rs b/codex-rs/tui/src/conversation_viewport.rs index 40b2a9be81..e99366c0ac 100644 --- a/codex-rs/tui/src/conversation_viewport.rs +++ b/codex-rs/tui/src/conversation_viewport.rs @@ -27,6 +27,7 @@ use crate::render::renderable::Renderable; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::mark_buffer_hyperlinks; use crate::terminal_hyperlinks::visible_lines; +use crate::tui::MouseScrollDirection; pub(crate) struct ConversationViewport { content: PagerContent, @@ -66,6 +67,10 @@ impl ConversationViewport { self.content.handle_navigation_key(area, key_event) } + pub(crate) fn handle_mouse_scroll(&mut self, direction: MouseScrollDirection) { + self.content.handle_mouse_scroll(direction); + } + pub(crate) fn push_cell(&mut self, cell: Arc) { let follow_bottom = self.content.is_following_bottom(); let had_prior_cells = !self.cells.is_empty(); diff --git a/codex-rs/tui/src/conversation_viewport_tests.rs b/codex-rs/tui/src/conversation_viewport_tests.rs index 2a0a23fe0c..95911faad9 100644 --- a/codex-rs/tui/src/conversation_viewport_tests.rs +++ b/codex-rs/tui/src/conversation_viewport_tests.rs @@ -11,6 +11,7 @@ use ratatui::text::Line; use super::*; use crate::history_cell::AgentMarkdownCell; +use crate::tui::MouseScrollDirection; #[derive(Debug)] struct TestCell { @@ -274,6 +275,59 @@ LATEST "###); } +#[test] +fn mouse_wheel_leaves_and_restores_bottom_follow() { + let mut viewport = viewport(vec![ + cell("oldest"), + cell("older"), + cell("middle"), + cell("newer"), + cell("LATEST"), + ]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3, + ); + let mut bottom = Buffer::empty(area); + viewport.render(area, &mut bottom); + + viewport.handle_mouse_scroll(MouseScrollDirection::Up); + let mut scrolled = Buffer::empty(area); + viewport.render(area, &mut scrolled); + assert!(!viewport.is_following_bottom()); + + viewport.handle_mouse_scroll(MouseScrollDirection::Down); + let mut restored = Buffer::empty(area); + viewport.render(area, &mut restored); + assert!(viewport.is_following_bottom()); + + let trim_rows = |buffer: &Buffer| { + buffer_text(buffer, area) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + }; + assert_snapshot!(format!( + "bottom:\n{}\nscrolled:\n{}\nrestored:\n{}", + trim_rows(&bottom), + trim_rows(&scrolled), + trim_rows(&restored), + ), @r###" +bottom: +newer + +LATEST +scrolled: + +middle + +restored: +newer + +LATEST +"###); +} + fn buffer_text(buffer: &Buffer, area: Rect) -> String { let mut rows = Vec::new(); for y in area.y..area.bottom() { diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index 264fa39c79..f6ff661406 100644 --- a/codex-rs/tui/src/cwd_prompt.rs +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -96,7 +96,7 @@ pub(crate) async fn run_cwd_selection_prompt( if let Some(event) = events.next().await { match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {} TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/external_agent_config_migration.rs b/codex-rs/tui/src/external_agent_config_migration.rs index 995c97f517..b8f2b3ecfd 100644 --- a/codex-rs/tui/src/external_agent_config_migration.rs +++ b/codex-rs/tui/src/external_agent_config_migration.rs @@ -99,7 +99,7 @@ pub(crate) async fn run_external_agent_config_migration_prompt( if let Some(event) = events.next().await { match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {} TuiEvent::Draw | TuiEvent::Resize => { let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ef43de2d11..25551dccc1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1787,7 +1787,7 @@ async fn run_ratatui_app( }; if alt_screen_behavior == AltScreenBehavior::Owned { - tui.enter_alt_screen()?; + tui.enter_owned_alt_screen()?; } let app_result = App::run( &mut tui, diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index c307abb78f..a920c02850 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -152,7 +152,7 @@ pub(crate) async fn run_model_migration_prompt( if let Some(event) = events.next().await { match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {} TuiEvent::Draw | TuiEvent::Resize => { let _ = alt.tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 405b37743c..e56399cc91 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -509,6 +509,7 @@ pub(crate) async fn run_onboarding_app( TuiEvent::Paste(text) => { onboarding_screen.handle_paste(text); } + TuiEvent::MouseScroll(_) => {} TuiEvent::Draw | TuiEvent::Resize => { if !did_full_clear_after_success && onboarding_screen.steps.iter().any(|step| { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 9dbe512dbc..2797929be4 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -33,6 +33,8 @@ use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::mark_buffer_hyperlinks; use crate::terminal_hyperlinks::visible_lines; use crate::tui; +use crate::tui::MouseScrollDirection; +use crate::tui::MouseScrollEvent; use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -95,6 +97,8 @@ fn first_or_empty(bindings: &[KeyBinding]) -> Vec { bindings.first().copied().into_iter().collect() } +const MOUSE_SCROLL_ROWS: usize = 3; + // Render a single line of key hints from (key(s), description) pairs. fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &str)]) { let mut spans: Vec> = vec![" ".into()]; @@ -120,6 +124,8 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &st struct PagerView { renderables: Vec>, scroll_offset: usize, + /// Rows to move upward from the freshly measured bottom on the next render. + pending_rows_from_bottom: usize, title: String, keymap: PagerKeymap, last_content_height: Option, @@ -138,6 +144,7 @@ impl PagerView { Self { renderables, scroll_offset, + pending_rows_from_bottom: 0, title, keymap, last_content_height: None, @@ -165,9 +172,7 @@ impl PagerView { if let Some(idx) = self.pending_scroll_chunk.take() { self.ensure_chunk_visible(idx, content_area); } - self.scroll_offset = self - .scroll_offset - .min(content_height.saturating_sub(content_area.height as usize)); + self.resolve_scroll_offset(content_height, content_area.height as usize); self.render_content(content_area, buf, /*empty_row_marker*/ Some('~')); @@ -264,34 +269,35 @@ impl PagerView { fn apply_key_event(&mut self, viewport_area: Rect, key_event: KeyEvent) -> bool { match key_event { e if self.keymap.scroll_up.is_pressed(e) => { - self.scroll_offset = self.scroll_offset.saturating_sub(1); + self.scroll_up_rows(/*rows*/ 1); } e if self.keymap.scroll_down.is_pressed(e) => { - self.scroll_offset = self.scroll_offset.saturating_add(1); + self.scroll_down_rows(/*rows*/ 1); } e if self.keymap.page_up.is_pressed(e) => { let page_height = self.page_height(viewport_area); - self.scroll_offset = self.scroll_offset.saturating_sub(page_height); + self.scroll_up_rows(page_height); } e if self.keymap.page_down.is_pressed(e) => { let page_height = self.page_height(viewport_area); - self.scroll_offset = self.scroll_offset.saturating_add(page_height); + self.scroll_down_rows(page_height); } e if self.keymap.half_page_down.is_pressed(e) => { let area = self.content_area(viewport_area); let half_page = (area.height as usize).saturating_add(1) / 2; - self.scroll_offset = self.scroll_offset.saturating_add(half_page); + self.scroll_down_rows(half_page); } e if self.keymap.half_page_up.is_pressed(e) => { let area = self.content_area(viewport_area); let half_page = (area.height as usize).saturating_add(1) / 2; - self.scroll_offset = self.scroll_offset.saturating_sub(half_page); + self.scroll_up_rows(half_page); } e if self.keymap.jump_top.is_pressed(e) => { self.scroll_offset = 0; + self.pending_rows_from_bottom = 0; } e if self.keymap.jump_bottom.is_pressed(e) => { - self.scroll_offset = usize::MAX; + self.scroll_to_bottom(); } _ => { return false; @@ -300,6 +306,51 @@ impl PagerView { true } + fn handle_mouse_scroll(&mut self, tui: &mut tui::Tui, event: MouseScrollEvent) -> Result<()> { + self.apply_mouse_scroll(event.direction); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + Ok(()) + } + + fn apply_mouse_scroll(&mut self, direction: MouseScrollDirection) { + match direction { + MouseScrollDirection::Up => self.scroll_up_rows(MOUSE_SCROLL_ROWS), + MouseScrollDirection::Down => self.scroll_down_rows(MOUSE_SCROLL_ROWS), + } + } + + fn scroll_up_rows(&mut self, rows: usize) { + if self.scroll_offset == usize::MAX { + self.pending_rows_from_bottom = self.pending_rows_from_bottom.saturating_add(rows); + } else { + self.scroll_offset = self.scroll_offset.saturating_sub(rows); + } + } + + fn scroll_down_rows(&mut self, rows: usize) { + if self.scroll_offset == usize::MAX { + self.pending_rows_from_bottom = self.pending_rows_from_bottom.saturating_sub(rows); + } else { + self.scroll_offset = self.scroll_offset.saturating_add(rows); + } + } + + fn scroll_to_bottom(&mut self) { + self.scroll_offset = usize::MAX; + self.pending_rows_from_bottom = 0; + } + + fn resolve_scroll_offset(&mut self, content_height: usize, viewport_height: usize) { + let max_scroll = content_height.saturating_sub(viewport_height); + if self.scroll_offset == usize::MAX { + self.scroll_offset = max_scroll.saturating_sub(self.pending_rows_from_bottom); + self.pending_rows_from_bottom = 0; + } else { + self.scroll_offset = self.scroll_offset.min(max_scroll); + } + } + /// Returns the height of one page in content rows. /// /// Prefers the last rendered content height (excluding header/footer chrome); @@ -335,11 +386,9 @@ impl PagerView { if let Some(idx) = self.pending_scroll_chunk.take() { self.ensure_chunk_visible(idx, area); } else if follow_bottom { - self.scroll_offset = usize::MAX; + self.scroll_to_bottom(); } - self.scroll_offset = self - .scroll_offset - .min(content_height.saturating_sub(area.height as usize)); + self.resolve_scroll_offset(content_height, area.height as usize); self.render_content(area, buf, /*empty_row_marker*/ None); } } @@ -347,7 +396,7 @@ impl PagerView { impl PagerView { fn is_scrolled_to_bottom(&self) -> bool { if self.scroll_offset == usize::MAX { - return true; + return self.pending_rows_from_bottom == 0; } let Some(height) = self.last_content_height else { return false; @@ -368,6 +417,7 @@ impl PagerView { /// Request that the given text chunk index be scrolled into view on next render. fn scroll_chunk_into_view(&mut self, chunk_index: usize) { self.pending_scroll_chunk = Some(chunk_index); + self.pending_rows_from_bottom = 0; } fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) { @@ -433,7 +483,7 @@ impl PagerContent { } pub(crate) fn scroll_to_bottom(&mut self) { - self.view.scroll_offset = usize::MAX; + self.view.scroll_to_bottom(); } pub(crate) fn handle_navigation_key( @@ -443,6 +493,10 @@ impl PagerContent { ) -> bool { self.view.apply_key_event(viewport_area, key_event) } + + pub(crate) fn handle_mouse_scroll(&mut self, direction: MouseScrollDirection) { + self.view.apply_mouse_scroll(direction); + } } /// A renderable that caches its desired height. @@ -640,7 +694,7 @@ impl TranscriptOverlay { self.view.renderables.push(tail); } if follow_bottom { - self.view.scroll_offset = usize::MAX; + self.view.scroll_to_bottom(); } } @@ -660,7 +714,7 @@ impl TranscriptOverlay { } self.rebuild_renderables(); if follow_bottom { - self.view.scroll_offset = usize::MAX; + self.view.scroll_to_bottom(); } } @@ -703,7 +757,7 @@ impl TranscriptOverlay { self.rebuild_renderables(); } if follow_bottom { - self.view.scroll_offset = usize::MAX; + self.view.scroll_to_bottom(); } } @@ -751,7 +805,7 @@ impl TranscriptOverlay { } } if follow_bottom { - self.view.scroll_offset = usize::MAX; + self.view.scroll_to_bottom(); } } @@ -876,6 +930,7 @@ impl TranscriptOverlay { } other => self.view.handle_key_event(tui, other), }, + TuiEvent::MouseScroll(event) => self.view.handle_mouse_scroll(tui, event), TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); @@ -979,6 +1034,7 @@ impl StaticOverlay { } other => self.view.handle_key_event(tui, other), }, + TuiEvent::MouseScroll(event) => self.view.handle_mouse_scroll(tui, event), TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); @@ -1362,6 +1418,66 @@ mod tests { assert_eq!(overlay.view.scroll_offset, usize::MAX); } + #[test] + fn transcript_overlay_mouse_wheel_scrolls_active_pager() { + let mut overlay = transcript_overlay( + (0..20) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12, + ); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let bottom_offset = overlay.view.scroll_offset; + + overlay.view.apply_mouse_scroll(MouseScrollDirection::Up); + assert_eq!( + overlay.view.scroll_offset, + bottom_offset.saturating_sub(MOUSE_SCROLL_ROWS) + ); + + overlay.view.apply_mouse_scroll(MouseScrollDirection::Down); + overlay.render(area, &mut buffer); + assert!(overlay.view.is_scrolled_to_bottom()); + } + + #[test] + fn mouse_scroll_after_followed_append_uses_updated_bottom() { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { + lines: vec!["initial".into()], + })]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12, + ); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + overlay.insert_cell(Arc::new(TestCell { + lines: (0..20) + .map(|line| Line::from(format!("tail-{line:02}"))) + .collect(), + })); + + overlay.view.apply_mouse_scroll(MouseScrollDirection::Up); + overlay.render(area, &mut buffer); + + let max_scroll = overlay + .view + .last_rendered_height + .expect("rendered height") + .saturating_sub(overlay.view.last_content_height.expect("content height")); + assert_eq!( + overlay.view.scroll_offset, + max_scroll.saturating_sub(MOUSE_SCROLL_ROWS) + ); + assert!(overlay.view.scroll_offset > 0); + } + #[test] fn transcript_overlay_preserves_manual_scroll_position() { let mut overlay = transcript_overlay( diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index e0e3907246..6a2552e624 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -475,6 +475,7 @@ async fn run_session_picker_with_loader( TuiEvent::Paste(pasted) => { state.handle_paste(pasted); } + TuiEvent::MouseScroll(_) => {} TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { let list_height = diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 97c53f7144..f57d692985 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -166,7 +166,7 @@ async fn run_startup_hooks_review_app( } } } - TuiEvent::Paste(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {} TuiEvent::Draw | TuiEvent::Resize => draw_view(tui, &view)?, } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 08344820d0..fd7b111f0a 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -300,9 +300,8 @@ pub(super) fn reapply_raw_mode_after_resume() -> Result<()> { /// Uses a stronger keyboard reset than [`restore`] so the parent shell recovers even if a /// terminal missed the stack pop that normally pairs with [`set_modes`]. pub fn restore_after_exit() -> Result<()> { - let mut first_error = - restore_common(RawModeRestore::Disable, KeyboardRestore::ResetAfterExit).err(); - if let Err(err) = screen_session::restore_physical_alt_screen(&mut stdout()) { + let mut first_error = screen_session::restore_physical_alt_screen(&mut stdout()).err(); + if let Err(err) = restore_common(RawModeRestore::Disable, KeyboardRestore::ResetAfterExit) { first_error.get_or_insert(err); } if let Err(err) = terminal_stderr::finish() { @@ -518,6 +517,8 @@ pub enum TuiEvent { Key(KeyEvent), /// A bracketed paste payload normalized by the app layer before it reaches the composer. Paste(String), + /// A vertical mouse-wheel event reported while an owned screen has mouse capture. + MouseScroll(MouseScrollEvent), /// A terminal size notification that should be handled as resize-sensitive draw work. /// /// Resize is separate from `Draw` so the app can run feature-gated pre-render logic without @@ -527,6 +528,19 @@ pub enum TuiEvent { Draw, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MouseScrollEvent { + pub direction: MouseScrollDirection, + pub column: u16, + pub row: u16, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MouseScrollDirection { + Up, + Down, +} + pub struct Tui { frame_requester: FrameRequester, draw_tx: broadcast::Sender<()>, @@ -739,6 +753,11 @@ impl Tui { self.screen_session.enter(&mut self.terminal) } + /// Enter an application-owned alternate screen with direct mouse input. + pub fn enter_owned_alt_screen(&mut self) -> Result<()> { + self.screen_session.enter_owned(&mut self.terminal) + } + /// Leave alternate screen and restore the previously saved inline viewport, if any. pub fn leave_alt_screen(&mut self) -> Result<()> { self.screen_session.leave(&mut self.terminal) diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index a396aaef1f..baf3dbb314 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -26,6 +26,7 @@ use std::task::Context; use std::task::Poll; use crossterm::event::Event; +use crossterm::event::MouseEventKind; use tokio::sync::broadcast; use tokio::sync::watch; use tokio_stream::Stream; @@ -33,10 +34,14 @@ use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::WatchStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use super::MouseScrollDirection; +use super::MouseScrollEvent; use super::TuiEvent; #[cfg(unix)] use super::screen_session::ScreenSession; +const MAX_SKIPPED_EVENTS_PER_POLL: usize = 32; + /// Result type produced by an event source. pub type EventResult = std::io::Result; @@ -174,13 +179,13 @@ impl TuiEventStream { /// Poll the shared crossterm stream for the next mapped `TuiEvent`. /// - /// This skips events we don't use (mouse events, etc.) and keeps polling until it yields + /// This skips events we don't use (mouse clicks, focus loss, etc.) and keeps polling until it yields /// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops /// the underlying stream and returns `Pending` to fully release stdin. pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll> { - // Some crossterm events map to None (e.g. FocusLost, mouse); loop so we keep polling + // Some crossterm events map to None (e.g. FocusLost, mouse clicks); loop so we keep polling // until we return a mapped event, hit Pending, or see EOF/error. - loop { + for _ in 0..MAX_SKIPPED_EVENTS_PER_POLL { let poll_result = { let mut state = self .broker @@ -221,6 +226,8 @@ impl TuiEventStream { return Poll::Ready(Some(mapped)); } } + cx.waker().wake_by_ref(); + Poll::Pending } /// Poll the draw broadcast stream for the next draw event. Draw events are used to trigger a redraw of the TUI. @@ -235,7 +242,7 @@ impl TuiEventStream { } } - /// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.). + /// Map a crossterm event to a [`TuiEvent`], skipping events we don't use. fn map_crossterm_event(&mut self, event: Event) -> Option { match event { Event::Key(key_event) => { @@ -257,6 +264,23 @@ impl TuiEventStream { } Event::Resize(_, _) => Some(TuiEvent::Resize), Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)), + Event::Mouse(mouse_event) => { + let direction = match mouse_event.kind { + MouseEventKind::ScrollUp => MouseScrollDirection::Up, + MouseEventKind::ScrollDown => MouseScrollDirection::Down, + MouseEventKind::Down(_) + | MouseEventKind::Up(_) + | MouseEventKind::Drag(_) + | MouseEventKind::Moved + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => return None, + }; + Some(TuiEvent::MouseScroll(MouseScrollEvent { + direction, + column: mouse_event.column, + row: mouse_event.row, + })) + } Event::FocusGained => { self.terminal_focused.store(true, Ordering::Relaxed); crate::terminal_palette::requery_default_colors(); @@ -266,7 +290,6 @@ impl TuiEventStream { self.terminal_focused.store(false, Ordering::Relaxed); None } - _ => None, } } } @@ -308,6 +331,8 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use crossterm::event::MouseButton; + use crossterm::event::MouseEvent; use pretty_assertions::assert_eq; use std::task::Context; use std::task::Poll; @@ -473,6 +498,62 @@ mod tests { assert!(matches!(next, Some(TuiEvent::Resize))); } + #[tokio::test(flavor = "current_thread")] + async fn vertical_mouse_wheel_maps_to_scroll_and_ignored_events_yield_to_draws() { + let (broker, handle, draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + stream.poll_draw_first = false; + + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 3, + row: 4, + modifiers: KeyModifiers::NONE, + }))); + for column in 0..=MAX_SKIPPED_EVENTS_PER_POLL { + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: column as u16, + row: 0, + modifiers: KeyModifiers::NONE, + }))); + } + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 5, + row: 6, + modifiers: KeyModifiers::NONE, + }))); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 7, + row: 8, + modifiers: KeyModifiers::NONE, + }))); + draw_tx.send(()).expect("send draw"); + + let first = stream.next().await; + let second = stream.next().await; + let third = stream.next().await; + assert!(matches!(first, Some(TuiEvent::Draw))); + assert!(matches!( + second, + Some(TuiEvent::MouseScroll(MouseScrollEvent { + direction: MouseScrollDirection::Up, + column: 5, + row: 6, + })) + )); + assert!(matches!( + third, + Some(TuiEvent::MouseScroll(MouseScrollEvent { + direction: MouseScrollDirection::Down, + column: 7, + row: 8, + })) + )); + } + #[tokio::test(flavor = "current_thread")] async fn error_or_eof_ends_stream() { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); diff --git a/codex-rs/tui/src/tui/screen_session.rs b/codex-rs/tui/src/tui/screen_session.rs index 1ade865f2d..cf1f5fca3e 100644 --- a/codex-rs/tui/src/tui/screen_session.rs +++ b/codex-rs/tui/src/tui/screen_session.rs @@ -9,6 +9,8 @@ use std::sync::PoisonError; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use crossterm::event::DisableMouseCapture; +use crossterm::event::EnableMouseCapture; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; use ratatui::crossterm::execute; @@ -23,34 +25,75 @@ static PHYSICAL_ALT_SCREEN: PhysicalAltScreenTracker = PhysicalAltScreenTracker: struct PhysicalAltScreenTracker { active: AtomicBool, + alternate_scroll_active: AtomicBool, + mouse_capture_active: AtomicBool, } impl PhysicalAltScreenTracker { const fn new() -> Self { Self { - active: AtomicBool::new(false), + active: AtomicBool::new(/*v*/ false), + alternate_scroll_active: AtomicBool::new(/*v*/ false), + mouse_capture_active: AtomicBool::new(/*v*/ false), } } fn mark_entered(&self) { - self.active.store(true, Ordering::Release); + self.active.store(/*val*/ true, Ordering::Release); } fn mark_left(&self) { - self.active.store(false, Ordering::Release); + self.active.store(/*val*/ false, Ordering::Release); + } + + fn mark_alternate_scroll_enabled(&self) { + self.alternate_scroll_active + .store(/*val*/ true, Ordering::Release); + } + + fn mark_alternate_scroll_disabled(&self) { + self.alternate_scroll_active + .store(/*val*/ false, Ordering::Release); + } + + fn mark_mouse_capture_enabled(&self) { + self.mouse_capture_active + .store(/*val*/ true, Ordering::Release); + } + + fn mark_mouse_capture_disabled(&self) { + self.mouse_capture_active + .store(/*val*/ false, Ordering::Release); } fn restore(&self, writer: &mut impl Write) -> io::Result<()> { - if !self.active.load(Ordering::Acquire) { - return Ok(()); - } - - let disable_result = execute!(writer, DisableAlternateScroll); - let leave_result = execute!(writer, LeaveAlternateScreen); + let mouse_result = if self.mouse_capture_active.load(Ordering::Acquire) { + let result = execute!(writer, DisableMouseCapture); + if result.is_ok() { + self.mark_mouse_capture_disabled(); + } + result + } else { + Ok(()) + }; + let scroll_result = if self.alternate_scroll_active.load(Ordering::Acquire) { + let result = execute!(writer, DisableAlternateScroll); + if result.is_ok() { + self.mark_alternate_scroll_disabled(); + } + result + } else { + Ok(()) + }; + let leave_result = if self.active.load(Ordering::Acquire) { + execute!(writer, LeaveAlternateScreen) + } else { + Ok(()) + }; if leave_result.is_ok() { self.mark_left(); } - merge_results(disable_result, leave_result) + merge_results(mouse_result, merge_results(scroll_result, leave_result)) } } @@ -77,6 +120,7 @@ enum ScreenOwnership { owners: NonZeroUsize, saved_viewport: Rect, physical: PhysicalAltScreen, + input_mode: AltScreenInputMode, }, } @@ -86,6 +130,28 @@ enum PhysicalAltScreen { Suspended, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AltScreenInputMode { + AlternateScroll, + MouseCapture, +} + +impl AltScreenInputMode { + fn enable(self, commands: &mut impl ScreenCommands) -> io::Result<()> { + match self { + Self::AlternateScroll => commands.enable_alternate_scroll(), + Self::MouseCapture => commands.enable_mouse_capture(), + } + } + + fn disable(self, commands: &mut impl ScreenCommands) -> io::Result<()> { + match self { + Self::AlternateScroll => commands.disable_alternate_scroll(), + Self::MouseCapture => commands.disable_mouse_capture(), + } + } +} + impl ScreenSession { pub(super) fn new() -> Self { Self { @@ -131,9 +197,21 @@ impl ScreenSession { } pub(super) fn enter(&self, terminal: &mut Terminal) -> io::Result<()> { + self.enter_with_input_mode(terminal, AltScreenInputMode::AlternateScroll) + } + + pub(super) fn enter_owned(&self, terminal: &mut Terminal) -> io::Result<()> { + self.enter_with_input_mode(terminal, AltScreenInputMode::MouseCapture) + } + + fn enter_with_input_mode( + &self, + terminal: &mut Terminal, + input_mode: AltScreenInputMode, + ) -> io::Result<()> { let was_active = self.is_active(); let saved_viewport = terminal.viewport_area; - let command_result = self.acquire(terminal, saved_viewport); + let command_result = self.acquire(terminal, saved_viewport, input_mode); let layout_result = if !was_active && self.is_active() { expand_to_full_screen(terminal) } else { @@ -193,7 +271,12 @@ impl ScreenSession { merge_results(command_result, layout_result) } - fn acquire(&self, commands: &mut impl ScreenCommands, saved_viewport: Rect) -> io::Result<()> { + fn acquire( + &self, + commands: &mut impl ScreenCommands, + saved_viewport: Rect, + input_mode: AltScreenInputMode, + ) -> io::Result<()> { let mut inner = self.lock(); if inner.availability == AltScreenAvailability::Disabled { return Ok(()); @@ -205,9 +288,10 @@ impl ScreenSession { owners: NonZeroUsize::MIN, saved_viewport, physical: PhysicalAltScreen::Active, + input_mode, }; - if let Err(enable_error) = commands.enable_alternate_scroll() { - let disable_result = commands.disable_alternate_scroll(); + if let Err(enable_error) = input_mode.enable(commands) { + let disable_result = input_mode.disable(commands); let leave_result = commands.leave_alternate_screen(); if leave_result.is_ok() { inner.ownership = ScreenOwnership::Inline; @@ -234,7 +318,10 @@ impl ScreenSession { fn release(&self, commands: &mut impl ScreenCommands) -> io::Result<()> { let mut inner = self.lock(); let ScreenOwnership::Alternate { - owners, physical, .. + owners, + physical, + input_mode, + .. } = &mut inner.ownership else { return Ok(()); @@ -250,7 +337,7 @@ impl ScreenSession { return Ok(()); } - let disable_result = commands.disable_alternate_scroll(); + let disable_result = input_mode.disable(commands); let leave_result = commands.leave_alternate_screen(); if leave_result.is_ok() { inner.ownership = ScreenOwnership::Inline; @@ -260,7 +347,12 @@ impl ScreenSession { fn finish_commands(&self, commands: &mut impl ScreenCommands) -> io::Result<()> { let mut inner = self.lock(); - let ScreenOwnership::Alternate { physical, .. } = inner.ownership else { + let ScreenOwnership::Alternate { + physical, + input_mode, + .. + } = inner.ownership + else { return Ok(()); }; if physical == PhysicalAltScreen::Suspended { @@ -268,7 +360,7 @@ impl ScreenSession { return Ok(()); } - let disable_result = commands.disable_alternate_scroll(); + let disable_result = input_mode.disable(commands); let leave_result = commands.leave_alternate_screen(); if leave_result.is_ok() { inner.ownership = ScreenOwnership::Inline; @@ -278,13 +370,18 @@ impl ScreenSession { fn suspend_commands(&self, commands: &mut impl ScreenCommands) -> io::Result<()> { let mut inner = self.lock(); - let ScreenOwnership::Alternate { physical, .. } = &mut inner.ownership else { + let ScreenOwnership::Alternate { + physical, + input_mode, + .. + } = &mut inner.ownership + else { return Ok(()); }; if *physical == PhysicalAltScreen::Suspended { return Ok(()); } - let disable_result = commands.disable_alternate_scroll(); + let disable_result = input_mode.disable(commands); let leave_result = commands.leave_alternate_screen(); if leave_result.is_ok() { *physical = PhysicalAltScreen::Suspended; @@ -294,7 +391,12 @@ impl ScreenSession { fn resume_commands(&self, commands: &mut impl ScreenCommands) -> io::Result<()> { let mut inner = self.lock(); - let ScreenOwnership::Alternate { physical, .. } = &mut inner.ownership else { + let ScreenOwnership::Alternate { + physical, + input_mode, + .. + } = &mut inner.ownership + else { return Ok(()); }; if *physical == PhysicalAltScreen::Active { @@ -302,8 +404,8 @@ impl ScreenSession { } commands.enter_alternate_screen()?; *physical = PhysicalAltScreen::Active; - if let Err(enable_error) = commands.enable_alternate_scroll() { - let disable_result = commands.disable_alternate_scroll(); + if let Err(enable_error) = input_mode.enable(commands) { + let disable_result = input_mode.disable(commands); let leave_result = commands.leave_alternate_screen(); if leave_result.is_ok() { *physical = PhysicalAltScreen::Suspended; @@ -327,6 +429,8 @@ trait ScreenCommands { fn leave_alternate_screen(&mut self) -> io::Result<()>; fn enable_alternate_scroll(&mut self) -> io::Result<()>; fn disable_alternate_scroll(&mut self) -> io::Result<()>; + fn enable_mouse_capture(&mut self) -> io::Result<()>; + fn disable_mouse_capture(&mut self) -> io::Result<()>; } pub(super) fn restore_physical_alt_screen(writer: &mut impl Write) -> io::Result<()> { @@ -351,11 +455,35 @@ impl ScreenCommands for Terminal { } fn enable_alternate_scroll(&mut self) -> io::Result<()> { - execute!(self.backend_mut(), EnableAlternateScroll) + let result = execute!(self.backend_mut(), EnableAlternateScroll); + if result.is_ok() { + PHYSICAL_ALT_SCREEN.mark_alternate_scroll_enabled(); + } + result } fn disable_alternate_scroll(&mut self) -> io::Result<()> { - execute!(self.backend_mut(), DisableAlternateScroll) + let result = execute!(self.backend_mut(), DisableAlternateScroll); + if result.is_ok() { + PHYSICAL_ALT_SCREEN.mark_alternate_scroll_disabled(); + } + result + } + + fn enable_mouse_capture(&mut self) -> io::Result<()> { + let result = execute!(self.backend_mut(), EnableMouseCapture); + if result.is_ok() { + PHYSICAL_ALT_SCREEN.mark_mouse_capture_enabled(); + } + result + } + + fn disable_mouse_capture(&mut self) -> io::Result<()> { + let result = execute!(self.backend_mut(), DisableMouseCapture); + if result.is_ok() { + PHYSICAL_ALT_SCREEN.mark_mouse_capture_disabled(); + } + result } } @@ -379,11 +507,35 @@ impl ScreenCommands for WriterCommands<'_, W> { } fn enable_alternate_scroll(&mut self) -> io::Result<()> { - execute!(self.0, EnableAlternateScroll) + let result = execute!(self.0, EnableAlternateScroll); + if result.is_ok() { + PHYSICAL_ALT_SCREEN.mark_alternate_scroll_enabled(); + } + result } fn disable_alternate_scroll(&mut self) -> io::Result<()> { - execute!(self.0, DisableAlternateScroll) + let result = execute!(self.0, DisableAlternateScroll); + if result.is_ok() { + PHYSICAL_ALT_SCREEN.mark_alternate_scroll_disabled(); + } + result + } + + fn enable_mouse_capture(&mut self) -> io::Result<()> { + let result = execute!(self.0, EnableMouseCapture); + if result.is_ok() { + PHYSICAL_ALT_SCREEN.mark_mouse_capture_enabled(); + } + result + } + + fn disable_mouse_capture(&mut self) -> io::Result<()> { + let result = execute!(self.0, DisableMouseCapture); + if result.is_ok() { + PHYSICAL_ALT_SCREEN.mark_mouse_capture_disabled(); + } + result } } diff --git a/codex-rs/tui/src/tui/screen_session_tests.rs b/codex-rs/tui/src/tui/screen_session_tests.rs index 013944abbf..99ff22bde1 100644 --- a/codex-rs/tui/src/tui/screen_session_tests.rs +++ b/codex-rs/tui/src/tui/screen_session_tests.rs @@ -3,6 +3,7 @@ use std::io; use pretty_assertions::assert_eq; use ratatui::layout::Rect; +use super::AltScreenInputMode; use super::PhysicalAltScreenTracker; use super::ScreenCommands; use super::ScreenSession; @@ -13,6 +14,8 @@ enum Effect { Leave, EnableScroll, DisableScroll, + EnableMouse, + DisableMouse, } const ENTER_AND_LEAVE: &[Effect] = &[ @@ -22,6 +25,12 @@ const ENTER_AND_LEAVE: &[Effect] = &[ Effect::Leave, ]; const LEAVE: &[Effect] = &[Effect::DisableScroll, Effect::Leave]; +const OWNED_ENTER_AND_LEAVE: &[Effect] = &[ + Effect::Enter, + Effect::EnableMouse, + Effect::DisableMouse, + Effect::Leave, +]; #[derive(Default)] struct FakeCommands { @@ -63,6 +72,14 @@ impl ScreenCommands for FakeCommands { fn disable_alternate_scroll(&mut self) -> io::Result<()> { self.record(Effect::DisableScroll) } + + fn enable_mouse_capture(&mut self) -> io::Result<()> { + self.record(Effect::EnableMouse) + } + + fn disable_mouse_capture(&mut self) -> io::Result<()> { + self.record(Effect::DisableMouse) + } } fn acquire(session: &ScreenSession, commands: &mut FakeCommands) { @@ -75,10 +92,17 @@ fn acquire(session: &ScreenSession, commands: &mut FakeCommands) { width: 80, height: 12, }, + AltScreenInputMode::AlternateScroll, ) .expect("acquire"); } +fn acquire_owned(session: &ScreenSession, commands: &mut FakeCommands) { + session + .acquire(commands, Rect::default(), AltScreenInputMode::MouseCapture) + .expect("acquire owned"); +} + fn assert_effects(commands: &FakeCommands, expected: &[Effect]) { assert_eq!(commands.effects, expected); } @@ -116,6 +140,20 @@ fn finish_clears_all_nested_owners_with_one_physical_transition() { assert_effects(&commands, LEAVE); } +#[test] +fn owned_root_keeps_mouse_capture_across_nested_overlay_ownership() { + let session = ScreenSession::new(); + let mut commands = FakeCommands::default(); + acquire_owned(&session, &mut commands); + acquire(&session, &mut commands); + + session.release(&mut commands).expect("nested release"); + session.release(&mut commands).expect("owned release"); + + assert!(!session.is_active()); + assert_effects(&commands, OWNED_ENTER_AND_LEAVE); +} + #[test] fn emergency_restore_only_leaves_a_physically_owned_screen() { let tracker = PhysicalAltScreenTracker::new(); @@ -126,6 +164,7 @@ fn emergency_restore_only_leaves_a_physically_owned_screen() { assert_eq!(inactive_output, Vec::::new()); tracker.mark_entered(); + tracker.mark_alternate_scroll_enabled(); let mut restore_output = Vec::new(); tracker @@ -146,6 +185,27 @@ fn emergency_restore_only_leaves_a_physically_owned_screen() { assert_eq!(restore_output, expected_output); } +#[cfg(unix)] +#[test] +fn emergency_restore_disables_tracked_mouse_capture() { + let tracker = PhysicalAltScreenTracker::new(); + tracker.mark_entered(); + tracker.mark_mouse_capture_enabled(); + + let mut restore_output = Vec::new(); + tracker + .restore(&mut restore_output) + .expect("restore mouse-captured screen"); + let mut expected_output = Vec::new(); + ratatui::crossterm::execute!( + expected_output, + crossterm::event::DisableMouseCapture, + crossterm::terminal::LeaveAlternateScreen + ) + .expect("write expected restore sequence"); + assert_eq!(restore_output, expected_output); +} + #[test] fn temporary_suspend_preserves_nested_ownership_until_resume() { let session = ScreenSession::new(); @@ -180,6 +240,30 @@ fn temporary_suspend_preserves_nested_ownership_until_resume() { ); } +#[test] +fn owned_suspend_and_resume_restore_mouse_capture() { + let session = ScreenSession::new(); + let mut commands = FakeCommands::default(); + acquire_owned(&session, &mut commands); + commands.effects.clear(); + + session.suspend_commands(&mut commands).expect("suspend"); + session.resume_commands(&mut commands).expect("resume"); + session.release(&mut commands).expect("release"); + + assert_effects( + &commands, + &[ + Effect::DisableMouse, + Effect::Leave, + Effect::Enter, + Effect::EnableMouse, + Effect::DisableMouse, + Effect::Leave, + ], + ); +} + #[test] fn disabled_session_ignores_ownership_requests() { let session = ScreenSession::new(); @@ -199,13 +283,29 @@ fn disabled_session_ignores_ownership_requests() { fn partial_acquire_rolls_back_or_retains_ownership_for_retry() { let session = ScreenSession::new(); let mut commands = FakeCommands::failing(vec![Effect::EnableScroll]); - assert!(session.acquire(&mut commands, Rect::default()).is_err()); + assert!( + session + .acquire( + &mut commands, + Rect::default(), + AltScreenInputMode::AlternateScroll, + ) + .is_err() + ); assert!(!session.is_active()); assert_effects(&commands, ENTER_AND_LEAVE); let session = ScreenSession::new(); let mut commands = FakeCommands::failing(vec![Effect::EnableScroll, Effect::Leave]); - assert!(session.acquire(&mut commands, Rect::default()).is_err()); + assert!( + session + .acquire( + &mut commands, + Rect::default(), + AltScreenInputMode::AlternateScroll, + ) + .is_err() + ); assert!(session.is_active()); commands.effects.clear(); session.release(&mut commands).expect("retry cleanup"); @@ -213,6 +313,24 @@ fn partial_acquire_rolls_back_or_retains_ownership_for_retry() { assert_effects(&commands, LEAVE); } +#[test] +fn partial_owned_acquire_disables_mouse_before_leaving() { + let session = ScreenSession::new(); + let mut commands = FakeCommands::failing(vec![Effect::EnableMouse]); + + assert!( + session + .acquire( + &mut commands, + Rect::default(), + AltScreenInputMode::MouseCapture, + ) + .is_err() + ); + assert!(!session.is_active()); + assert_effects(&commands, OWNED_ENTER_AND_LEAVE); +} + #[test] fn release_attempts_all_cleanup_and_retains_ownership_when_leave_fails() { let session = ScreenSession::new(); diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index 8fec45590f..8accdeb214 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -58,7 +58,7 @@ pub(crate) async fn run_update_prompt_if_needed( if let Some(event) = events.next().await { match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) => {} TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area());