diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0fd5429fb9..982f3fd22b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -215,6 +215,7 @@ mod history_ui; mod input; mod loaded_threads; mod owned_screen; +mod owned_screen_resize; mod pending_interactive_replay; mod pets; mod platform_actions; @@ -1269,11 +1270,29 @@ See the Codex keymap documentation for supported actions and examples." app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { + match &event { + TuiEvent::Resize => { + self.chat_widget.cancel_owned_screen_split_drag(); + } + TuiEvent::FocusLost => { + if self.chat_widget.cancel_owned_screen_split_drag() { + tui.frame_requester().schedule_frame(); + } + } + TuiEvent::Key(_) + | TuiEvent::Paste(_) + | TuiEvent::MouseScroll(_) + | TuiEvent::MousePrimary(_) + | TuiEvent::Draw => {} + } if matches!(event, TuiEvent::Draw | TuiEvent::Resize) { self.handle_draw_pre_render(tui)?; } if self.overlay.is_some() { + if self.chat_widget.cancel_owned_screen_split_drag() { + tui.frame_requester().schedule_frame(); + } let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { match event { @@ -1303,9 +1322,10 @@ See the Codex keymap documentation for supported actions and examples." .await; } } - TuiEvent::MousePrimaryPress(event) => { - self.handle_owned_screen_mouse_primary_press(tui, event); + TuiEvent::MousePrimary(event) => { + self.handle_owned_screen_mouse_primary(tui, event); } + TuiEvent::FocusLost => {} TuiEvent::Draw | TuiEvent::Resize => { if self.backtrack_render_pending { self.rebuild_transcript_after_backtrack(tui)?; diff --git a/codex-rs/tui/src/app/conversation_panes.rs b/codex-rs/tui/src/app/conversation_panes.rs index 1ee6488675..b1bc48152f 100644 --- a/codex-rs/tui/src/app/conversation_panes.rs +++ b/codex-rs/tui/src/app/conversation_panes.rs @@ -16,6 +16,9 @@ use tokio::sync::mpsc; use super::InitialHistoryReplayBuffer; use super::owned_screen::OwnedScreen; +use super::owned_screen_resize::OwnedScreenLayout; +use super::owned_screen_resize::OwnedScreenSplitState; +use super::owned_screen_resize::PaneSplitPreference; use super::thread_events::ThreadBufferedEvent; use crate::app_event::ConversationOrigin; use crate::app_event::PaneSlot; @@ -25,6 +28,7 @@ use crate::history_cell::HistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::transcript_reflow::TranscriptReflowState; +use crate::tui::MousePrimaryEvent; pub(super) fn parent_pane_shortcut() -> KeyBinding { key_hint::alt(KeyCode::Char('1')) @@ -125,6 +129,7 @@ pub(crate) struct ConversationPanes { side: Option, focused: PaneSlot, dispatch: Option, + owned_screen_split: OwnedScreenSplitState, } // Rendering and `/side` lifecycle consume the remaining APIs in the next stack layers. @@ -136,6 +141,7 @@ impl ConversationPanes { side: None, focused: PaneSlot::Parent, dispatch: None, + owned_screen_split: OwnedScreenSplitState::default(), }) } @@ -173,6 +179,7 @@ impl ConversationPanes { } pub(super) fn take_side(&mut self) -> Option { + self.owned_screen_split.clear_rendered_layout(); if self.focused == PaneSlot::Side { self.focus(PaneSlot::Parent); } @@ -186,6 +193,26 @@ impl ConversationPanes { self.side.take() } + pub(super) fn owned_screen_split_preference(&self) -> PaneSplitPreference { + self.owned_screen_split.preference() + } + + pub(super) fn record_owned_screen_layout(&mut self, layout: OwnedScreenLayout) { + self.owned_screen_split.record_layout(layout); + } + + pub(super) fn handle_owned_screen_split_mouse(&mut self, event: MousePrimaryEvent) -> bool { + self.owned_screen_split.handle_mouse(event) + } + + pub(super) fn owned_screen_split_is_dragging(&self) -> bool { + self.owned_screen_split.is_dragging() + } + + pub(super) fn cancel_owned_screen_split_drag(&mut self) -> bool { + self.owned_screen_split.cancel_drag() + } + pub(super) fn by_slot(&self, slot: PaneSlot) -> Option<&ConversationPane> { match slot { PaneSlot::Parent => Some(&self.parent), diff --git a/codex-rs/tui/src/app/owned_screen.rs b/codex-rs/tui/src/app/owned_screen.rs index 532ca8ebaa..0d81af1805 100644 --- a/codex-rs/tui/src/app/owned_screen.rs +++ b/codex-rs/tui/src/app/owned_screen.rs @@ -18,15 +18,14 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use super::owned_screen_resize::OwnedScreenLayout; use super::*; use crate::AltScreenBehavior; use crate::key_hint::is_plain_text_key_event; -use crate::tui::MousePrimaryPressEvent; +use crate::tui::MousePrimaryEvent; +use crate::tui::MousePrimaryEventKind; use crate::tui::MouseScrollEvent; -const MIN_SPLIT_PANE_WIDTH: u16 = 41; -const SPLIT_DIVIDER_WIDTH: u16 = 1; -const MIN_SPLIT_WIDTH: u16 = MIN_SPLIT_PANE_WIDTH * 2 + SPLIT_DIVIDER_WIDTH; const PANE_HEADER_HEIGHT: u16 = 1; pub(super) struct OwnedScreen { @@ -41,59 +40,6 @@ struct RenderedOwnedScreen { cursor_style: SetCursorStyle, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum OwnedScreenLayout { - Single { - slot: PaneSlot, - area: Rect, - show_header: bool, - }, - Split { - area: Rect, - parent: Rect, - divider: Rect, - side: Rect, - }, -} - -impl OwnedScreenLayout { - fn new(area: Rect, has_side: bool, focused: PaneSlot) -> Self { - if !has_side { - return Self::Single { - slot: PaneSlot::Parent, - area, - show_header: false, - }; - } - if area.width < MIN_SPLIT_WIDTH { - return Self::Single { - slot: focused, - area, - show_header: true, - }; - } - - let pane_width = area.width.saturating_sub(SPLIT_DIVIDER_WIDTH); - let parent_width = (pane_width + 1) / 2; - let side_width = pane_width.saturating_sub(parent_width); - let parent = Rect::new(area.x, area.y, parent_width, area.height); - let divider = Rect::new(parent.right(), area.y, SPLIT_DIVIDER_WIDTH, area.height); - let side = Rect::new(divider.right(), area.y, side_width, area.height); - Self::Split { - area, - parent, - divider, - side, - } - } - - fn area(self) -> Rect { - match self { - Self::Single { area, .. } | Self::Split { area, .. } => area, - } - } -} - impl OwnedScreen { fn new(chat_widget: &ChatWidget, keymap: crate::keymap::PagerKeymap) -> Self { Self { @@ -231,14 +177,19 @@ fn render_pane_header(slot: PaneSlot, focused: bool, area: Rect, buffer: &mut Bu ); } -fn render_divider(area: Rect, buffer: &mut Buffer) { +fn render_divider(area: Rect, active: bool, buffer: &mut Buffer) { if area.width == 0 { return; } + let style = if active { + Style::default().cyan().bold() + } else { + Style::default().dim() + }; for y in area.y..area.bottom() { buffer[(area.x, y)] - .set_symbol("│") - .set_style(Style::default().dim()); + .set_symbol(if active { "┃" } else { "│" }) + .set_style(style); } } @@ -265,6 +216,8 @@ fn render_layout( focused: PaneSlot, buffer: &mut Buffer, ) -> Option { + panes.record_owned_screen_layout(layout); + let divider_active = panes.owned_screen_split_is_dragging(); Clear.render(layout.area(), buffer); for slot in [PaneSlot::Parent, PaneSlot::Side] { if let Some(screen) = panes @@ -303,7 +256,7 @@ fn render_layout( focused, buffer, ); - render_divider(divider, buffer); + render_divider(divider, divider_active, buffer); match focused { PaneSlot::Parent => parent_rendered, PaneSlot::Side => side_rendered, @@ -380,6 +333,9 @@ impl App { tui: &mut tui::Tui, event: MouseScrollEvent, ) -> bool { + if self.chat_widget.owned_screen_split_is_dragging() { + return true; + } for slot in [PaneSlot::Parent, PaneSlot::Side] { let handled = self.chat_widget.by_slot_mut(slot).is_some_and(|pane| { pane.chat_widget.no_modal_or_popup_active() @@ -397,12 +353,33 @@ impl App { false } - pub(super) fn handle_owned_screen_mouse_primary_press( + pub(super) fn handle_owned_screen_mouse_primary( &mut self, tui: &mut tui::Tui, - event: MousePrimaryPressEvent, + event: MousePrimaryEvent, ) -> bool { - if !self.chat_widget.has_side() { + if self.overlay.is_some() + || !self.chat_widget.has_side() + || !self.chat_widget.no_modal_or_popup_active() + { + if self.chat_widget.cancel_owned_screen_split_drag() { + tui.frame_requester().schedule_frame(); + } + return false; + } + + if self.chat_widget.handle_owned_screen_split_mouse(event) { + match event.kind { + MousePrimaryEventKind::Drag => tui + .frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL), + MousePrimaryEventKind::Press | MousePrimaryEventKind::Release => { + tui.frame_requester().schedule_frame(); + } + } + return true; + } + if event.kind != MousePrimaryEventKind::Press { return false; } @@ -474,12 +451,16 @@ impl App { if !self.has_owned_screen() { return Ok(None); } + if !self.chat_widget.no_modal_or_popup_active() { + self.chat_widget.cancel_owned_screen_split_drag(); + } let focused = self.chat_widget.focused_slot(); let has_side = self.chat_widget.has_side(); + let split_preference = self.chat_widget.owned_screen_split_preference(); let mut rendered_area = Rect::default(); tui.draw(/*height*/ u16::MAX, |frame| { rendered_area = frame.area(); - let layout = OwnedScreenLayout::new(rendered_area, has_side, focused); + let layout = OwnedScreenLayout::new(rendered_area, has_side, focused, split_preference); if let Some(rendered) = render_layout(&mut self.chat_widget, layout, focused, frame.buffer) && let Some((x, y)) = rendered.cursor diff --git a/codex-rs/tui/src/app/owned_screen_resize.rs b/codex-rs/tui/src/app/owned_screen_resize.rs new file mode 100644 index 0000000000..694d3c68c8 --- /dev/null +++ b/codex-rs/tui/src/app/owned_screen_resize.rs @@ -0,0 +1,219 @@ +//! Layout and pointer interaction state for application-owned conversation panes. + +use ratatui::layout::Position; +use ratatui::layout::Rect; + +use crate::app_event::PaneSlot; +use crate::tui::MousePrimaryEvent; +use crate::tui::MousePrimaryEventKind; + +const MIN_SPLIT_PANE_WIDTH: u16 = 41; +const SPLIT_DIVIDER_WIDTH: u16 = 1; +const MIN_SPLIT_WIDTH: u16 = MIN_SPLIT_PANE_WIDTH * 2 + SPLIT_DIVIDER_WIDTH; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct PaneSplitPreference { + parent_width: u16, + available_width: u16, +} + +impl Default for PaneSplitPreference { + fn default() -> Self { + Self { + parent_width: 1, + available_width: 2, + } + } +} + +impl PaneSplitPreference { + fn parent_width(self, available_width: u16) -> u16 { + let denominator = u32::from(self.available_width.max(/*other*/ 1)); + let numerator = u32::from(self.parent_width) * u32::from(available_width); + ((numerator + denominator / 2) / denominator) as u16 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum OwnedScreenLayout { + Single { + slot: PaneSlot, + area: Rect, + show_header: bool, + }, + Split { + area: Rect, + parent: Rect, + divider: Rect, + side: Rect, + }, +} + +impl OwnedScreenLayout { + pub(super) fn new( + area: Rect, + has_side: bool, + focused: PaneSlot, + preference: PaneSplitPreference, + ) -> Self { + if !has_side { + return Self::Single { + slot: PaneSlot::Parent, + area, + show_header: false, + }; + } + if area.width < MIN_SPLIT_WIDTH { + return Self::Single { + slot: focused, + area, + show_header: true, + }; + } + + let available_width = area.width.saturating_sub(SPLIT_DIVIDER_WIDTH); + let max_parent_width = available_width.saturating_sub(MIN_SPLIT_PANE_WIDTH); + let parent_width = preference + .parent_width(available_width) + .clamp(MIN_SPLIT_PANE_WIDTH, max_parent_width); + let side_width = available_width.saturating_sub(parent_width); + let parent = Rect::new(area.x, area.y, parent_width, area.height); + let divider = Rect::new(parent.right(), area.y, SPLIT_DIVIDER_WIDTH, area.height); + let side = Rect::new(divider.right(), area.y, side_width, area.height); + Self::Split { + area, + parent, + divider, + side, + } + } + + pub(super) fn area(self) -> Rect { + match self { + Self::Single { area, .. } | Self::Split { area, .. } => area, + } + } + + fn split_geometry(self) -> Option { + match self { + Self::Single { .. } => None, + Self::Split { area, divider, .. } => Some(SplitGeometry { area, divider }), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SplitGeometry { + area: Rect, + divider: Rect, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum SplitInteraction { + #[default] + Idle, + Dragging { + origin_column: u16, + initial_preference: PaneSplitPreference, + }, +} + +#[derive(Debug, Default)] +pub(super) struct OwnedScreenSplitState { + preference: PaneSplitPreference, + interaction: SplitInteraction, + geometry: Option, +} + +impl OwnedScreenSplitState { + pub(super) fn preference(&self) -> PaneSplitPreference { + self.preference + } + + pub(super) fn record_layout(&mut self, layout: OwnedScreenLayout) { + let next_geometry = layout.split_geometry(); + let area_changed = self + .geometry + .zip(next_geometry) + .is_some_and(|(current, next)| current.area != next.area); + if next_geometry.is_none() || area_changed { + self.interaction = SplitInteraction::Idle; + } + self.geometry = next_geometry; + } + + pub(super) fn clear_rendered_layout(&mut self) { + self.interaction = SplitInteraction::Idle; + self.geometry = None; + } + + pub(super) fn is_dragging(&self) -> bool { + matches!(self.interaction, SplitInteraction::Dragging { .. }) + } + + pub(super) fn cancel_drag(&mut self) -> bool { + let was_dragging = self.is_dragging(); + self.interaction = SplitInteraction::Idle; + was_dragging + } + + pub(super) fn handle_mouse(&mut self, event: MousePrimaryEvent) -> bool { + match event.kind { + MousePrimaryEventKind::Press => { + self.interaction = SplitInteraction::Idle; + let position = Position::new(event.column, event.row); + if self.geometry.is_some_and(|geometry| { + geometry.area.width > MIN_SPLIT_WIDTH && geometry.divider.contains(position) + }) { + self.interaction = SplitInteraction::Dragging { + origin_column: event.column, + initial_preference: self.preference, + }; + true + } else { + false + } + } + MousePrimaryEventKind::Drag if self.is_dragging() => { + self.update_preference(event.column); + true + } + MousePrimaryEventKind::Release if self.is_dragging() => { + let SplitInteraction::Dragging { + origin_column, + initial_preference, + } = self.interaction + else { + return false; + }; + if event.column == origin_column { + self.preference = initial_preference; + } else { + self.update_preference(event.column); + } + self.interaction = SplitInteraction::Idle; + true + } + MousePrimaryEventKind::Drag | MousePrimaryEventKind::Release => false, + } + } + + fn update_preference(&mut self, column: u16) { + let Some(geometry) = self.geometry else { + return; + }; + let available_width = geometry.area.width.saturating_sub(SPLIT_DIVIDER_WIDTH); + let max_parent_width = available_width.saturating_sub(MIN_SPLIT_PANE_WIDTH); + let parent_width = column + .saturating_sub(geometry.area.x) + .clamp(MIN_SPLIT_PANE_WIDTH, max_parent_width); + self.preference = PaneSplitPreference { + parent_width, + available_width, + }; + } +} + +#[cfg(test)] +#[path = "owned_screen_resize_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/owned_screen_resize_tests.rs b/codex-rs/tui/src/app/owned_screen_resize_tests.rs new file mode 100644 index 0000000000..9e0f696c11 --- /dev/null +++ b/codex-rs/tui/src/app/owned_screen_resize_tests.rs @@ -0,0 +1,194 @@ +use pretty_assertions::assert_eq; + +use super::*; + +fn split_widths(layout: OwnedScreenLayout) -> (u16, u16, u16) { + let OwnedScreenLayout::Split { + parent, + divider, + side, + .. + } = layout + else { + panic!("expected split layout"); + }; + (parent.width, divider.x, side.width) +} + +#[test] +fn layout_uses_threshold_parent_bias_and_nonzero_origin() { + let preference = PaneSplitPreference::default(); + let narrow = Rect::new( + /*x*/ 7, /*y*/ 3, /*width*/ 82, /*height*/ 20, + ); + assert_eq!( + OwnedScreenLayout::new(narrow, /*has_side*/ true, PaneSlot::Side, preference), + OwnedScreenLayout::Single { + slot: PaneSlot::Side, + area: narrow, + show_header: true, + } + ); + + for (width, expected) in [(83, (41, 48, 41)), (84, (42, 49, 41))] { + let area = Rect::new(/*x*/ 7, /*y*/ 3, width, /*height*/ 20); + assert_eq!( + split_widths(OwnedScreenLayout::new( + area, + /*has_side*/ true, + PaneSlot::Parent, + preference, + )), + expected + ); + } +} + +#[test] +fn drag_lifecycle_clamps_and_preserves_the_preferred_ratio() { + let mut state = OwnedScreenSplitState::default(); + let initial_preference = state.preference(); + let wide = Rect::new( + /*x*/ 10, /*y*/ 2, /*width*/ 120, /*height*/ 20, + ); + state.record_layout(OwnedScreenLayout::new( + wide, + /*has_side*/ true, + PaneSlot::Parent, + state.preference(), + )); + assert!(!state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Drag, + column: 80, + row: 4, + })); + assert!(!state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column: 20, + row: 4, + })); + + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column: 70, + row: 4, + })); + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Drag, + column: 10, + row: 4, + })); + assert_eq!( + split_widths(OwnedScreenLayout::new( + wide, + /*has_side*/ true, + PaneSlot::Parent, + state.preference(), + )), + (41, 51, 78) + ); + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Release, + column: 70, + row: 4, + })); + assert_eq!(state.preference(), initial_preference); + + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column: 70, + row: 4, + })); + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Drag, + column: 80, + row: 4, + })); + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Release, + column: 80, + row: u16::MAX, + })); + assert_eq!( + split_widths(OwnedScreenLayout::new( + wide, + /*has_side*/ true, + PaneSlot::Parent, + state.preference(), + )), + (70, 80, 49) + ); + assert!(!state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Drag, + column: 70, + row: 4, + })); + + let preference = state.preference(); + let constrained = Rect::new( + /*x*/ 10, /*y*/ 2, /*width*/ 90, /*height*/ 20, + ); + let constrained_layout = OwnedScreenLayout::new( + constrained, + /*has_side*/ true, + PaneSlot::Parent, + preference, + ); + assert_eq!(split_widths(constrained_layout), (48, 58, 41)); + state.record_layout(constrained_layout); + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column: 58, + row: 4, + })); + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Release, + column: 58, + row: 4, + })); + assert_eq!(state.preference(), preference); + + assert!(state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column: 58, + row: 4, + })); + let narrow = Rect::new( + /*x*/ 10, /*y*/ 2, /*width*/ 82, /*height*/ 20, + ); + state.record_layout(OwnedScreenLayout::new( + narrow, + /*has_side*/ true, + PaneSlot::Parent, + state.preference(), + )); + assert!(!state.is_dragging()); + assert_eq!(state.preference(), preference); + assert_eq!( + split_widths(OwnedScreenLayout::new( + wide, + /*has_side*/ true, + PaneSlot::Parent, + state.preference(), + )), + (70, 80, 49) + ); + + let fixed = Rect::new( + /*x*/ 10, + /*y*/ 2, + MIN_SPLIT_WIDTH, + /*height*/ 20, + ); + state.record_layout(OwnedScreenLayout::new( + fixed, + /*has_side*/ true, + PaneSlot::Parent, + state.preference(), + )); + assert!(!state.handle_mouse(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column: 51, + row: 4, + })); +} diff --git a/codex-rs/tui/src/app/owned_screen_tests.rs b/codex-rs/tui/src/app/owned_screen_tests.rs index 3a2a449e82..31a7ceb765 100644 --- a/codex-rs/tui/src/app/owned_screen_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_tests.rs @@ -17,7 +17,8 @@ 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::MousePrimaryEvent; +use crate::tui::MousePrimaryEventKind; use crate::tui::MouseScrollDirection; use crate::tui::MouseScrollEvent; use codex_app_server_protocol::ConfigWarningNotification; @@ -95,10 +96,11 @@ fn seed_pane(app: &mut App, slot: PaneSlot, draft: &str, cells: &[&'static str]) fn render_app(app: &mut App, width: u16, height: u16) -> Terminal { let focused = app.chat_widget.focused_slot(); let has_side = app.chat_widget.has_side(); + let split_preference = app.chat_widget.owned_screen_split_preference(); let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); terminal .draw(|frame| { - let layout = OwnedScreenLayout::new(frame.area(), has_side, focused); + let layout = OwnedScreenLayout::new(frame.area(), has_side, focused, split_preference); if let Some(rendered) = render_layout(&mut app.chat_widget, layout, focused, frame.buffer_mut()) && let Some((x, y)) = rendered.cursor @@ -119,51 +121,12 @@ fn is_following_bottom(app: &App, slot: PaneSlot) -> bool { .is_following_bottom() } -#[test] -fn responsive_layout_uses_expected_threshold_and_parent_bias() { - let area_narrow = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 82, /*height*/ 20, - ); - let narrow = OwnedScreenLayout::new(area_narrow, /*has_side*/ true, PaneSlot::Side); - assert!(matches!( - narrow, - OwnedScreenLayout::Single { - slot: PaneSlot::Side, - show_header: true, - .. - } - )); - assert_eq!( - OwnedScreenLayout::new(area_narrow, /*has_side*/ false, PaneSlot::Side), - OwnedScreenLayout::Single { - slot: PaneSlot::Parent, - area: area_narrow, - show_header: false, - } - ); - for (width, expected_parent_width) in [(83, 41), (84, 42)] { - let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 20); - let OwnedScreenLayout::Split { - parent, - divider, - side, - .. - } = OwnedScreenLayout::new(area, /*has_side*/ true, PaneSlot::Parent) - else { - panic!("width {width} should split"); - }; - assert_eq!(parent.width, expected_parent_width); - assert_eq!( - divider, - Rect::new( - parent.right(), - /*y*/ 0, - /*width*/ 1, - /*height*/ 20 - ) - ); - assert_eq!(side.width, 41); - } +fn primary_event(kind: MousePrimaryEventKind, column: u16, row: u16) -> MousePrimaryEvent { + MousePrimaryEvent { kind, column, row } +} + +fn primary_press(column: u16, row: u16) -> MousePrimaryEvent { + primary_event(MousePrimaryEventKind::Press, column, row) } #[tokio::test] @@ -382,14 +345,14 @@ async fn primary_press_focuses_closed_parent_without_enabling_input() { .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!( + app.handle_owned_screen_mouse_primary( + &mut tui, + primary_press(parent_area.x, parent_area.y), + ) + ); assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + let split_preference = app.chat_widget.owned_screen_split_preference(); let mut rendered_cursor = None; let mut terminal = Terminal::new(TestBackend::new(/*width*/ 83, /*height*/ 12)).expect("create terminal"); @@ -398,7 +361,12 @@ async fn primary_press_focuses_closed_parent_without_enabling_input() { rendered_cursor = Some( render_layout( &mut app.chat_widget, - OwnedScreenLayout::new(frame.area(), /*has_side*/ true, PaneSlot::Parent), + OwnedScreenLayout::new( + frame.area(), + /*has_side*/ true, + PaneSlot::Parent, + split_preference, + ), PaneSlot::Parent, frame.buffer_mut(), ) @@ -442,10 +410,9 @@ async fn narrow_layout_renders_only_the_focused_side() { ); 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!( + app.handle_owned_screen_mouse_primary(&mut tui, primary_press(/*column*/ 2, /*row*/ 2),) + ); assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); } @@ -513,47 +480,34 @@ async fn primary_press_focuses_visible_pane_regions() { for (parent_position, side_position) in region_pairs { app.backtrack.primed = true; - assert!(app.handle_owned_screen_mouse_primary_press( + assert!(app.handle_owned_screen_mouse_primary( &mut tui, - MousePrimaryPressEvent { - column: side_position.0, - row: side_position.1, - }, + primary_press(side_position.0, side_position.1), )); assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); assert!(!app.backtrack.primed); - assert!(app.handle_owned_screen_mouse_primary_press( + assert!(app.handle_owned_screen_mouse_primary( &mut tui, - MousePrimaryPressEvent { - column: parent_position.0, - row: parent_position.1, - }, + primary_press(parent_position.0, 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.handle_owned_screen_mouse_primary( + &mut tui, + primary_press(parent_area.x, parent_area.y), + ) + ); assert!(!app.backtrack.primed); - assert!(!app.handle_owned_screen_mouse_primary_press( + assert!(!app.handle_owned_screen_mouse_primary( &mut tui, - MousePrimaryPressEvent { - column: parent_area.right(), - row: parent_area.y, - }, + primary_press(parent_area.right(), parent_area.y), )); - assert!(!app.handle_owned_screen_mouse_primary_press( + assert!(!app.handle_owned_screen_mouse_primary( &mut tui, - MousePrimaryPressEvent { - column: side_area.right(), - row: side_area.y, - }, + primary_press(side_area.right(), side_area.y), )); assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); } @@ -609,12 +563,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() { 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( + assert!(app.handle_owned_screen_mouse_primary( &mut tui, - MousePrimaryPressEvent { - column: side_area.x, - row: side_conversation_area.bottom(), - }, + primary_press(side_area.x, 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"); @@ -650,12 +601,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() { ("parent draft".to_string(), "side draft".to_string()) ); - assert!(app.handle_owned_screen_mouse_primary_press( + assert!(app.handle_owned_screen_mouse_primary( &mut tui, - MousePrimaryPressEvent { - column: parent_area.x, - row: parent_conversation_area.bottom(), - }, + primary_press(parent_area.x, parent_conversation_area.bottom()), )); let mut parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16); assert_eq!( @@ -664,12 +612,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() { .expect("restored parent cursor"), parent_cursor ); - assert!(app.handle_owned_screen_mouse_primary_press( + assert!(app.handle_owned_screen_mouse_primary( &mut tui, - MousePrimaryPressEvent { - column: side_area.x, - row: side_conversation_area.bottom(), - }, + primary_press(side_area.x, side_conversation_area.bottom()), )); let mut side_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16); assert_eq!( @@ -696,28 +641,113 @@ async fn primary_press_does_not_switch_behind_overlay_or_popup() { 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!( + !app.handle_owned_screen_mouse_primary(&mut tui, primary_press(side_area.x, 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!( + !app.handle_owned_screen_mouse_primary(&mut tui, primary_press(side_area.x, side_area.y),) + ); assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); } +#[tokio::test] +async fn primary_drag_resizes_panes_without_changing_pane_state() { + 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 _initial = render_app(&mut app, /*width*/ 120, /*height*/ 12); + let initial_parent = app + .chat_widget + .by_slot(PaneSlot::Parent) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("parent screen") + .last_pane_area; + let initial_side = app + .chat_widget + .by_slot(PaneSlot::Side) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("side screen") + .last_pane_area; + assert_eq!((initial_parent.width, initial_side.width), (60, 59)); + + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + app.backtrack.primed = true; + assert!(app.handle_owned_screen_mouse_primary( + &mut tui, + primary_press( + initial_parent.right(), + initial_parent.y.saturating_add(/*rhs*/ 2) + ), + )); + assert!(app.chat_widget.owned_screen_split_is_dragging()); + assert!(app.handle_owned_screen_mouse_primary( + &mut tui, + primary_event(MousePrimaryEventKind::Drag, /*column*/ 70, u16::MAX,), + )); + + let active = render_app(&mut app, /*width*/ 120, /*height*/ 12); + let parent = app + .chat_widget + .by_slot(PaneSlot::Parent) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("parent screen") + .last_pane_area; + let side = app + .chat_widget + .by_slot(PaneSlot::Side) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("side screen") + .last_pane_area; + assert_eq!((parent.width, side.width), (70, 49)); + assert_eq!(active.backend().buffer()[(parent.right(), 2)].symbol(), "┃"); + assert!( + active.backend().buffer()[(parent.right(), 2)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + ); + assert_snapshot!( + "owned_screen_resized_parent_wide_dragging", + active.backend() + ); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + assert!(app.backtrack.primed); + 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( + &mut tui, + primary_event(MousePrimaryEventKind::Release, /*column*/ 70, u16::MAX,), + )); + assert!(!app.chat_widget.owned_screen_split_is_dragging()); + let settled = render_app(&mut app, /*width*/ 120, /*height*/ 12); + assert_eq!( + settled.backend().buffer()[(parent.right(), 2)].symbol(), + "│" + ); +} + #[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_resized_parent_wide_dragging.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_resized_parent_wide_dragging.snap new file mode 100644 index 0000000000..45167efc1e --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_resized_parent_wide_dragging.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/owned_screen_tests.rs +expression: active.backend() +--- +" Parent ⌥ + 1 / ⌥ + 2 focus ┃ Side " +"parent 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 d6556573ac..ce21075ed4 100644 --- a/codex-rs/tui/src/cwd_prompt.rs +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -96,7 +96,10 @@ 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::MousePrimaryPress(_) => {} + TuiEvent::Paste(_) + | TuiEvent::MouseScroll(_) + | TuiEvent::MousePrimary(_) + | TuiEvent::FocusLost => {} 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 16365b87ae..ea42cc7368 100644 --- a/codex-rs/tui/src/external_agent_config_migration.rs +++ b/codex-rs/tui/src/external_agent_config_migration.rs @@ -99,7 +99,10 @@ 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::MousePrimaryPress(_) => {} + TuiEvent::Paste(_) + | TuiEvent::MouseScroll(_) + | TuiEvent::MousePrimary(_) + | TuiEvent::FocusLost => {} 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 e585f02d85..0a94860aa7 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -152,7 +152,10 @@ 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::MousePrimaryPress(_) => {} + TuiEvent::Paste(_) + | TuiEvent::MouseScroll(_) + | TuiEvent::MousePrimary(_) + | TuiEvent::FocusLost => {} 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 aed10933f7..f4f3e74518 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::MousePrimaryPress(_) => {} + TuiEvent::MouseScroll(_) | TuiEvent::MousePrimary(_) | TuiEvent::FocusLost => {} 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 0b85a35278..5dcae44cab 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::MousePrimaryPress(_) => {} + TuiEvent::MouseScroll(_) | TuiEvent::MousePrimary(_) | TuiEvent::FocusLost => {} 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 fd87ebe142..99674df9cb 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -166,7 +166,10 @@ async fn run_startup_hooks_review_app( } } } - TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {} + TuiEvent::Paste(_) + | TuiEvent::MouseScroll(_) + | TuiEvent::MousePrimary(_) + | TuiEvent::FocusLost => {} 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 fb40d379a8..959abeabd5 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -519,8 +519,10 @@ 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 primary mouse-button lifecycle event with terminal coordinates. + MousePrimary(MousePrimaryEvent), + /// Notification that the terminal stopped receiving keyboard and mouse input. + FocusLost, /// 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 @@ -538,11 +540,19 @@ pub struct MouseScrollEvent { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct MousePrimaryPressEvent { +pub struct MousePrimaryEvent { + pub kind: MousePrimaryEventKind, pub column: u16, pub row: u16, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MousePrimaryEventKind { + Press, + Drag, + Release, +} + #[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 c87be5fe84..c82be0c570 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -34,7 +34,8 @@ use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::WatchStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; -use super::MousePrimaryPressEvent; +use super::MousePrimaryEvent; +use super::MousePrimaryEventKind; use super::MouseScrollDirection; use super::MouseScrollEvent; use super::TuiEvent; @@ -184,7 +185,7 @@ impl TuiEventStream { /// 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 movement); loop so we keep polling + // Some crossterm events map to None (for example, 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 = { @@ -277,7 +278,22 @@ impl TuiEventStream { row: mouse_event.row, })), MouseEventKind::Down(crossterm::event::MouseButton::Left) => { - Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent { + Some(TuiEvent::MousePrimary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column: mouse_event.column, + row: mouse_event.row, + })) + } + MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { + Some(TuiEvent::MousePrimary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Drag, + column: mouse_event.column, + row: mouse_event.row, + })) + } + MouseEventKind::Up(crossterm::event::MouseButton::Left) => { + Some(TuiEvent::MousePrimary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Release, column: mouse_event.column, row: mouse_event.row, })) @@ -296,7 +312,7 @@ impl TuiEventStream { } Event::FocusLost => { self.terminal_focused.store(false, Ordering::Relaxed); - None + Some(TuiEvent::FocusLost) } } } @@ -433,23 +449,14 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn key_event_skips_unmapped() { + async fn focus_lost_maps_to_event_and_updates_terminal_state() { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); - let mut stream = make_stream(broker, draw_rx, terminal_focused); + let mut stream = make_stream(broker, draw_rx, terminal_focused.clone()); handle.send(Ok(Event::FocusLost)); - handle.send(Ok(Event::Key(KeyEvent::new( - KeyCode::Char('a'), - KeyModifiers::NONE, - )))); - let next = stream.next().await.unwrap(); - match next { - TuiEvent::Key(key) => { - assert_eq!(key, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); - } - other => panic!("expected key event, got {other:?}"), - } + assert!(matches!(stream.next().await, Some(TuiEvent::FocusLost))); + assert!(!terminal_focused.load(Ordering::Relaxed)); } #[tokio::test(flavor = "current_thread")] @@ -507,7 +514,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn primary_mouse_press_maps_to_event() { + async fn primary_mouse_lifecycle_maps_to_events() { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); let mut stream = make_stream(broker, draw_rx, terminal_focused); @@ -517,14 +524,43 @@ mod tests { row: 4, modifiers: KeyModifiers::NONE, }))); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 5, + row: 6, + modifiers: KeyModifiers::NONE, + }))); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 7, + row: 8, + modifiers: KeyModifiers::NONE, + }))); assert!(matches!( stream.next().await, - Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent { + Some(TuiEvent::MousePrimary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, column: 3, row: 4, })) )); + assert!(matches!( + stream.next().await, + Some(TuiEvent::MousePrimary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Drag, + column: 5, + row: 6, + })) + )); + assert!(matches!( + stream.next().await, + Some(TuiEvent::MousePrimary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Release, + column: 7, + row: 8, + })) + )); } #[tokio::test(flavor = "current_thread")] @@ -540,13 +576,25 @@ mod tests { modifiers: KeyModifiers::NONE, }))); handle.send(Ok(Event::Mouse(MouseEvent { - kind: MouseEventKind::Up(MouseButton::Left), + kind: MouseEventKind::Down(MouseButton::Middle), column: 3, row: 4, modifiers: KeyModifiers::NONE, }))); handle.send(Ok(Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), + kind: MouseEventKind::Drag(MouseButton::Right), + column: 3, + row: 4, + modifiers: KeyModifiers::NONE, + }))); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Middle), + column: 3, + row: 4, + modifiers: KeyModifiers::NONE, + }))); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollLeft, column: 4, row: 4, modifiers: KeyModifiers::NONE, diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index ec8e2b23b5..6b1fb55dea 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -58,7 +58,10 @@ 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::MousePrimaryPress(_) => {} + TuiEvent::Paste(_) + | TuiEvent::MouseScroll(_) + | TuiEvent::MousePrimary(_) + | TuiEvent::FocusLost => {} TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area());