Enable mouse navigation in the TUI usage view (#46866)

## What changed

- Enable mouse input for the usage overlay, with clickable tabs and report controls that invoke the same actions as keyboard shortcuts.
- Scroll the report, dashboard, or help body with the mouse wheel in bounded three-line steps, preserving separate reading positions.
- Limit clicks to rendered targets and ignore stale targets after refreshes, control changes, or surface transitions.

## Testing

Add coverage for mouse event routing, clipped targets, remapped or disabled keybindings, stale targets, bounded scrolling, and retained reading positions. Exercise the business token model filter through a mouse click.

GitOrigin-RevId: be839695d46609b6df7062f99711701372335907
This commit is contained in:
Eric Traut
2026-09-20 18:26:14 +00:00
committed by copyberry
parent d6d43270bd
commit b48232f975
10 changed files with 284 additions and 10 deletions
+24
View File
@@ -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.
@@ -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);
+2 -1
View File
@@ -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());
+9 -3
View File
@@ -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();
}
}
+55
View File
@@ -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);
}
}
_ => {}
}
}
}
+171
View File
@@ -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);
}
+8 -3
View File
@@ -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::<Vec<_>>(),
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);
+3
View File
@@ -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;
+1 -1
View File
@@ -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 {
-1
View File
@@ -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,
}