diff --git a/codex-rs/tui/src/analytics.rs b/codex-rs/tui/src/analytics.rs index 975fa54ece..8235011023 100644 --- a/codex-rs/tui/src/analytics.rs +++ b/codex-rs/tui/src/analytics.rs @@ -18,6 +18,7 @@ mod data; mod fixture; mod hints; mod models; +mod mouse; mod normalize; mod panels; mod plan; @@ -49,6 +50,8 @@ use codex_app_server_client::AppServerRequestHandle; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::MouseButton; +use crossterm::event::MouseEventKind; use data::Load; use sections::Section; use sections::SectionState; @@ -78,6 +81,10 @@ pub(crate) struct AnalyticsView { dashboard_scroll: usize, body_area: ratatui::layout::Rect, report_area: ratatui::layout::Rect, + tab_hits: Vec<(Section, ratatui::layout::Rect)>, + control_hits: Vec<(controls::Control, ratatui::layout::Rect)>, + mouse_context: Option<(Section, bool, bool)>, + max_scroll: usize, selection_visible: bool, help_scroll: usize, follow_selection: bool, @@ -110,6 +117,10 @@ impl AnalyticsView { dashboard_scroll: 0, body_area: ratatui::layout::Rect::default(), report_area: ratatui::layout::Rect::default(), + tab_hits: Vec::new(), + control_hits: Vec::new(), + mouse_context: None, + max_scroll: 0, selection_visible: false, help_scroll: 0, follow_selection: true, @@ -148,6 +159,7 @@ impl AnalyticsView { } pub(crate) fn refresh(&mut self) { + self.invalidate_mouse_targets(); self.end_date = chrono::Utc::now().date_naive(); self.live = self.connection.as_ref().map(|(config, _, _)| { std::sync::Arc::new(client::Live::new( @@ -189,6 +201,7 @@ impl AnalyticsView { /// Abort work when closing or invalidating a retained view; reopening starts fresh requests. pub(crate) fn cancel_loads(&mut self) { + self.invalidate_mouse_targets(); for section in &mut self.sections.0 { section.history = Load::Unavailable; } @@ -498,6 +511,17 @@ impl AnalyticsView { } tui.frame_requester().schedule_frame(); } + TuiEvent::Mouse(mouse) + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + ) => + { + self.handle_mouse(mouse); + tui.frame_requester().schedule_frame(); + } TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { // Rendering follows a selection on resize only when it was visible before // reflow. Preserve an intentional reading position away from that selection. diff --git a/codex-rs/tui/src/analytics/account_layout_tests.rs b/codex-rs/tui/src/analytics/account_layout_tests.rs index 8ef18bd2d7..aec8e86508 100644 --- a/codex-rs/tui/src/analytics/account_layout_tests.rs +++ b/codex-rs/tui/src/analytics/account_layout_tests.rs @@ -140,7 +140,17 @@ async fn business_tokens_filter_models() { fixture::seed_reports(&mut view); assert_eq!(view.token_model.as_deref(), Some("GPT-5.5")); screen(&mut view, /*width*/ 100, /*height*/ 30); - press(&mut view, KeyCode::Char('m')); + let (_, target) = view + .control_hits + .iter() + .find(|(control, _)| *control == crate::analytics::controls::Control::Model) + .unwrap(); + view.handle_mouse(crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: target.x, + row: target.y, + modifiers: KeyModifiers::NONE, + }); test_support::settle(&mut view).await; fixture::seed_reports(&mut view); assert_eq!(view.token_model, None); diff --git a/codex-rs/tui/src/analytics/chrome.rs b/codex-rs/tui/src/analytics/chrome.rs index 9ccc618308..d14bc55373 100644 --- a/codex-rs/tui/src/analytics/chrome.rs +++ b/codex-rs/tui/src/analytics/chrome.rs @@ -1,4 +1,4 @@ -//! Report navigation and bounded keyboard help for the full-screen usage view. +//! Report navigation and bounded keyboard/mouse help for the full-screen usage view. //! Each report retains its own reading position; help never replaces its selection or draft. use super::AnalyticsView; @@ -121,6 +121,7 @@ impl AnalyticsView { self.hint(ListAction::PageUp), self.hint(ListAction::PageDown) ), + "In alternate screen: mouse wheel · scroll; click tabs or report controls".into(), ]; if self.control_available(Control::Range) { controls.push("r · switch between 7 and 30 days".into()); diff --git a/codex-rs/tui/src/analytics/controls.rs b/codex-rs/tui/src/analytics/controls.rs index c846299334..9018fd7fe4 100644 --- a/codex-rs/tui/src/analytics/controls.rs +++ b/codex-rs/tui/src/analytics/controls.rs @@ -1,4 +1,4 @@ -//! Visible report controls, update times, and shared keyboard actions. +//! Visible report controls and update times. Mouse targets and keyboard shortcuts invoke the same semantic actions. //! Their reserved rows depend only on terminal width, so loading never shifts the report body. use super::AnalyticsView; @@ -16,7 +16,7 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Widget; -/// Report actions shared by keyboard dispatch, visible controls, and help. +/// A mouse action is independent of a user's remapped or disabled arrow bindings. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum Control { Range, @@ -54,6 +54,8 @@ impl AnalyticsView { if !self.control_available(control) { return; } + // Labels can change width; wait for their new painted positions before another click. + self.invalidate_mouse_targets(); match control { Control::ZeroCreditGroups => { self.show_zero_credit_groups = !self.show_zero_credit_groups @@ -101,6 +103,7 @@ impl AnalyticsView { } pub(super) fn render_controls(&mut self, area: Rect, buf: &mut Buffer) { + self.control_hits.clear(); if area.is_empty() { return; } @@ -237,7 +240,7 @@ impl AnalyticsView { .take(usize::from(area.height.saturating_sub(/*rhs*/ 1))) { let mut x = area.x; - for (_, line) in controls { + for (key, line) in controls { if x > area.x { " · ".dim().render( Rect::new( @@ -262,6 +265,9 @@ impl AnalyticsView { /*height*/ 1, ); line.render(hit, buf); + if !hit.is_empty() { + self.control_hits.push((key, hit)); + } x = hit.right(); } } diff --git a/codex-rs/tui/src/analytics/mouse.rs b/codex-rs/tui/src/analytics/mouse.rs new file mode 100644 index 0000000000..f288211cf9 --- /dev/null +++ b/codex-rs/tui/src/analytics/mouse.rs @@ -0,0 +1,55 @@ +//! Mouse actions use the most recently painted Usage layout and semantic report controls. +//! Wheel input belongs only to the body, with bounded independent report/help/dashboard offsets. + +use super::AnalyticsView; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use ratatui::layout::Position; + +impl AnalyticsView { + pub(super) fn invalidate_mouse_targets(&mut self) { + self.mouse_context = None; + self.tab_hits.clear(); + self.control_hits.clear(); + } + + pub(super) fn handle_mouse(&mut self, event: MouseEvent) { + // A report/help/dashboard transition can happen before its scheduled redraw. Old + // rectangles must not activate controls belonging to the surface being replaced. + if self.mouse_context != Some((self.section, self.show_help, self.zoomed)) { + return; + } + let point = Position::new(event.column, event.row); + match event.kind { + MouseEventKind::ScrollDown | MouseEventKind::ScrollUp + if self.body_area.contains(point) => + { + let offset = self.scroll_offset().min(self.max_scroll); + *self.scroll_offset_mut() = match event.kind { + MouseEventKind::ScrollDown => { + offset.saturating_add(/*rhs*/ 3).min(self.max_scroll) + } + _ => offset.saturating_sub(/*rhs*/ 3), + }; + self.follow_selection = false; + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some((section, _)) = + self.tab_hits.iter().find(|(_, rect)| rect.contains(point)) + { + self.select_section(*section); + } else if let Some((control, _)) = self + .control_hits + .iter() + .find(|(_, rect)| rect.contains(point)) + { + let control = *control; + self.follow_selection = false; + self.activate_control(control); + } + } + _ => {} + } + } +} diff --git a/codex-rs/tui/src/analytics/mouse_tests.rs b/codex-rs/tui/src/analytics/mouse_tests.rs new file mode 100644 index 0000000000..09fd70631b --- /dev/null +++ b/codex-rs/tui/src/analytics/mouse_tests.rs @@ -0,0 +1,171 @@ +//! Mouse routing, painted control bounds, and scrolling across retained report surfaces. + +use super::*; +use crate::analytics::controls::Control; +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use pretty_assertions::assert_eq; +use ratatui::layout::Rect; + +fn mouse_at(view: &mut AnalyticsView, kind: MouseEventKind, area: Rect) { + view.handle_mouse(MouseEvent { + kind, + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }); +} + +#[tokio::test] +async fn mouse_routing_keeps_clipped_tabs_and_refresh_targets_account_scoped() { + let mut view = fixture::view(models::AccountKind::Consumer); + screen(&mut view, /*width*/ 100, /*height*/ 24); + let (_, tab) = view + .tab_hits + .iter() + .find(|(section, _)| *section == Section::Plugins) + .unwrap(); + let mut tui = crate::tui::test_support::make_test_tui().unwrap(); + view.handle_event( + &mut tui, + TuiEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: tab.x, + row: tab.y, + modifiers: KeyModifiers::NONE, + }), + ) + .unwrap(); + assert_eq!(view.section, Section::Plugins); + screen(&mut view, /*width*/ 20, /*height*/ 12); + let (_, tab) = view.tab_hits[0]; + mouse_at(&mut view, MouseEventKind::Down(MouseButton::Left), tab); + assert_eq!(view.section, Section::Plugins); + + screen(&mut view, /*width*/ 100, /*height*/ 24); + let targets = view.control_hits.clone(); + assert!(!targets.is_empty()); + let before = (view.section, view.ranges); + press(&mut view, KeyCode::Char('R')); + for (_, rect) in targets { + mouse_at(&mut view, MouseEventKind::Down(MouseButton::Left), rect); + } + assert_eq!((view.section, view.ranges), before); + assert!(matches!(view.account, Load::Unavailable)); +} + +#[test] +fn clipped_controls_ignore_remapped_keys_and_obsolete_frames() { + let mut config = TuiKeymap::default(); + config.list.cancel = Some(KeybindingsSpec::One(KeybindingSpec("r".into()))); + config.list.accept = Some(KeybindingsSpec::Many(Vec::new())); + config.list.move_left = Some(KeybindingsSpec::Many(Vec::new())); + config.list.move_right = Some(KeybindingsSpec::Many(Vec::new())); + let mut view = fixture::view(models::AccountKind::Consumer); + view.keymap = RuntimeKeymap::from_config(&config).unwrap().list; + screen(&mut view, /*width*/ 100, /*height*/ 24); + let (_, range) = *view + .control_hits + .iter() + .find(|(control, _)| *control == Control::Range) + .unwrap(); + mouse_at(&mut view, MouseEventKind::Down(MouseButton::Left), range); + assert_eq!( + ( + view.ranges[view.range_group(Section::Usage) as usize], + view.is_done + ), + (1, false) + ); + + press(&mut view, KeyCode::Char('z')); + screen(&mut view, /*width*/ 100, /*height*/ 24); + let (_, dashboard) = *view + .control_hits + .iter() + .find(|(control, _)| *control == Control::Dashboard) + .unwrap(); + mouse_at( + &mut view, + MouseEventKind::Down(MouseButton::Left), + dashboard, + ); + assert!(view.zoomed); + + view.plan.enabled = true; + view.select_section(Section::Plan); + view.plan.cursor = [2, 4]; + let retained = view.plan.cursor; + for width in [58, 14] { + view.plan.window = 0; + screen(&mut view, width, /*height*/ 20); + let (_, hit) = *view + .control_hits + .iter() + .find(|(control, _)| *control == Control::PlanWindow) + .unwrap(); + mouse_at( + &mut view, + MouseEventKind::Down(MouseButton::Left), + Rect { + x: hit.right(), + ..hit + }, + ); + assert_eq!(view.plan.window, 0); + let last_painted = Rect { + x: hit.right() - 1, + ..hit + }; + mouse_at( + &mut view, + MouseEventKind::Down(MouseButton::Left), + last_painted, + ); + assert_eq!( + (view.plan.window, view.plan.cursor, view.is_done), + (1, retained, false) + ); + mouse_at( + &mut view, + MouseEventKind::Down(MouseButton::Left), + last_painted, + ); + assert_eq!(view.plan.window, 1); + } +} + +#[test] +fn wheel_scrolls_only_the_painted_body_within_its_bounds() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.follow_selection = false; + screen(&mut view, /*width*/ 58, /*height*/ 12); + assert!(view.max_scroll > 3); + let body = view.body_area; + let tab = view.tab_hits[0].1; + mouse_at(&mut view, MouseEventKind::ScrollDown, tab); + assert_eq!(view.scroll_offset(), 0); + mouse_at(&mut view, MouseEventKind::ScrollDown, body); + assert_eq!(view.scroll_offset(), 3); + *view.scroll_offset_mut() = view.max_scroll; + mouse_at(&mut view, MouseEventKind::ScrollDown, body); + assert_eq!(view.scroll_offset(), view.max_scroll); + mouse_at(&mut view, MouseEventKind::ScrollUp, body); + assert_eq!(view.scroll_offset(), view.max_scroll - 3); + assert!(!view.follow_selection); + let reading = view.scroll_offset(); + + press(&mut view, KeyCode::Char('?')); + mouse_at(&mut view, MouseEventKind::ScrollDown, body); + assert_eq!(view.scroll_offset(), 0); + screen(&mut view, /*width*/ 58, /*height*/ 12); + let help_body = view.body_area; + mouse_at(&mut view, MouseEventKind::ScrollDown, help_body); + assert_eq!(view.scroll_offset(), 3); + press(&mut view, KeyCode::Esc); + assert_eq!(view.scroll_offset(), reading); +} diff --git a/codex-rs/tui/src/analytics/render.rs b/codex-rs/tui/src/analytics/render.rs index 5671d7933d..294fc638fb 100644 --- a/codex-rs/tui/src/analytics/render.rs +++ b/codex-rs/tui/src/analytics/render.rs @@ -149,12 +149,15 @@ impl AnalyticsView { .iter() .position(|section| *section == self.section) .unwrap_or_default(); - crate::bottom_pane::render_filled_tab_bar( + self.tab_hits = crate::bottom_pane::render_filled_tab_bar( &labels.iter().map(String::as_str).collect::>(), selected, tabs, buf, - ); + ) + .into_iter() + .map(|(index, rect)| (visible[index], rect)) + .collect(); // Keep a visible selection on screen across reflow, while retaining intentional // reading positions when the user has scrolled away from that selection. if !self.show_help && self.report_area != body && self.selection_visible { @@ -216,7 +219,8 @@ impl AnalyticsView { } } let scrollable = lines.len() > self.viewport_height; - scroll_offset = scroll_offset.min(lines.len().saturating_sub(self.viewport_height)); + self.max_scroll = lines.len().saturating_sub(self.viewport_height); + scroll_offset = scroll_offset.min(self.max_scroll); *self.scroll_offset_mut() = scroll_offset; self.follow_selection = false; if !self.show_help { @@ -234,6 +238,7 @@ impl AnalyticsView { /*offset*/ (scroll_offset.min(usize::from(u16::MAX)) as u16, 0), ) .render(body, buf); + self.mouse_context = Some((self.section, self.show_help, self.zoomed)); Paragraph::new(self.footer_lines(width, scrollable)) .style(secondary_style()) .render(hints, buf); diff --git a/codex-rs/tui/src/analytics_tests.rs b/codex-rs/tui/src/analytics_tests.rs index b3d2ae879c..7dc5a54101 100644 --- a/codex-rs/tui/src/analytics_tests.rs +++ b/codex-rs/tui/src/analytics_tests.rs @@ -662,3 +662,6 @@ fn analytics_details_reflow_and_keep_focus() { #[path = "analytics/dashboard_tests.rs"] mod dashboard; + +#[path = "analytics/mouse_tests.rs"] +mod mouse; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index f8f35ae041..9bfc6aa2ac 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -72,7 +72,7 @@ impl Overlay { let input = match self { Overlay::Transcript(_) => tui::OverlayInput::Transcript, Overlay::Static(_) => tui::OverlayInput::StaticPager, - Overlay::Analytics(_) => tui::OverlayInput::Default, + Overlay::Analytics(_) => tui::OverlayInput::Usage, }; tui.set_overlay_input(input)?; let result = match self { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index efcdf83056..9f03439747 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -599,7 +599,6 @@ pub(crate) enum OverlayInput { Default, Transcript, StaticPager, - #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] Usage, }