diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d3b259976e..0fd5429fb9 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1303,6 +1303,9 @@ See the Codex keymap documentation for supported actions and examples." .await; } } + TuiEvent::MousePrimaryPress(event) => { + self.handle_owned_screen_mouse_primary_press(tui, event); + } TuiEvent::Draw | TuiEvent::Resize => { if self.backtrack_render_pending { self.rebuild_transcript_after_backtrack(tui)?; diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index db5b26ea61..cd2b7d4468 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -102,9 +102,6 @@ impl App { key_event: KeyEvent, ) { if self.handle_conversation_pane_focus_key(key_event) { - if self.backtrack.primed { - self.reset_backtrack_state(); - } tui.frame_requester().schedule_frame(); return; } @@ -276,13 +273,26 @@ impl App { }; } - fn handle_conversation_pane_focus_key(&mut self, key_event: KeyEvent) -> bool { + pub(super) fn focus_conversation_pane(&mut self, target: PaneSlot) -> bool { if self.overlay.is_some() - || self.chat_widget.by_slot(PaneSlot::Side).is_none() || !self.chat_widget.no_modal_or_popup_active() + || self.chat_widget.by_slot(target).is_none() { return false; } + if self.backtrack.primed { + self.reset_backtrack_state(); + } + if self.chat_widget.focused_slot() == target { + return true; + } + self.chat_widget.focus(target) + } + + fn handle_conversation_pane_focus_key(&mut self, key_event: KeyEvent) -> bool { + if self.chat_widget.by_slot(PaneSlot::Side).is_none() { + return false; + } let composer_is_empty = self.chat_widget.composer_text_with_pending().is_empty(); let allow_word_motion_fallback = !self.enhanced_keys_supported && composer_is_empty; @@ -299,7 +309,7 @@ impl App { } else { return false; }; - self.chat_widget.focus(target) + self.focus_conversation_pane(target) } pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool { @@ -395,6 +405,22 @@ mod tests { ); } + #[tokio::test] + async fn pane_focus_key_clears_primed_backtrack_before_switching() { + let mut app = make_test_app().await; + install_side_pane(&mut app).await; + app.backtrack.primed = true; + app.chat_widget.show_esc_backtrack_hint(); + + assert!(app.handle_conversation_pane_focus_key(KeyEvent::new( + KeyCode::Char('2'), + KeyModifiers::ALT, + ))); + + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); + assert!(!app.backtrack.primed); + } + #[cfg(target_os = "macos")] #[tokio::test] async fn pane_focus_does_not_steal_word_motion_from_nonempty_draft() { diff --git a/codex-rs/tui/src/app/owned_screen.rs b/codex-rs/tui/src/app/owned_screen.rs index 63a56a679e..532ca8ebaa 100644 --- a/codex-rs/tui/src/app/owned_screen.rs +++ b/codex-rs/tui/src/app/owned_screen.rs @@ -21,6 +21,7 @@ use ratatui::widgets::Widget; use super::*; use crate::AltScreenBehavior; use crate::key_hint::is_plain_text_key_event; +use crate::tui::MousePrimaryPressEvent; use crate::tui::MouseScrollEvent; const MIN_SPLIT_PANE_WIDTH: u16 = 41; @@ -31,6 +32,7 @@ const PANE_HEADER_HEIGHT: u16 = 1; pub(super) struct OwnedScreen { viewport: ConversationViewport, replay_in_progress: bool, + last_pane_area: Rect, last_conversation_area: Rect, } @@ -101,6 +103,7 @@ impl OwnedScreen { keymap, ), replay_in_progress: false, + last_pane_area: Rect::default(), last_conversation_area: Rect::default(), } } @@ -177,7 +180,8 @@ impl OwnedScreen { true } - fn clear_last_conversation_area(&mut self) { + fn clear_last_render_areas(&mut self) { + self.last_pane_area = Rect::default(); self.last_conversation_area = Rect::default(); } } @@ -251,6 +255,7 @@ fn render_pane( let pane = panes.by_slot_mut(slot)?; pane.chat_widget.update_owned_screen_width(body_area.width); let screen = pane.owned_screen.as_mut()?; + screen.last_pane_area = area; Some(screen.render(&pane.chat_widget, body_area, buffer)) } @@ -266,7 +271,7 @@ fn render_layout( .by_slot_mut(slot) .and_then(|pane| pane.owned_screen.as_mut()) { - screen.clear_last_conversation_area(); + screen.clear_last_render_areas(); } } @@ -392,6 +397,36 @@ impl App { false } + pub(super) fn handle_owned_screen_mouse_primary_press( + &mut self, + tui: &mut tui::Tui, + event: MousePrimaryPressEvent, + ) -> bool { + if !self.chat_widget.has_side() { + return false; + } + + let position = Position::new(event.column, event.row); + let target = [PaneSlot::Parent, PaneSlot::Side].into_iter().find(|slot| { + self.chat_widget + .by_slot(*slot) + .and_then(|pane| pane.owned_screen.as_ref()) + .is_some_and(|screen| screen.last_pane_area.contains(position)) + }); + let Some(target) = target else { + return false; + }; + let focus_changed = self.chat_widget.focused_slot() != target; + let backtrack_was_primed = self.backtrack.primed; + if !self.focus_conversation_pane(target) { + return false; + } + if focus_changed || backtrack_was_primed { + tui.frame_requester().schedule_frame(); + } + true + } + pub(crate) fn sync_owned_screen_cells(&mut self) { let cells = self.chat_widget.transcript_cells.clone(); if let Some(screen) = &mut self.chat_widget.owned_screen { diff --git a/codex-rs/tui/src/app/owned_screen_tests.rs b/codex-rs/tui/src/app/owned_screen_tests.rs index 5080018766..3a2a449e82 100644 --- a/codex-rs/tui/src/app/owned_screen_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_tests.rs @@ -17,6 +17,7 @@ use crate::chatwidget::tests::constructor::make_chatwidget_for_pane; use crate::chatwidget::tests::constructor::make_chatwidget_for_pane_with_sender; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::file_search::FileSearchManager; +use crate::tui::MousePrimaryPressEvent; use crate::tui::MouseScrollDirection; use crate::tui::MouseScrollEvent; use codex_app_server_protocol::ConfigWarningNotification; @@ -362,6 +363,60 @@ async fn renders_closed_parent_read_only_while_side_remains_focused() { ); } +#[tokio::test] +async fn primary_press_focuses_closed_parent_without_enabling_input() { + let mut app = app_with_owned_side().await; + seed_pane(&mut app, PaneSlot::Parent, "", &["parent transcript"]); + seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]); + app.chat_widget + .by_slot_mut(PaneSlot::Parent) + .expect("parent pane") + .mark_thread_closed(); + assert!(app.chat_widget.focus(PaneSlot::Side)); + let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 12); + + let parent_area = app + .chat_widget + .by_slot(PaneSlot::Parent) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("parent screen") + .last_pane_area; + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + assert!(app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: parent_area.x, + row: parent_area.y, + }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + let mut rendered_cursor = None; + let mut terminal = + Terminal::new(TestBackend::new(/*width*/ 83, /*height*/ 12)).expect("create terminal"); + terminal + .draw(|frame| { + rendered_cursor = Some( + render_layout( + &mut app.chat_widget, + OwnedScreenLayout::new(frame.area(), /*has_side*/ true, PaneSlot::Parent), + PaneSlot::Parent, + frame.buffer_mut(), + ) + .expect("render closed parent") + .cursor, + ); + }) + .expect("render owned panes"); + assert_eq!(rendered_cursor, Some(None)); + assert_eq!( + app.chat_widget + .by_slot(PaneSlot::Side) + .expect("side pane") + .composer_text_with_pending(), + "side draft" + ); +} + #[tokio::test] async fn narrow_layout_renders_only_the_focused_side() { let mut app = app_with_owned_side().await; @@ -372,6 +427,7 @@ async fn narrow_layout_renders_only_the_focused_side() { &["PARENT MUST BE HIDDEN"], ); seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]); + let _wide = render_app(&mut app, /*width*/ 83, /*height*/ 14); assert!(app.chat_widget.focus(PaneSlot::Side)); let terminal = render_app(&mut app, /*width*/ 82, /*height*/ 14); @@ -381,9 +437,16 @@ async fn narrow_layout_renders_only_the_focused_side() { app.chat_widget .by_slot(PaneSlot::Parent) .and_then(|pane| pane.owned_screen.as_ref()) - .map(|screen| screen.last_conversation_area), - Some(Rect::default()) + .map(|screen| (screen.last_pane_area, screen.last_conversation_area)), + Some((Rect::default(), Rect::default())) ); + + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + assert!(app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { column: 2, row: 2 }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); } #[tokio::test] @@ -402,6 +465,259 @@ async fn terminal_cursor_tracks_only_the_focused_pane() { assert!(side_cursor.x > 41); } +#[tokio::test] +async fn primary_press_focuses_visible_pane_regions() { + let mut app = app_with_owned_side().await; + seed_pane( + &mut app, + PaneSlot::Parent, + "parent draft", + &["parent transcript"], + ); + seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]); + let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16); + let (parent_area, parent_conversation_area, side_area, side_conversation_area) = { + let parent = app + .chat_widget + .by_slot(PaneSlot::Parent) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("parent screen"); + let side = app + .chat_widget + .by_slot(PaneSlot::Side) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("side screen"); + ( + parent.last_pane_area, + parent.last_conversation_area, + side.last_pane_area, + side.last_conversation_area, + ) + }; + let region_pairs = [ + ((parent_area.x, parent_area.y), (side_area.x, side_area.y)), + ( + (parent_conversation_area.x, parent_conversation_area.y), + (side_conversation_area.x, side_conversation_area.y), + ), + ( + (parent_area.x, parent_conversation_area.bottom()), + (side_area.x, side_conversation_area.bottom()), + ), + ( + (parent_area.x, parent_area.bottom() - 1), + (side_area.x, side_area.bottom() - 1), + ), + ]; + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + + for (parent_position, side_position) in region_pairs { + app.backtrack.primed = true; + assert!(app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: side_position.0, + row: side_position.1, + }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); + assert!(!app.backtrack.primed); + assert!(app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: parent_position.0, + row: parent_position.1, + }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + } + + app.backtrack.primed = true; + assert!(app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: parent_area.x, + row: parent_area.y, + }, + )); + assert!(!app.backtrack.primed); + assert!(!app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: parent_area.right(), + row: parent_area.y, + }, + )); + assert!(!app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: side_area.right(), + row: side_area.y, + }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); +} + +#[tokio::test] +async fn primary_press_preserves_pane_drafts_and_cursors() { + let mut app = app_with_owned_side().await; + seed_pane( + &mut app, + PaneSlot::Parent, + "parent draft", + &["parent transcript"], + ); + seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]); + for (slot, cursor_moves) in [(PaneSlot::Parent, 1), (PaneSlot::Side, 2)] { + for _ in 0..cursor_moves { + app.chat_widget + .by_slot_mut(slot) + .expect("installed pane") + .handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + } + } + + let mut parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16); + let parent_cursor = parent_terminal + .get_cursor_position() + .expect("parent cursor"); + let (parent_area, parent_conversation_area, side_area, side_conversation_area) = { + let parent = app + .chat_widget + .by_slot(PaneSlot::Parent) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("parent screen"); + let side = app + .chat_widget + .by_slot(PaneSlot::Side) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("side screen"); + ( + parent.last_pane_area, + parent.last_conversation_area, + side.last_pane_area, + side.last_conversation_area, + ) + }; + assert!(app.chat_widget.focus(PaneSlot::Side)); + let mut side_terminal_before_click = + render_app(&mut app, /*width*/ 83, /*height*/ 16); + let side_cursor_before_click = side_terminal_before_click + .get_cursor_position() + .expect("side cursor before click"); + assert!(app.chat_widget.focus(PaneSlot::Parent)); + let _parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + + assert!(app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: side_area.x, + row: side_conversation_area.bottom(), + }, + )); + let mut side_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16); + let side_cursor = side_terminal.get_cursor_position().expect("side cursor"); + assert_eq!(side_cursor, side_cursor_before_click); + assert_snapshot!( + "owned_screen_wide_split_side_focused_by_click", + side_terminal.backend() + ); + let buffer = side_terminal.backend().buffer(); + assert!( + buffer[(parent_area.x + 1, parent_area.y)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::DIM) + ); + assert!( + buffer[(side_area.x + 1, side_area.y)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + ); + assert_eq!( + ( + app.chat_widget + .by_slot(PaneSlot::Parent) + .expect("parent pane") + .composer_text_with_pending(), + app.chat_widget + .by_slot(PaneSlot::Side) + .expect("side pane") + .composer_text_with_pending(), + ), + ("parent draft".to_string(), "side draft".to_string()) + ); + + assert!(app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: parent_area.x, + row: parent_conversation_area.bottom(), + }, + )); + let mut parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16); + assert_eq!( + parent_terminal + .get_cursor_position() + .expect("restored parent cursor"), + parent_cursor + ); + assert!(app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: side_area.x, + row: side_conversation_area.bottom(), + }, + )); + let mut side_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16); + assert_eq!( + side_terminal + .get_cursor_position() + .expect("restored side cursor"), + side_cursor + ); +} + +#[tokio::test] +async fn primary_press_does_not_switch_behind_overlay_or_popup() { + let mut app = app_with_owned_side().await; + let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 12); + let side_area = app + .chat_widget + .by_slot(PaneSlot::Side) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("side screen") + .last_pane_area; + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + + app.overlay = Some(Overlay::new_transcript( + Vec::new(), + app.keymap.pager.clone(), + )); + assert!(!app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: side_area.x, + row: side_area.y, + }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + + app.overlay = None; + let keymap = app.keymap.clone(); + app.chat_widget.open_keymap_debug(&keymap); + assert!(!app.handle_owned_screen_mouse_primary_press( + &mut tui, + MousePrimaryPressEvent { + column: side_area.x, + row: side_area.y, + }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); +} + #[tokio::test] async fn mouse_wheel_routes_by_pointer_without_changing_focus() { let mut app = app_with_owned_side().await; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_wide_split_side_focused_by_click.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_wide_split_side_focused_by_click.snap new file mode 100644 index 0000000000..c602d352ce --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_wide_split_side_focused_by_click.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/app/owned_screen_tests.rs +expression: side_terminal.backend() +--- +" Parent │ Side ⌥ + 1 / ⌥ + 2 focus " +"parent transcript │side transcript " +" │ " +" │╭───────────────────────────────────────╮" +" ││ >_ OpenAI Codex (v0.0.0) │" +" ││ │" +" ││ model: loading /model to change │" +" ││ directory: /tmp/project │" +" │╰───────────────────────────────────────╯" +" │ " +" │ " +" │ " +" │ " +"› parent draft │› side draft " +" │ " +" gpt-5.5 default · /tmp/project │ gpt-5.5 default · /tmp/project " diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index f6ff661406..d6556573ac 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::MouseScroll(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {} 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 b8f2b3ecfd..16365b87ae 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::MouseScroll(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {} TuiEvent::Draw | TuiEvent::Resize => { let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index a920c02850..e585f02d85 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::MouseScroll(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {} 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 e56399cc91..aed10933f7 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -509,7 +509,7 @@ pub(crate) async fn run_onboarding_app( TuiEvent::Paste(text) => { onboarding_screen.handle_paste(text); } - TuiEvent::MouseScroll(_) => {} + TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {} TuiEvent::Draw | TuiEvent::Resize => { if !did_full_clear_after_success && onboarding_screen.steps.iter().any(|step| { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 6a2552e624..0b85a35278 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -475,7 +475,7 @@ async fn run_session_picker_with_loader( TuiEvent::Paste(pasted) => { state.handle_paste(pasted); } - TuiEvent::MouseScroll(_) => {} + TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {} 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 f57d692985..fd87ebe142 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::MouseScroll(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {} 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 fd7b111f0a..fb40d379a8 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -519,6 +519,8 @@ pub enum TuiEvent { Paste(String), /// A vertical mouse-wheel event reported while an owned screen has mouse capture. MouseScroll(MouseScrollEvent), + /// A primary mouse-button press with terminal coordinates. + MousePrimaryPress(MousePrimaryPressEvent), /// 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 @@ -535,6 +537,12 @@ pub struct MouseScrollEvent { pub row: u16, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MousePrimaryPressEvent { + pub column: u16, + pub row: u16, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MouseScrollDirection { Up, diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index baf3dbb314..c87be5fe84 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -34,6 +34,7 @@ use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::WatchStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use super::MousePrimaryPressEvent; use super::MouseScrollDirection; use super::MouseScrollEvent; use super::TuiEvent; @@ -179,11 +180,11 @@ impl TuiEventStream { /// Poll the shared crossterm stream for the next mapped `TuiEvent`. /// - /// This skips events we don't use (mouse clicks, focus loss, etc.) and keeps polling until it yields + /// This skips events we don't use (mouse movement, 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 clicks); loop so we keep polling + // Some crossterm events map to None (e.g. FocusLost, mouse movement); loop so we keep polling // until we return a mapped event, hit Pending, or see EOF/error. for _ in 0..MAX_SKIPPED_EVENTS_PER_POLL { let poll_result = { @@ -264,23 +265,30 @@ 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, + Event::Mouse(mouse_event) => match mouse_event.kind { + MouseEventKind::ScrollUp => Some(TuiEvent::MouseScroll(MouseScrollEvent { + direction: MouseScrollDirection::Up, column: mouse_event.column, row: mouse_event.row, - })) - } + })), + MouseEventKind::ScrollDown => Some(TuiEvent::MouseScroll(MouseScrollEvent { + direction: MouseScrollDirection::Down, + column: mouse_event.column, + row: mouse_event.row, + })), + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent { + column: mouse_event.column, + row: mouse_event.row, + })) + } + MouseEventKind::Down(_) + | MouseEventKind::Up(_) + | MouseEventKind::Drag(_) + | MouseEventKind::Moved + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => None, + }, Event::FocusGained => { self.terminal_focused.store(true, Ordering::Relaxed); crate::terminal_palette::requery_default_colors(); @@ -498,6 +506,27 @@ mod tests { assert!(matches!(next, Some(TuiEvent::Resize))); } + #[tokio::test(flavor = "current_thread")] + async fn primary_mouse_press_maps_to_event() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 3, + row: 4, + modifiers: KeyModifiers::NONE, + }))); + + assert!(matches!( + stream.next().await, + Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent { + column: 3, + row: 4, + })) + )); + } + #[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(); @@ -505,11 +534,23 @@ mod tests { stream.poll_draw_first = false; handle.send(Ok(Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), + kind: MouseEventKind::Down(MouseButton::Right), column: 3, row: 4, modifiers: KeyModifiers::NONE, }))); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 3, + row: 4, + modifiers: KeyModifiers::NONE, + }))); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 4, + row: 4, + modifiers: KeyModifiers::NONE, + }))); for column in 0..=MAX_SKIPPED_EVENTS_PER_POLL { handle.send(Ok(Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index 8accdeb214..ec8e2b23b5 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::MouseScroll(_) => {} + TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {} TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area());