From c8638b9ce83c3089581b86e0436594dc6fbb7831 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Jul 2026 18:37:29 -0300 Subject: [PATCH] feat(tui): allow half-width right rail --- codex-rs/tui/src/app/owned_screen_frame.rs | 15 ++++-- .../tui/src/app/owned_screen_frame_layout.rs | 29 +++++++----- .../tui/src/app/owned_screen_frame_tests.rs | 35 ++++++++++++++ codex-rs/tui/src/app/owned_screen_tests.rs | 47 +++++++++++++++++++ ...wser_renders_in_half_width_right_rail.snap | 16 +++++++ 5 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_renders_in_half_width_right_rail.snap diff --git a/codex-rs/tui/src/app/owned_screen_frame.rs b/codex-rs/tui/src/app/owned_screen_frame.rs index ce996deeb4..a438a880b7 100644 --- a/codex-rs/tui/src/app/owned_screen_frame.rs +++ b/codex-rs/tui/src/app/owned_screen_frame.rs @@ -28,9 +28,12 @@ const SIDEBAR_MIN_WIDTH: u16 = 24; const SIDEBAR_MAX_WIDTH: u16 = 48; const SUMMARY_DEFAULT_WIDTH: u16 = 34; const SUMMARY_MIN_WIDTH: u16 = 30; -const SUMMARY_MAX_WIDTH: u16 = 48; const PANEL_SCROLL_ROWS: u16 = 3; +fn right_rail_max_width(frame_width: u16) -> u16 { + frame_width / 2 +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum OwnedScreenPanelPresentation { Docked, @@ -504,10 +507,12 @@ impl OwnedScreenFrameState { OwnedScreenPanel::Sidebar => column .saturating_sub(area.x) .clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH), - OwnedScreenPanel::Summary => area - .right() - .saturating_sub(column.saturating_add(PANEL_DIVIDER_WIDTH)) - .clamp(SUMMARY_MIN_WIDTH, SUMMARY_MAX_WIDTH), + OwnedScreenPanel::Summary => { + let max_width = right_rail_max_width(area.width); + area.right() + .saturating_sub(column.saturating_add(PANEL_DIVIDER_WIDTH)) + .clamp(SUMMARY_MIN_WIDTH.min(max_width), max_width) + } }; self.panel_state_mut(panel).width = width; } diff --git a/codex-rs/tui/src/app/owned_screen_frame_layout.rs b/codex-rs/tui/src/app/owned_screen_frame_layout.rs index 1dc8a6f055..f6376e7a58 100644 --- a/codex-rs/tui/src/app/owned_screen_frame_layout.rs +++ b/codex-rs/tui/src/app/owned_screen_frame_layout.rs @@ -32,6 +32,12 @@ impl OwnedScreenFrameState { } let hard_center_width = OwnedScreenLayout::minimum_width(has_side); let preferred_center_width = PREFERRED_CENTER_WIDTH.max(hard_center_width); + let sidebar_width = self.sidebar.width; + let summary_width = self.summary.width.min(right_rail_max_width(area.width)); + let panel_width = |panel| match panel { + OwnedScreenPanel::Sidebar => sidebar_width, + OwnedScreenPanel::Summary => summary_width, + }; let mut remaining_width = area.width; let mut sidebar_docked = false; let mut summary_docked = false; @@ -48,31 +54,31 @@ impl OwnedScreenFrameState { if self.preference(panel) != OwnedScreenPanelPreference::Shown { continue; } - let panel_width = self.panel_state(panel).width + PANEL_DIVIDER_WIDTH; - if remaining_width >= hard_center_width.saturating_add(panel_width) { + let required_width = panel_width(panel) + PANEL_DIVIDER_WIDTH; + if remaining_width >= hard_center_width.saturating_add(required_width) { match panel { OwnedScreenPanel::Sidebar => sidebar_docked = true, OwnedScreenPanel::Summary => summary_docked = true, } - remaining_width = remaining_width.saturating_sub(panel_width); + remaining_width = remaining_width.saturating_sub(required_width); } } for panel in [OwnedScreenPanel::Sidebar, OwnedScreenPanel::Summary] { if self.preference(panel) != OwnedScreenPanelPreference::Auto { continue; } - let panel_width = self.panel_state(panel).width + PANEL_DIVIDER_WIDTH; - if remaining_width >= preferred_center_width.saturating_add(panel_width) { + let required_width = panel_width(panel) + PANEL_DIVIDER_WIDTH; + if remaining_width >= preferred_center_width.saturating_add(required_width) { match panel { OwnedScreenPanel::Sidebar => sidebar_docked = true, OwnedScreenPanel::Summary => summary_docked = true, } - remaining_width = remaining_width.saturating_sub(panel_width); + remaining_width = remaining_width.saturating_sub(required_width); } } let mut center = area; let (sidebar, sidebar_divider) = if sidebar_docked { - let panel_area = Rect::new(center.x, center.y, self.sidebar.width, center.height); + let panel_area = Rect::new(center.x, center.y, sidebar_width, center.height); let divider = Rect::new( panel_area.right(), center.y, @@ -82,7 +88,7 @@ impl OwnedScreenFrameState { center.x = divider.right(); center.width = center .width - .saturating_sub(self.sidebar.width + PANEL_DIVIDER_WIDTH); + .saturating_sub(sidebar_width + PANEL_DIVIDER_WIDTH); ( Some(OwnedScreenPanelLayout { area: panel_area, @@ -98,16 +104,15 @@ impl OwnedScreenFrameState { let divider = Rect::new( center .right() - .saturating_sub(self.summary.width + PANEL_DIVIDER_WIDTH), + .saturating_sub(summary_width + PANEL_DIVIDER_WIDTH), center.y, PANEL_DIVIDER_WIDTH, center.height, ); - let panel_area = - Rect::new(divider.right(), center.y, self.summary.width, center.height); + let panel_area = Rect::new(divider.right(), center.y, summary_width, center.height); center.width = center .width - .saturating_sub(self.summary.width + PANEL_DIVIDER_WIDTH); + .saturating_sub(summary_width + PANEL_DIVIDER_WIDTH); ( Some(OwnedScreenPanelLayout { area: panel_area, diff --git a/codex-rs/tui/src/app/owned_screen_frame_tests.rs b/codex-rs/tui/src/app/owned_screen_frame_tests.rs index 830a88d776..c4bb2c1e2f 100644 --- a/codex-rs/tui/src/app/owned_screen_frame_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_frame_tests.rs @@ -227,6 +227,41 @@ fn divider_drag_resizes_only_the_target_panel() { assert_eq!(state.focus(), OwnedScreenFrameFocus::Conversation); } +#[test] +fn right_rail_resize_is_capped_at_half_the_frame_width() { + let mut state = OwnedScreenFrameState::default(); + let frame_area = area(/*width*/ 144); + let initial = state.layout(frame_area, /*has_side*/ false); + let divider = initial.summary_divider.expect("summary divider"); + assert!(state.handle_mouse_primary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column: divider.x, + row: divider.y, + modifiers: KeyModifiers::NONE, + })); + assert!(state.handle_mouse_primary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Drag, + column: frame_area.x, + row: divider.y, + modifiers: KeyModifiers::NONE, + })); + assert!(state.handle_mouse_primary(MousePrimaryEvent { + kind: MousePrimaryEventKind::Release, + column: frame_area.x, + row: divider.y, + modifiers: KeyModifiers::NONE, + })); + + let resized = state.layout(frame_area, /*has_side*/ false); + assert_eq!(resized.summary.expect("summary rail").area.width, 72); + + let narrower = state.layout(area(/*width*/ 120), /*has_side*/ false); + assert_eq!(narrower.summary.expect("summary rail").area.width, 60); + + let restored = state.layout(frame_area, /*has_side*/ false); + assert_eq!(restored.summary.expect("summary rail").area.width, 72); +} + #[test] fn resizing_auto_panel_keeps_it_visible_at_the_auto_breakpoint() { let mut state = OwnedScreenFrameState::default(); diff --git a/codex-rs/tui/src/app/owned_screen_tests.rs b/codex-rs/tui/src/app/owned_screen_tests.rs index af660fcb56..4101ad580f 100644 --- a/codex-rs/tui/src/app/owned_screen_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_tests.rs @@ -363,6 +363,53 @@ async fn owned_screen_browser_renders_in_wide_right_rail() { ); } +#[tokio::test] +async fn owned_screen_browser_renders_in_half_width_right_rail() { + let mut app = app_with_owned_parent().await; + seed_pane( + &mut app, + PaneSlot::Parent, + "draft sentinel", + &["committed response"], + ); + app.owned_screen_frame + .select_right_rail_content(OwnedScreenRightRailContent::Browser); + let frame_area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 144, /*height*/ 12, + ); + let layout = app + .owned_screen_frame + .layout(frame_area, /*has_side*/ false); + let divider = layout.summary_divider.expect("browser divider"); + assert!( + app.owned_screen_frame + .handle_mouse_primary(primary_press(divider.x, divider.y)) + ); + assert!(app.owned_screen_frame.handle_mouse_primary(primary_event( + MousePrimaryEventKind::Drag, + frame_area.x, + divider.y, + ))); + assert!(app.owned_screen_frame.handle_mouse_primary(primary_event( + MousePrimaryEventKind::Release, + frame_area.x, + divider.y, + ))); + let browser = browser_view(/*human_control*/ false, Some((1, 2))); + + let terminal = render_frame_app_with_browser( + &mut app, + frame_area.width, + frame_area.height, + Some(&browser), + ); + + assert_snapshot!( + "owned_screen_browser_renders_in_half_width_right_rail", + terminal.backend() + ); +} + #[tokio::test] async fn owned_screen_browser_preserves_narrow_overlay_chrome() { let mut app = app_with_owned_parent().await; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_renders_in_half_width_right_rail.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_renders_in_half_width_right_rail.snap new file mode 100644 index 0000000000..b7b1e817be --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_renders_in_half_width_right_rail.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/owned_screen_tests.rs +expression: terminal.backend() +--- +"committed response ┃ Summary | Browser " +" ┃ [<] [>] [R] Example running " +" ┃ URL https://example.com " +" ┃Codex browser panel " +" ┃interactive page " +" ┃ready " +" ┃ " +" ┃ " +" ┃ " +"› draft sentinel ┃ " +" ┃ " +" gpt-5.5 default · /tmp/project ┃ Esc hide click page take control /browser close stop "