From 4aa82bb77d025edc9962e6e87f8fc8cdd3f4bb5b Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 4 Jul 2026 17:13:59 -0300 Subject: [PATCH] feat(tui): render side conversation panes --- codex-rs/tui/src/app/conversation_panes.rs | 15 + codex-rs/tui/src/app/input.rs | 131 ++++++++ codex-rs/tui/src/app/owned_screen.rs | 255 ++++++++++++-- codex-rs/tui/src/app/owned_screen_tests.rs | 317 ++++++++++++++++++ ...sts__owned_screen_narrow_side_focused.snap | 18 + ...wned_screen_wide_split_parent_focused.snap | 20 ++ 6 files changed, 728 insertions(+), 28 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_narrow_side_focused.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_wide_split_parent_focused.snap diff --git a/codex-rs/tui/src/app/conversation_panes.rs b/codex-rs/tui/src/app/conversation_panes.rs index 95ced393e3..2efa81d56f 100644 --- a/codex-rs/tui/src/app/conversation_panes.rs +++ b/codex-rs/tui/src/app/conversation_panes.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use codex_protocol::ThreadId; +use crossterm::event::KeyCode; use tokio::sync::mpsc; use super::InitialHistoryReplayBuffer; @@ -20,8 +21,18 @@ use crate::app_event::PaneSlot; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; +use crate::key_hint; +use crate::key_hint::KeyBinding; use crate::transcript_reflow::TranscriptReflowState; +pub(super) fn parent_pane_shortcut() -> KeyBinding { + key_hint::alt(KeyCode::Char('1')) +} + +pub(super) fn side_pane_shortcut() -> KeyBinding { + key_hint::alt(KeyCode::Char('2')) +} + pub(super) struct ConversationPaneInit { pub(super) chat_widget: ChatWidget, pub(super) file_search: FileSearchManager, @@ -131,6 +142,10 @@ impl ConversationPanes { self.focused } + pub(super) fn has_side(&self) -> bool { + self.side.is_some() + } + pub(super) fn focus(&mut self, slot: PaneSlot) -> bool { if self.by_slot(slot).is_none() { return false; diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 6ba52050f9..c20b9a1544 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -100,6 +100,14 @@ impl App { app_server: &mut AppServerSession, 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; + } + // Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless // enhanced keyboard reporting is available. We only treat those word-motion fallbacks as // agent-switch shortcuts when the composer is empty so we never steal the expected @@ -267,6 +275,32 @@ impl App { }; } + fn handle_conversation_pane_focus_key(&mut self, key_event: KeyEvent) -> bool { + if self.overlay.is_some() + || self.chat_widget.by_slot(PaneSlot::Side).is_none() + || !self.chat_widget.no_modal_or_popup_active() + { + 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; + let target = if conversation_panes::parent_pane_shortcut().is_press(key_event) + || (composer_is_empty + && previous_agent_shortcut_matches(key_event, allow_word_motion_fallback)) + { + PaneSlot::Parent + } else if conversation_panes::side_pane_shortcut().is_press(key_event) + || (composer_is_empty + && next_agent_shortcut_matches(key_event, allow_word_motion_fallback)) + { + PaneSlot::Side + } else { + return false; + }; + self.chat_widget.focus(target) + } + pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool { !self.chat_widget.side_conversation_active() && self.chat_widget.is_normal_backtrack_mode() @@ -299,6 +333,25 @@ impl App { #[cfg(test)] mod tests { use super::super::test_support::make_test_app; + use super::*; + use crate::chatwidget::tests::constructor::make_chatwidget_for_pane; + + async fn install_side_pane(app: &mut App) { + let (chat_widget, _rx) = make_chatwidget_for_pane(PaneSlot::Side).await; + let file_search = FileSearchManager::new( + chat_widget.config_ref().cwd.to_path_buf(), + chat_widget.conversation_event_sender(), + ); + assert!( + app.chat_widget + .install_side(ConversationPaneInit { + chat_widget, + file_search, + owned_screen: None, + }) + .is_ok() + ); + } #[tokio::test] async fn app_keymap_shortcuts_are_disabled_while_keymap_view_is_active() { @@ -310,4 +363,82 @@ mod tests { assert!(!app.app_keymap_shortcuts_available()); } + + #[tokio::test] + async fn canonical_pane_focus_keys_preserve_independent_drafts() { + let mut app = make_test_app().await; + install_side_pane(&mut app).await; + app.chat_widget + .set_composer_text("parent draft".to_string(), Vec::new(), Vec::new()); + + assert!(app.handle_conversation_pane_focus_key(KeyEvent::new( + KeyCode::Char('2'), + KeyModifiers::ALT, + ))); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); + app.chat_widget + .set_composer_text("side draft".to_string(), Vec::new(), Vec::new()); + + assert!(app.handle_conversation_pane_focus_key(KeyEvent::new( + KeyCode::Char('1'), + KeyModifiers::ALT, + ))); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft"); + assert_eq!( + app.chat_widget + .by_slot(PaneSlot::Side) + .expect("side pane") + .composer_text_with_pending(), + "side draft" + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn pane_focus_does_not_steal_word_motion_from_nonempty_draft() { + let mut app = make_test_app().await; + install_side_pane(&mut app).await; + app.chat_widget + .set_composer_text("draft".to_string(), Vec::new(), Vec::new()); + + assert!( + !app.handle_conversation_pane_focus_key(KeyEvent::new( + KeyCode::Right, + KeyModifiers::ALT, + )) + ); + assert!(!app.handle_conversation_pane_focus_key(KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::ALT, + ))); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + } + + #[tokio::test] + async fn empty_composer_allows_arrow_pane_focus_shortcut() { + let mut app = make_test_app().await; + install_side_pane(&mut app).await; + + assert!( + app.handle_conversation_pane_focus_key(KeyEvent::new( + KeyCode::Right, + KeyModifiers::ALT, + )) + ); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); + } + + #[tokio::test] + async fn pane_focus_key_is_available_to_agent_navigation_without_side() { + let mut app = make_test_app().await; + + assert!( + !app.handle_conversation_pane_focus_key(KeyEvent::new( + KeyCode::Right, + KeyModifiers::ALT, + )) + ); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + } } diff --git a/codex-rs/tui/src/app/owned_screen.rs b/codex-rs/tui/src/app/owned_screen.rs index 84d4232d73..92a951fe1b 100644 --- a/codex-rs/tui/src/app/owned_screen.rs +++ b/codex-rs/tui/src/app/owned_screen.rs @@ -10,13 +10,23 @@ use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Position; use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use super::*; use crate::AltScreenBehavior; 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 { viewport: ConversationViewport, replay_in_progress: bool, @@ -28,6 +38,59 @@ 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 { @@ -105,6 +168,135 @@ impl OwnedScreen { self.viewport.handle_mouse_scroll(event.direction); true } + + fn clear_last_conversation_area(&mut self) { + self.last_conversation_area = Rect::default(); + } +} + +fn pane_body_area(area: Rect, show_header: bool) -> Rect { + if !show_header { + return area; + } + let header_height = PANE_HEADER_HEIGHT.min(area.height); + Rect::new( + area.x, + area.y.saturating_add(header_height), + area.width, + area.height.saturating_sub(header_height), + ) +} + +fn render_pane_header(slot: PaneSlot, focused: bool, area: Rect, buffer: &mut Buffer) { + if area.height == 0 { + return; + } + let label = match slot { + PaneSlot::Parent => "Parent", + PaneSlot::Side => "Side", + }; + let line: Line<'static> = if focused { + let parent: Span<'static> = conversation_panes::parent_pane_shortcut().into(); + let side: Span<'static> = conversation_panes::side_pane_shortcut().into(); + vec![ + " ".into(), + label.cyan().bold(), + " ".into(), + format!("{} / {} focus", parent.content, side.content).dim(), + ] + .into() + } else { + vec![" ".into(), label.dim()].into() + }; + Paragraph::new(line).render( + Rect::new( + area.x, + area.y, + area.width, + PANE_HEADER_HEIGHT.min(area.height), + ), + buffer, + ); +} + +fn render_divider(area: Rect, buffer: &mut Buffer) { + if area.width == 0 { + return; + } + for y in area.y..area.bottom() { + buffer[(area.x, y)] + .set_symbol("│") + .set_style(Style::default().dim()); + } +} + +fn render_pane( + panes: &mut ConversationPanes, + slot: PaneSlot, + area: Rect, + show_header: bool, + focused: PaneSlot, + buffer: &mut Buffer, +) -> Option { + render_pane_header(slot, slot == focused, area, buffer); + let body_area = pane_body_area(area, show_header); + let pane = panes.by_slot_mut(slot)?; + pane.chat_widget.update_owned_screen_width(body_area.width); + let screen = pane.owned_screen.as_mut()?; + Some(screen.render(&pane.chat_widget, body_area, buffer)) +} + +fn render_layout( + panes: &mut ConversationPanes, + layout: OwnedScreenLayout, + focused: PaneSlot, + buffer: &mut Buffer, +) -> Option { + Clear.render(layout.area(), buffer); + for slot in [PaneSlot::Parent, PaneSlot::Side] { + if let Some(screen) = panes + .by_slot_mut(slot) + .and_then(|pane| pane.owned_screen.as_mut()) + { + screen.clear_last_conversation_area(); + } + } + + match layout { + OwnedScreenLayout::Single { + slot, + area, + show_header, + } => render_pane(panes, slot, area, show_header, focused, buffer), + OwnedScreenLayout::Split { + parent, + divider, + side, + .. + } => { + let parent_rendered = render_pane( + panes, + PaneSlot::Parent, + parent, + /*show_header*/ true, + focused, + buffer, + ); + let side_rendered = render_pane( + panes, + PaneSlot::Side, + side, + /*show_header*/ true, + focused, + buffer, + ); + render_divider(divider, buffer); + match focused { + PaneSlot::Parent => parent_rendered, + PaneSlot::Side => side_rendered, + } + } + } } impl App { @@ -120,7 +312,9 @@ impl App { } pub(super) fn has_owned_screen(&self) -> bool { - self.chat_widget.owned_screen.is_some() + self.chat_widget + .by_slot(PaneSlot::Parent) + .is_some_and(|pane| pane.owned_screen.is_some()) } pub(super) fn owned_screen_push_cell(&mut self, cell: Arc) { @@ -173,19 +367,21 @@ impl App { tui: &mut tui::Tui, event: MouseScrollEvent, ) -> bool { - if !self.chat_widget.no_modal_or_popup_active() { - return false; + 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() + && pane + .owned_screen + .as_mut() + .is_some_and(|screen| screen.handle_mouse_scroll(event)) + }); + if handled { + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + return true; + } } - let handled = self - .chat_widget - .owned_screen - .as_mut() - .is_some_and(|screen| screen.handle_mouse_scroll(event)); - if handled { - tui.frame_requester() - .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); - } - handled + false } pub(crate) fn sync_owned_screen_cells(&mut self) { @@ -203,34 +399,37 @@ impl App { } pub(super) fn handle_owned_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result { - if self.chat_widget.owned_screen.is_none() { + if !self.has_owned_screen() { return Ok(false); } let size = tui.terminal.size()?; - if size.width != tui.terminal.last_known_screen_size.width { - self.chat_widget.on_terminal_resize(size.width); + let size_changed = size != tui.terminal.last_known_screen_size; + for slot in [PaneSlot::Parent, PaneSlot::Side] { + if let Some(pane) = self.chat_widget.by_slot_mut(slot) { + if size_changed { + pane.chat_widget.refresh_status_line(); + } + pane.transcript_reflow.clear(); + } } - if size != tui.terminal.last_known_screen_size { - self.refresh_status_line(); - } - self.chat_widget.transcript_reflow.clear(); tui.clear_pending_history_lines(); Ok(true) } pub(super) fn render_owned_screen_frame(&mut self, tui: &mut tui::Tui) -> Result> { - self.chat_widget - .update_owned_screen_width(tui.terminal.size()?.width); - let pane = self.chat_widget.selected_mut(); - let Some(screen) = &mut pane.owned_screen else { + if !self.has_owned_screen() { return Ok(None); - }; - let chat_widget = &pane.chat_widget; + } + let focused = self.chat_widget.focused_slot(); + let has_side = self.chat_widget.has_side(); let mut rendered_area = Rect::default(); tui.draw(/*height*/ u16::MAX, |frame| { rendered_area = frame.area(); - let rendered = screen.render(chat_widget, rendered_area, frame.buffer); - if let Some((x, y)) = rendered.cursor { + let layout = OwnedScreenLayout::new(rendered_area, has_side, focused); + if let Some(rendered) = + render_layout(&mut self.chat_widget, layout, focused, frame.buffer) + && let Some((x, y)) = rendered.cursor + { frame.set_cursor_style(rendered.cursor_style); frame.set_cursor_position((x, y)); } diff --git a/codex-rs/tui/src/app/owned_screen_tests.rs b/codex-rs/tui/src/app/owned_screen_tests.rs index 3c3a2df08c..f934dd0e50 100644 --- a/codex-rs/tui/src/app/owned_screen_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_tests.rs @@ -2,14 +2,20 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; +use pretty_assertions::assert_eq; use ratatui::Terminal; use ratatui::backend::TestBackend; +use ratatui::layout::Rect; use ratatui::text::Line; use std::time::Duration; use tokio::sync::broadcast::error::TryRecvError; +use super::super::conversation_panes::ConversationPaneInit; use super::*; +use crate::app_event::PaneSlot; +use crate::chatwidget::tests::constructor::make_chatwidget_for_pane; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; +use crate::file_search::FileSearchManager; use crate::tui::MouseScrollDirection; use crate::tui::MouseScrollEvent; @@ -26,6 +32,317 @@ impl HistoryCell for TestCell { } } +async fn app_with_owned_parent() -> App { + let mut app = super::super::test_support::make_test_app().await; + app.chat_widget.owned_screen = App::owned_screen_for_behavior( + AltScreenBehavior::Owned, + &app.chat_widget, + app.keymap.pager.clone(), + ); + app +} + +async fn app_with_owned_side() -> App { + let mut app = app_with_owned_parent().await; + let (side_widget, _side_rx) = make_chatwidget_for_pane(PaneSlot::Side).await; + let file_search = FileSearchManager::new( + side_widget.config_ref().cwd.to_path_buf(), + side_widget.conversation_event_sender(), + ); + let owned_screen = App::owned_screen_for_behavior( + AltScreenBehavior::Owned, + &side_widget, + app.keymap.pager.clone(), + ); + let result = app.chat_widget.install_side(ConversationPaneInit { + chat_widget: side_widget, + file_search, + owned_screen, + }); + assert!(result.is_ok(), "side pane should install"); + app +} + +fn seed_pane(app: &mut App, slot: PaneSlot, draft: &str, cells: &[&'static str]) { + let pane = app.chat_widget.by_slot_mut(slot).expect("installed pane"); + pane.chat_widget + .set_composer_text(draft.to_string(), Vec::new(), Vec::new()); + let screen = pane.owned_screen.as_mut().expect("owned screen"); + for text in cells { + screen.viewport.push_cell(Arc::new(TestCell(text))); + } +} + +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 mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); + terminal + .draw(|frame| { + let layout = OwnedScreenLayout::new(frame.area(), has_side, focused); + if let Some(rendered) = + render_layout(&mut app.chat_widget, layout, focused, frame.buffer_mut()) + && let Some((x, y)) = rendered.cursor + { + frame.set_cursor_position((x, y)); + } + }) + .expect("render owned panes"); + terminal +} + +fn is_following_bottom(app: &App, slot: PaneSlot) -> bool { + app.chat_widget + .by_slot(slot) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("owned screen") + .viewport + .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); + } +} + +#[tokio::test] +async fn single_pane_app_layout_preserves_existing_owned_render() { + let mut app = app_with_owned_parent().await; + seed_pane( + &mut app, + PaneSlot::Parent, + "draft sentinel", + &["committed response"], + ); + + let terminal = render_app(&mut app, /*width*/ 50, /*height*/ 10); + + assert_snapshot!(terminal.backend(), @r###" +"committed response " +" " +" " +" " +" " +" " +" " +"› draft sentinel " +" " +" gpt-5.5 default · /tmp/project " +"###); +} + +#[tokio::test] +async fn renders_wide_parent_left_and_side_right() { + 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); + + assert_snapshot!("owned_screen_wide_split_parent_focused", terminal.backend()); + let buffer = terminal.backend().buffer(); + assert!( + buffer[(1, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + ); + assert!( + buffer[(43, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::DIM) + ); +} + +#[tokio::test] +async fn narrow_layout_renders_only_the_focused_side() { + let mut app = app_with_owned_side().await; + seed_pane( + &mut app, + PaneSlot::Parent, + "parent draft", + &["PARENT MUST BE HIDDEN"], + ); + seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]); + assert!(app.chat_widget.focus(PaneSlot::Side)); + + let terminal = render_app(&mut app, /*width*/ 82, /*height*/ 14); + + assert_snapshot!("owned_screen_narrow_side_focused", terminal.backend()); + assert_eq!( + app.chat_widget + .by_slot(PaneSlot::Parent) + .and_then(|pane| pane.owned_screen.as_ref()) + .map(|screen| screen.last_conversation_area), + Some(Rect::default()) + ); +} + +#[tokio::test] +async fn terminal_cursor_tracks_only_the_focused_pane() { + let mut app = app_with_owned_side().await; + seed_pane(&mut app, PaneSlot::Parent, "parent", &[]); + seed_pane(&mut app, PaneSlot::Side, "side", &[]); + + let mut terminal = render_app(&mut app, /*width*/ 83, /*height*/ 8); + let parent_cursor = terminal.get_cursor_position().expect("parent cursor"); + assert!(parent_cursor.x < 41); + + assert!(app.chat_widget.focus(PaneSlot::Side)); + terminal = render_app(&mut app, /*width*/ 83, /*height*/ 8); + let side_cursor = terminal.get_cursor_position().expect("side cursor"); + assert!(side_cursor.x > 41); +} + +#[tokio::test] +async fn mouse_wheel_routes_by_pointer_without_changing_focus() { + let mut app = app_with_owned_side().await; + let cells = [ + "one", "two", "three", "four", "five", "six", "seven", "eight", + ]; + seed_pane(&mut app, PaneSlot::Parent, "", &cells); + seed_pane(&mut app, PaneSlot::Side, "", &cells); + let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 8); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + + assert!(app.handle_owned_screen_mouse_scroll( + &mut tui, + MouseScrollEvent { + direction: MouseScrollDirection::Up, + column: 2, + row: 2, + }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + assert!(!is_following_bottom(&app, PaneSlot::Parent)); + assert!(is_following_bottom(&app, PaneSlot::Side)); + + assert!(app.handle_owned_screen_mouse_scroll( + &mut tui, + MouseScrollEvent { + direction: MouseScrollDirection::Up, + column: 44, + row: 2, + }, + )); + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent); + assert!(!is_following_bottom(&app, PaneSlot::Side)); +} + +#[tokio::test] +async fn narrow_side_clears_parent_hit_area_before_wheel_routing() { + let mut app = app_with_owned_side().await; + let cells = ["one", "two", "three", "four", "five", "six"]; + seed_pane(&mut app, PaneSlot::Parent, "", &cells); + seed_pane(&mut app, PaneSlot::Side, "", &cells); + let _wide = render_app(&mut app, /*width*/ 83, /*height*/ 7); + assert!(app.chat_widget.focus(PaneSlot::Side)); + let _narrow = render_app(&mut app, /*width*/ 82, /*height*/ 7); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + let side_area = app + .chat_widget + .by_slot(PaneSlot::Side) + .and_then(|pane| pane.owned_screen.as_ref()) + .expect("side screen") + .last_conversation_area; + assert!(side_area.height > 0); + + assert!(app.handle_owned_screen_mouse_scroll( + &mut tui, + MouseScrollEvent { + direction: MouseScrollDirection::Up, + column: side_area.x, + row: side_area.y, + }, + )); + assert!(is_following_bottom(&app, PaneSlot::Parent)); + assert!(!is_following_bottom(&app, PaneSlot::Side)); +} + +#[tokio::test] +async fn resizing_between_split_and_focused_only_preserves_pane_state() { + let mut app = app_with_owned_side().await; + let cells = [ + "one", "two", "three", "four", "five", "six", "seven", "eight", + ]; + seed_pane(&mut app, PaneSlot::Parent, "parent draft", &cells); + seed_pane(&mut app, PaneSlot::Side, "side draft", &cells); + let _wide = render_app(&mut app, /*width*/ 83, /*height*/ 8); + let parent_screen = app + .chat_widget + .by_slot_mut(PaneSlot::Parent) + .and_then(|pane| pane.owned_screen.as_mut()) + .expect("parent screen"); + assert!(parent_screen.handle_mouse_scroll(MouseScrollEvent { + direction: MouseScrollDirection::Up, + column: 2, + row: 2, + })); + assert!(app.chat_widget.focus(PaneSlot::Side)); + + let _narrow = render_app(&mut app, /*width*/ 82, /*height*/ 8); + let _wide_again = render_app(&mut app, /*width*/ 83, /*height*/ 8); + + assert_eq!( + app.chat_widget + .by_slot(PaneSlot::Parent) + .expect("parent pane") + .composer_text_with_pending(), + "parent draft" + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), "side draft"); + assert!(!is_following_bottom(&app, PaneSlot::Parent)); + assert!(is_following_bottom(&app, PaneSlot::Side)); +} + #[tokio::test] async fn renders_committed_conversation_above_fixed_composer() { let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_narrow_side_focused.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_narrow_side_focused.snap new file mode 100644 index 0000000000..3de5b56a75 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_narrow_side_focused.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/owned_screen_tests.rs +expression: terminal.backend() +--- +" Side ⌥ + 1 / ⌥ + 2 focus " +"side transcript " +" " +"╭───────────────────────────────────────╮ " +"│ >_ OpenAI Codex (v0.0.0) │ " +"│ │ " +"│ model: loading /model to change │ " +"│ directory: /tmp/project │ " +"╰───────────────────────────────────────╯ " +" " +" " +"› side draft " +" " +" gpt-5.5 default · /tmp/project " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_wide_split_parent_focused.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_wide_split_parent_focused.snap new file mode 100644 index 0000000000..316d5afbb9 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_wide_split_parent_focused.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/app/owned_screen_tests.rs +expression: terminal.backend() +--- +" Parent ⌥ + 1 / ⌥ + 2 focus │ Side " +"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 "