mirror of
https://github.com/openai/codex.git
synced 2026-10-16 16:58:46 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user