From 5fab289359a3101bcd585a86137ba41f7adc5993 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 6 Nov 2026 04:39:20 +0000 Subject: [PATCH] Enable resizing the sidebar chooser (#59824) ## Why Sidebar resizing previously required an open side chat, leaving the chooser without the same width controls. ## What changed - Support keyboard resizing, width presets, and divider dragging for the sidebar chooser, with resize hints that fit narrow sidebars. - Keep keyboard resize mode active across startup thread assignment and replacement of the chooser by a new side chat, so `Enter` finishes resizing without submitting the parent draft. - Activate resize bindings only when a split is available, and prevent divider drags from starting while the split is animating. ## Testing Add regression coverage for chooser resizing, width preservation when switching between the chooser and a chat, startup and side-chat creation transitions, draft preservation, and unavailable resize bindings in narrow terminals. GitOrigin-RevId: 511cd4bb5894e6f33f0cf5d7472bb3013361ffe0 --- codex-rs/tui/src/app/input.rs | 10 +- codex-rs/tui/src/app/link_hover.rs | 24 ++-- codex-rs/tui/src/app/owned_transcript.rs | 51 +++++--- codex-rs/tui/src/app/side_focus.rs | 5 +- codex-rs/tui/src/app/side_pane.rs | 146 ++++++++++++++--------- codex-rs/tui/src/app/sidebar.rs | 27 ++++- codex-rs/tui/src/app/tests.rs | 86 ++++++++++++- codex-rs/tui/src/app/tests/key_chords.rs | 121 +++++++++++++++++++ codex-rs/tui/src/app/thread_routing.rs | 12 +- codex-rs/tui/src/app/workspace_hud.rs | 3 +- codex-rs/tui/src/keymap/chords.rs | 11 +- codex-rs/tui/src/sidebar_chooser.rs | 16 ++- 12 files changed, 411 insertions(+), 101 deletions(-) diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index b0a65e7f2f..25bfb8ea03 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -250,12 +250,20 @@ impl App { if self.chooser_focus() == Some(super::sidebar::PaneFocus::Sidebar) && self.sidebar_chooser_available() { - return crate::keymap::KeymapContextSet::focused_sidebar_chooser(); + let contexts = crate::keymap::KeymapContextSet::focused_sidebar_chooser(); + return if self.can_nudge_side_width(tui) { + contexts.with_sidebar_resize().with_focused_side_pane() + } else { + contexts + }; } let mut contexts = contexts.with_pane_directions(); if self.open_pane_focus().is_some() { contexts = contexts.with_open_pane(); } + if self.can_resize_sidebar(tui) { + contexts = contexts.with_sidebar_resize(); + } if self.can_nudge_side_width(tui) { contexts = contexts.with_focused_side_pane(); } diff --git a/codex-rs/tui/src/app/link_hover.rs b/codex-rs/tui/src/app/link_hover.rs index f208c958e2..bdcca24859 100644 --- a/codex-rs/tui/src/app/link_hover.rs +++ b/codex-rs/tui/src/app/link_hover.rs @@ -133,21 +133,23 @@ impl App { .is_some_and(|mouse| self.conversation_frame.hud_contains(mouse)) || !tui.is_owned_screen() || self.overlay.is_some() - || !self.chat_widget.no_modal_or_popup_active() { PointerShape::Default - } else if self.divider_drag.is_some() - || tui.link_hover.mouse.is_some_and(|mouse| { - self.conversation_frame - .side_pane() - .is_some_and(|panes| panes.divider_contains(mouse)) - }) + } else if self.conversation_focus_available(tui) + && (self.divider_drag.is_some() + || tui.link_hover.mouse.is_some_and(|mouse| { + self.conversation_frame + .divider_area() + .is_some_and(|area| area.contains((mouse.column, mouse.row).into())) + && self.divider_is_settled(tui, tui.terminal.last_known_screen_size.into()) + })) { PointerShape::Resize - } else if tui - .link_hover - .mouse - .is_some_and(|mouse| self.transcript_link_at(mouse).is_some()) + } else if self.chat_widget.no_modal_or_popup_active() + && tui + .link_hover + .mouse + .is_some_and(|mouse| self.transcript_link_at(mouse).is_some()) { PointerShape::Link } else { diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 00efb4d67a..ef2c5b71eb 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -34,7 +34,7 @@ use ratatui::widgets::Widget; #[derive(Default)] pub(super) struct ConversationFrame { inset: Option, - sidebar: Option, + pub(super) sidebar: Option, pub(super) chat_strip: Option, pub(super) sidebar_input: Option<(usize, Option, usize)>, pub(super) leader_hud: Option, @@ -70,6 +70,10 @@ impl ConversationFrame { _ => None, } } + + pub(super) fn divider_area(&self) -> Option { + self.sidebar.as_ref()?.divider_area() + } } pub(super) struct PreparedConversation { @@ -267,8 +271,10 @@ impl App { self.conversation_frame.chat_strip = strip; self.conversation_frame.sidebar_input = self.sidebar().map(sidebar::SidebarPane::input_state); + let divider = self.conversation_frame.divider_area(); if self.divider_drag.is_some_and(|drag| { - !drag.matches(screen_size.into(), self.conversation_frame.side_pane()) + divider.is_none() + || !drag.matches(screen_size.into(), self) || !self.conversation_focus_available(tui) }) { self.divider_drag = None; @@ -304,24 +310,31 @@ impl App { } } painted = self.paint_conversation(frame, &prepared); - let divider = match &self.conversation_frame.sidebar { + match &self.conversation_frame.sidebar { Some(sidebar::SidebarLayout::Chat(panes)) => { if let Some(companion) = self.side_companions.get_mut(&panes.passive_thread) { companion.view.image_protocol = self.transcript_view.image_protocol; panes.paint_companion(companion, frame, &frames); } - Some(panes.divider) } - Some(sidebar::SidebarLayout::Chooser { - main, - sidebar, - focus, - }) => { + Some(sidebar::SidebarLayout::Chooser { sidebar, focus, .. }) => { let focused = *focus == PaneFocus::Sidebar; + let resize_hint = self.pane_resize.map(|_| side_pane::RESIZE_HINT); if let Some(creation) = self.sidebar().and_then(|pane| pane.creation.as_ref()) { use sidebar::CreationState; ratatui::widgets::Clear.render(*sidebar, frame.buffer); - let body = sidebar.inner(ratatui::layout::Margin::new(1, 1)); + let mut body = sidebar.inner(ratatui::layout::Margin::new(1, 1)); + if let Some(hint) = resize_hint { + let footer = Rect::new( + body.x, + body.bottom().saturating_sub(1), + body.width, + body.height.min(1), + ); + crate::sidebar_chooser::resize_hint(hint, footer.width) + .render(footer, frame.buffer); + body.height = body.height.saturating_sub(1); + } let text = match &creation.state { CreationState::Waiting { started, .. } => { let mode = MotionMode::from_animations_enabled( @@ -331,7 +344,11 @@ impl App { format!("{glyph} Starting side chat…") } CreationState::Failed { message, .. } => { - format!("{message}\nEnter retry · Esc back") + if resize_hint.is_some() { + message.clone() + } else { + format!("{message}\nEnter retry · Esc back") + } } }; ratatui::widgets::Paragraph::new(text) @@ -345,6 +362,7 @@ impl App { &self.keymap, pane.chat_tabs(), pane.choice, + resize_hint, ); } frame @@ -353,10 +371,9 @@ impl App { if focused { frame.cursor_position = None; } - (!main.is_empty()).then_some(main.right()) } - None => None, - }; + None => {} + } if let Some(strip) = strip && let Some(pane) = self.sidebar() { @@ -368,8 +385,8 @@ impl App { frame.buffer, ); } - if let Some(x) = divider { - side_pane::paint_divider(frame, x, self.divider_drag, highlight_divider); + if let Some(divider) = divider { + side_pane::paint_divider(frame, divider.x, self.divider_drag, highlight_divider); } self.paint_centered_dialog(frame); self.conversation_frame.leader_hud = hud.map(|hud| { @@ -475,7 +492,7 @@ impl App { .then(crate::bottom_pane::ComposerGap::default); let prompt_footer = if self.pane_resize.is_some() { Some(crate::bottom_pane::TranscriptFooter { - text: "Resize · ←/h →/l · Shift+←/→ 10 cols · Enter/Esc done".into(), + text: side_pane::RESIZE_HINT.into(), cursor_column: None, is_interactive: true, }) diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs index b8df8ae8dc..ed63699a0e 100644 --- a/codex-rs/tui/src/app/side_focus.rs +++ b/codex-rs/tui/src/app/side_focus.rs @@ -249,6 +249,7 @@ impl App { } else { 0 }; + let can_resize = self.can_resize_sidebar(tui); let focused_percent = [ (&bindings.equalize_pane_widths, 50), (&bindings.minimize_pane_width, 0), @@ -256,7 +257,7 @@ impl App { ] .into_iter() .find_map(|(binding, percent)| binding.is_pressed(key).then_some(percent)) - .filter(|_| self.open_pane_focus().is_some()); + .filter(|_| can_resize); let zoom_target = if bindings.toggle_pane_zoom.is_pressed(key) { self.open_pane_focus() } else { @@ -265,7 +266,7 @@ impl App { if width_change != 0 && !self.can_nudge_side_width(tui) { return false; } - let resize = bindings.resize_panes.is_pressed(key) && self.open_pane_focus().is_some(); + let resize = bindings.resize_panes.is_pressed(key) && can_resize; let resizing = resize || width_change != 0 || focused_percent.is_some(); if !(tabs || left || right || main || sidebar || hide || zoom_target.is_some() || resizing) || !self.conversation_focus_available(tui) diff --git a/codex-rs/tui/src/app/side_pane.rs b/codex-rs/tui/src/app/side_pane.rs index ab5fa5246b..b4c7a706a7 100644 --- a/codex-rs/tui/src/app/side_pane.rs +++ b/codex-rs/tui/src/app/side_pane.rs @@ -1,9 +1,12 @@ -//! Lay out the two conversations and resize their divider. Only the displayed thread owns a live composer. +//! Lay out paired conversations and resize the shared sidebar split. use super::App; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use super::side_companion::SideCompanion; +use super::sidebar; +use super::sidebar::PaneFocus; use super::sidebar::SidebarContent; +use super::sidebar::SidebarLayout; use crate::bottom_pane::render_unfocused_status; use crate::custom_terminal::Frame; use crate::legacy_core::config::edit::ConfigEdit; @@ -27,18 +30,25 @@ use ratatui::layout::Rect; use ratatui::widgets::StatefulWidgetRef; use std::time::Duration; -const GAP: u16 = 1; +pub(super) const GAP: u16 = 1; +pub(super) const RESIZE_HINT: &str = "Resize · ←/h →/l · Shift+←/→ 10 cols · Enter/Esc done"; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct ResizeTarget { + displayed: Option, + content: SidebarContent, +} #[derive(Clone, Copy)] pub(super) struct PaneResize { - focused: ThreadId, - companion: ThreadId, + // Keyboard resize belongs to the parent split, including while a fork replaces the chooser. + pub(super) parent: Option, preview_width: Option, } #[derive(Clone, Copy)] pub(super) struct DividerDrag { - passive_thread: ThreadId, + target: ResizeTarget, screen: Rect, press: u16, initial_width: u16, @@ -47,8 +57,8 @@ pub(super) struct DividerDrag { } impl DividerDrag { - pub(super) fn matches(&self, screen: Rect, pane: Option<&SidePaneLayout>) -> bool { - screen == self.screen && pane.is_some_and(|pane| pane.passive_thread == self.passive_thread) + pub(super) fn matches(&self, screen: Rect, app: &App) -> bool { + screen == self.screen && app.sidebar_resize_target() == Some(self.target) } fn percent(&self) -> Option { @@ -81,25 +91,35 @@ pub(super) struct SidePaneLayout { pub(super) tab_strip: Rect, } -/// A wide terminal can show a pair once the outgoing conversation has been captured. -pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option { +/// The settled chat split used to check pointer events against the last painted frame. +pub(super) fn layout(app: &App, tui: &Tui, area: Rect) -> Option { + match visible_split(app, tui, area)? { + SidebarLayout::Chat(panes) => Some(panes), + SidebarLayout::Chooser { .. } => None, + } +} + +fn visible_split(app: &App, tui: &Tui, area: Rect) -> Option { if app.zoomed_thread.is_some() && app.zoomed_thread == app.current_displayed_thread_id() { return None; } split_layout(app, tui, area) } -pub(super) fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option { - let SidebarContent::Chat(child) = app.sidebar()?.visible_content()? else { - return None; - }; +pub(super) fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option { + let content = app.sidebar()?.visible_content()?; if area.width < 100 || app.conversation_frame.has_caller_inset() || !app.side_retention_enabled(tui) { return None; } - pair_layout(app, child, sidebar_areas(app, tui, area, /*fraction*/ 1.0)?) + sidebar::content_layout( + app, + content, + app.chooser_focus().unwrap_or(PaneFocus::Main), + sidebar_areas(app, tui, area, /*fraction*/ 1.0)?, + ) } pub(super) fn pair_layout( @@ -140,23 +160,17 @@ pub(super) fn tab_and_body(area: Rect) -> [Rect; 2] { } pub(super) fn sidebar_areas(app: &App, tui: &Tui, area: Rect, fraction: f64) -> Option<[Rect; 2]> { - let active = app.current_displayed_thread_id(); - let passive = app - .active_side_parent_thread_id() - .or_else(|| active.and_then(|id| app.side_thread_for_parent(id))); + let target = app.sidebar_resize_target(); let preview = app .divider_drag .filter(|drag| { - !drag.guide_only - && drag.screen == area - && Some(drag.passive_thread) == passive - && app.conversation_focus_available(tui) + !drag.guide_only && drag.matches(area, app) && app.conversation_focus_available(tui) }) .and_then(|drag| drag.percent()); let saved = app.local_settings.tui.sidebar_width_percent; let keyboard_width = app .pane_resize - .filter(|mode| Some(mode.focused) == active && Some(mode.companion) == passive) + .filter(|mode| mode.parent == app.sidebar_parent()) .and_then(|mode| mode.preview_width); let child_width = match (keyboard_width, preview, app.exact_sidebar_width) { (Some(width), _, _) => sidebar_width(i32::from(width), area.width), @@ -168,7 +182,7 @@ pub(super) fn sidebar_areas(app: &App, tui: &Tui, area: Rect, fraction: f64) -> _ => preferred_width(area.width, preview.or(saved)), }; // A newly focused composer needs a cell even on the first opening frame. - let open = app.sidebar()?.visible_content().is_some(); + let open = target.is_some(); let child_width = ((f64::from(child_width) * fraction).round() as u16).max(u16::from(open)); (child_width > 0).then(|| { Layout::horizontal([Constraint::Fill(1), Constraint::Length(child_width)]) @@ -178,18 +192,22 @@ pub(super) fn sidebar_areas(app: &App, tui: &Tui, area: Rect, fraction: f64) -> } impl App { + fn sidebar_resize_target(&self) -> Option { + Some(ResizeTarget { + displayed: self.current_displayed_thread_id(), + content: self.sidebar()?.visible_content()?, + }) + } + pub(super) fn begin_pane_resize(&mut self, tui: &mut Tui) -> Result<()> { let screen = tui.prepare_draw_size()?.into(); - if let Some(panes) = split_layout(self, tui, screen) - && let Some(focused) = self.current_displayed_thread_id() - { + if split_layout(self, tui, screen).is_some() { self.divider_drag = None; self.passive_link_drag = None; self.pending_right_click_paste = None; self.zoomed_thread = None; self.pane_resize = Some(PaneResize { - focused, - companion: panes.passive_thread, + parent: self.sidebar_parent(), preview_width: None, }); tui.frame_requester().schedule_frame(); @@ -206,11 +224,11 @@ impl App { _ => tui.terminal.last_known_screen_size, } .into(); - let Some(panes) = layout(self, tui, screen).filter(|panes| { + let divider = visible_split(self, tui, screen).and_then(|layout| layout.divider_area()); + let Some(divider) = divider.filter(|_| { !matches!(event, TuiEvent::FocusLost) && self.conversation_focus_available(tui) - && self.current_displayed_thread_id() == Some(mode.focused) - && panes.passive_thread == mode.companion + && self.sidebar_parent() == mode.parent }) else { self.pane_resize = None; tui.frame_requester().schedule_frame(); @@ -222,7 +240,7 @@ impl App { if key.kind == KeyEventKind::Release { return true; } - let current = screen.right() - panes.divider - GAP; + let current = screen.right() - divider.right(); let step = match (key.code, key.modifiers) { (KeyCode::Left | KeyCode::Char('h'), KeyModifiers::NONE) => 1, (KeyCode::Right | KeyCode::Char('l'), KeyModifiers::NONE) => -1, @@ -267,7 +285,7 @@ impl App { if split_layout(self, tui, screen).is_none() { return Ok(()); } - let side_percent = if self.active_side_parent_thread_id().is_some() { + let side_percent = if self.sidebar_has_focus() { focused_percent } else { 100 - focused_percent @@ -286,22 +304,32 @@ impl App { Ok(()) } + pub(super) fn can_resize_sidebar(&self, tui: &Tui) -> bool { + split_layout(self, tui, tui.terminal.last_known_screen_size.into()).is_some() + } + + pub(super) fn divider_is_settled(&self, tui: &Tui, screen: Rect) -> bool { + self.conversation_frame.sidebar.is_some() + && visible_split(self, tui, screen) == self.conversation_frame.sidebar + } + pub(super) fn can_nudge_side_width(&self, tui: &Tui) -> bool { - self.active_side_parent_thread_id().is_some() - && layout(self, tui, tui.terminal.last_known_screen_size.into()).is_some() + self.sidebar_has_focus() + && visible_split(self, tui, tui.terminal.last_known_screen_size.into()).is_some() } pub(super) async fn nudge_side_width(&mut self, tui: &mut Tui, percent: i32) -> Result<()> { - if self.active_side_parent_thread_id().is_some() { + if self.sidebar_has_focus() { let screen: Rect = tui.prepare_draw_size()?.into(); - if let Some(panes) = layout(self, tui, screen) { - let step = (i32::from(screen.width) * percent.abs() + 50) / 100; - let current = panes.focused.width; - let width = - sidebar_width(i32::from(current) + step * percent.signum(), screen.width); - if width != current { - self.save_sidebar_width(width_percent(width, screen.width)) - .await; + if let Some(divider) = + visible_split(self, tui, screen).and_then(|layout| layout.divider_area()) + { + let current = screen.right() - divider.right(); + let total = screen.width; + let step = (i32::from(total) * percent.abs() + 50) / 100; + let next = sidebar_width(i32::from(current) + step * percent.signum(), total); + if next != current { + self.save_sidebar_width(width_percent(next, total)).await; tui.frame_requester().schedule_frame(); } } @@ -349,19 +377,24 @@ impl App { } match mouse.kind { Down(Left) => { - let Some(painted) = self.conversation_frame.side_pane() else { + let Some(divider) = self + .conversation_frame + .divider_area() + .filter(|area| area.contains((mouse.column, mouse.row).into())) + else { return Ok(false); }; - if !painted.divider_contains(*mouse) { - return Ok(false); - } let screen = tui.prepare_draw_size()?.into(); - if layout(self, tui, screen).as_ref() != Some(painted) { - return Ok(false); + if !self.divider_is_settled(tui, screen) { + // The painted divider still owns this press while it moves. + return Ok(true); } - let width = screen.right() - painted.divider - GAP; + let Some(target) = self.sidebar_resize_target() else { + return Ok(false); + }; + let width = screen.right() - divider.right(); self.divider_drag = Some(DividerDrag { - passive_thread: painted.passive_thread, + target, screen, press: mouse.column, initial_width: width, @@ -377,7 +410,7 @@ impl App { return Ok(false); }; let screen = tui.prepare_draw_size()?.into(); - if drag.matches(screen, layout(self, tui, screen).as_ref()) { + if drag.matches(screen, self) && visible_split(self, tui, screen).is_some() { let width = i32::from(drag.initial_width) + i32::from(drag.press) - i32::from(mouse.column); drag.width = sidebar_width(width, screen.width); @@ -396,11 +429,6 @@ impl App { } impl SidePaneLayout { - pub(super) fn divider_contains(&self, mouse: crossterm::event::MouseEvent) -> bool { - (self.divider..self.divider + GAP).contains(&mouse.column) - && (self.focused.y..self.focused.bottom()).contains(&mouse.row) - } - /// Paint the divider, retained transcript and draft without claiming the terminal cursor. pub(super) fn paint_companion( &self, diff --git a/codex-rs/tui/src/app/sidebar.rs b/codex-rs/tui/src/app/sidebar.rs index f5db0c96b9..9e750d5c0d 100644 --- a/codex-rs/tui/src/app/sidebar.rs +++ b/codex-rs/tui/src/app/sidebar.rs @@ -175,7 +175,7 @@ impl App { }) } - fn sidebar_parent(&self) -> Option { + pub(super) fn sidebar_parent(&self) -> Option { self.active_side_parent_thread_id() .or_else(|| self.current_displayed_thread_id()) } @@ -193,8 +193,14 @@ impl App { pane.focus .filter(|_| pane.content == SidebarContent::Chooser) } + + pub(super) fn sidebar_has_focus(&self) -> bool { + self.active_side_parent_thread_id().is_some() + || self.chooser_focus() == Some(PaneFocus::Sidebar) + } } +#[derive(PartialEq)] pub(super) enum SidebarLayout { Chooser { main: Rect, @@ -204,6 +210,16 @@ pub(super) enum SidebarLayout { Chat(SidePaneLayout), } +impl SidebarLayout { + pub(super) fn divider_area(&self) -> Option { + let (x, pane) = match self { + Self::Chooser { main, .. } => (main.right(), *main), + Self::Chat(panes) => (panes.divider, panes.focused.union(panes.tab_strip)), + }; + (!pane.is_empty()).then(|| Rect::new(x, pane.y, side_pane::GAP, pane.height)) + } +} + /// Step motion once per frame. Input and saved width calculations use the settled split. pub(super) fn paint_layout(app: &mut App, tui: &Tui, area: Rect) -> Option { let zoomed = @@ -234,6 +250,15 @@ pub(super) fn paint_layout(app: &mut App, tui: &Tui, area: Rect) -> Option Option { match content { SidebarContent::Chooser => (app.sidebar_chooser_available() && (focus == PaneFocus::Main || app.chat_widget.completion_can_yield_focus())) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 9a2953f5a9..9712da7d93 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3326,7 +3326,7 @@ async fn docked_replay_finishes_while_skills_refresh_is_pending() -> Result<()> #[tokio::test] async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; // Both pane footers should fit the same way on Unix and Windows. std::fs::write( &app.local_settings.user_config_path, @@ -3416,12 +3416,55 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() // Finish in the background, then change the parent's permissions before the first child turn. app.sidebar_mut() .show(sidebar::SidebarContent::Chooser, sidebar::PaneFocus::Main); - while app.side_thread_for_parent(parent_thread_id).is_none() { + let resize_size = Size::new(/*width*/ 120, /*height*/ 12); + let original_terminal = std::mem::replace( + &mut tui.terminal, + crate::tui::Terminal::with_screen_size_and_cursor_position_for_test( + ratatui::backend::CrosstermBackend::new(std::io::stdout()), + resize_size, + Default::default(), + ), + ); + app.chat_widget + .apply_external_edit("unsent parent draft".into()); + side_frame(&mut app, &mut tui, resize_size)?; + app.begin_pane_resize(&mut tui)?; + assert!(app.pane_resize.is_some()); + loop { let event = time::timeout(Duration::from_secs(5), app_event_rx.recv()) .await? .expect("event stream"); + let created = matches!(event, AppEvent::SideCreated { .. }); + boxed_future!(app.handle_event(&mut tui, &mut app_server, event)).await?; + if created { + assert!(app.side_thread_for_parent(parent_thread_id).is_some()); + break; + } + } + // SideCreated has just replaced the chooser. Enter still finishes the resize. + boxed_future!(app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::Enter.into()) + )) + .await?; + assert!(app.pane_resize.is_none()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "unsent parent draft" + ); + while let Ok(event) = app_event_rx.try_recv() { + assert!(!matches!( + event, + AppEvent::CodexOp(AppCommand::UserTurn { .. }) + )); boxed_future!(app.handle_event(&mut tui, &mut app_server, event)).await?; } + assert!( + !std::iter::from_fn(|| op_rx.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. })) + ); + tui.terminal = original_terminal; + app.chat_widget.apply_external_edit(String::new()); let side_thread_id = app.side_thread_for_parent(parent_thread_id).unwrap(); assert_eq!(app.active_thread_id, Some(parent_thread_id)); assert_eq!( @@ -4507,6 +4550,45 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert_eq!(app.current_displayed_thread_id(), Some(sibling)); assert_eq!(app.side_threads.len(), 2); + keys!(prefix, chooser_key, prefix, KeyCode::Char('=').into()); + side_frame(&mut app, &mut tui, even)?; + assert_eq!(app.conversation_frame.chooser_area().unwrap().width, 100); + keys!(prefix, KeyCode::Char('r').into(), KeyCode::Left.into()); + let frame = side_frame(&mut app, &mut tui, even)?; + let (_, hint) = frame + .lines() + .last() + .and_then(|row| row.split_once('│')) + .expect("sidebar footer"); + insta::assert_snapshot!(hint.trim(), @"Resize · ←/h →/l · Shift+←/→ 10 cols · Enter/Esc done"); + keys!(KeyCode::Esc.into()); + side_frame(&mut app, &mut tui, even)?; + let divider = divider_at(&tui, 4); + // An even sidebar width at 200 columns survives the saved percentage round trip. + let target = divider - 11; + let left = MouseButton::Left; + for (kind, x) in [ + (MouseEventKind::Down(left), divider), + (MouseEventKind::Drag(left), target), + (MouseEventKind::Up(left), target), + ] { + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, mouse(kind, x, /*row*/ 4))) + .await?; + } + assert!(side_frame(&mut app, &mut tui, even)?.contains("Open in sidebar")); + assert_eq!(divider_at(&tui, 4), target); + keys!(enter); + side_frame(&mut app, &mut tui, even)?; + assert!(app.conversation_frame.side_pane().is_some()); + assert_eq!(divider_at(&tui, 4), target); + // A focused child has only its tab strip at this height. + let strip = side_pane::split_layout(&app, &tui, Rect::new(0, 0, even.width, 1)).unwrap(); + assert_eq!(strip.divider_area(), Some(Rect::new(target, 0, 1, 1))); + keys!(prefix, chooser_key); + side_frame(&mut app, &mut tui, even)?; + assert!(app.conversation_frame.chooser_area().is_some()); + assert_eq!(divider_at(&tui, 4), target); + app_server.shutdown().await?; Ok(()) } diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 9d4e0054e9..4cf49daa3f 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -1195,6 +1195,41 @@ async fn pane_direction_without_neighbor_preserves_fresh_typing() -> Result<()> Ok(()) } +#[tokio::test] +async fn sidebar_chooser_resize_survives_startup_thread_assignment() -> Result<()> { + let (mut app, mut tui, mut server) = chord_app().await?; + super::enable_side_retention(&mut app)?; + tui.set_owned_screen(/*owned*/ true)?; + app.local_settings.tui.animations = false; + let size = ratatui::layout::Size::new(/*width*/ 165, /*height*/ 12); + tui.terminal = crate::tui::Terminal::with_screen_size_and_cursor_position_for_test( + ratatui::backend::CrosstermBackend::new(std::io::stdout()), + size, + Default::default(), + ); + assert_eq!(app.sidebar_parent(), None); + for stroke in [ + ctrl('x'), + KeyCode::Char('t').into(), + ctrl('x'), + KeyCode::Char('r').into(), + ] { + Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?; + } + app.activate_thread_channel(ThreadId::new()).await; + let frame = super::side_frame(&mut app, &mut tui, size)?; + assert_eq!(app.conversation_frame.chooser_area().unwrap().width, 55); + let (_, footer) = frame.lines().last().unwrap().split_once('│').unwrap(); + insta::assert_snapshot!(footer.trim(), @"←/→ resize · Enter/Esc done"); + assert!( + app.handle_pane_resize(&tui, &TuiEvent::Key(KeyCode::Enter.into())) + .await + ); + assert!(app.pane_resize.is_none()); + server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().await; @@ -1206,9 +1241,18 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> app.chat_widget .handle_thread_session(test_thread_session(parent, app.config.cwd.to_path_buf())); app.chat_widget.local_settings.tui.status_line = Some(vec!["model-with-reasoning".into()]); + app.chat_widget.refresh_status_line(); app.chat_widget.insert_str("parent draft"); let size = ratatui::layout::Size::new(120, 12); + let terminal_at = |size| { + crate::tui::Terminal::with_screen_size_and_cursor_position_for_test( + ratatui::backend::CrosstermBackend::new(std::io::stdout()), + size, + Default::default(), + ) + }; + tui.terminal = terminal_at(size); app.local_settings.tui.animations = true; tokio::time::pause(); let open_chooser = [ctrl('x'), KeyCode::Char('s').into()]; @@ -1221,10 +1265,42 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> tokio::time::advance(std::time::Duration::from_millis(elapsed)).await; super::side_frame(&mut app, &mut tui, size)?; assert_eq!(app.conversation_frame.chooser_area().unwrap().width, width); + if key == KeyCode::Esc && elapsed == 90 && width == 20 { + let divider = app.conversation_frame.divider_area().unwrap(); + for kind in [ + crossterm::event::MouseEventKind::Moved, + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + ] { + let event = TuiEvent::Mouse(crossterm::event::MouseEvent { + kind, + column: divider.x, + row: divider.y, + modifiers: KeyModifiers::NONE, + }); + Box::pin(app.handle_tui_event(&mut tui, &mut server, event)).await?; + assert!(tui.link_hover.shape == crate::tui::PointerShape::Default); + assert!(app.divider_drag.is_none()); + assert_eq!( + app.chooser_focus(), + Some(crate::app::sidebar::PaneFocus::Sidebar) + ); + } + } } if key == KeyCode::Esc { let frame = super::side_frame(&mut app, &mut tui, size)?; insta::assert_snapshot!("sidebar_new_chat", frame); + app.begin_pane_resize(&mut tui)?; + let resizing = super::side_frame(&mut app, &mut tui, size)?; + let footer = resizing.lines().last().unwrap().split_once('│').unwrap().1; + insta::assert_snapshot!(footer.trim(), @"←/→ resize · Enter/Esc done"); + Box::pin(press( + &mut app, + &mut tui, + &mut server, + KeyCode::Enter.into(), + )) + .await?; for stroke in [ ctrl('x'), KeyCode::Char('c').into(), @@ -1240,6 +1316,30 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?; } assert!(super::side_frame(&mut app, &mut tui, size)?.contains("Open in sidebar")); + app.keymap = RuntimeKeymap::for_session( + &toml::from_str("[global]\nresize_panes = ['8','f12 r']")?, + /*workspace*/ true, + ) + .unwrap(); + let narrow = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12); + // Navigation can request a fresh backend sample between keystrokes. + tui.terminal = terminal_at(narrow); + super::side_frame(&mut app, &mut tui, narrow)?; + assert!(app.conversation_frame.chooser_area().is_none()); + let prefix = KeyEvent::from(KeyCode::F(12)); + assert_eq!(app.route_key_chord_event(&mut tui, prefix), Some(prefix)); + for code in [KeyCode::End, KeyCode::Char('8'), KeyCode::End] { + Box::pin(press(&mut app, &mut tui, &mut server, code.into())).await?; + } + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "parent draft!8" + ); + assert!(!app.key_chord_matcher.is_pending()); + app.chat_widget.apply_external_edit("parent draft!".into()); + super::enable_side_retention(&mut app)?; + tui.terminal = terminal_at(size); + super::side_frame(&mut app, &mut tui, size)?; for stroke in open_chooser { Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?; } @@ -1298,6 +1398,27 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> .await?; let narrow = super::side_frame(&mut app, &mut tui, ratatui::layout::Size::new(30, 8))?; insta::assert_snapshot!("sidebar_failed_creation", narrow); + super::side_frame(&mut app, &mut tui, size)?; + app.begin_pane_resize(&mut tui)?; + assert!(app.pane_resize.is_some()); + let resizing = super::side_frame(&mut app, &mut tui, size)?; + let hints: Vec<_> = resizing + .lines() + .filter_map(|row| row.split_once('│')) + .map(|(_, sidebar)| sidebar.trim()) + .filter(|row| !row.is_empty()) + .collect(); + insta::assert_snapshot!(hints.join("\n"), @"The side conversation could not be +prepared. +←/→ resize · Enter/Esc done"); + Box::pin(press( + &mut app, + &mut tui, + &mut server, + KeyCode::Enter.into(), + )) + .await?; + assert!(app.sidebar().unwrap().creation.is_some()); Box::pin(press(&mut app, &mut tui, &mut server, KeyCode::Esc.into())).await?; assert!( super::side_frame(&mut app, &mut tui, ratatui::layout::Size::new(30, 8))? diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index a614c9ce9e..2ef214d059 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -114,8 +114,16 @@ impl App { } else { None }; - if let Some(startup_pane) = self.sidebars.remove(&None) { - self.sidebars.entry(Some(thread_id)).or_insert(startup_pane); + if let Some(startup_pane) = self.sidebars.remove(&None) + && let std::collections::hash_map::Entry::Vacant(entry) = + self.sidebars.entry(Some(thread_id)) + { + entry.insert(startup_pane); + if let Some(mode) = &mut self.pane_resize + && mode.parent.is_none() + { + mode.parent = Some(thread_id); + } } self.active_thread_id = Some(thread_id); self.active_thread_rx = receiver; diff --git a/codex-rs/tui/src/app/workspace_hud.rs b/codex-rs/tui/src/app/workspace_hud.rs index 56da8b6684..af35982665 100644 --- a/codex-rs/tui/src/app/workspace_hud.rs +++ b/codex-rs/tui/src/app/workspace_hud.rs @@ -24,8 +24,7 @@ impl App { tui.frame_requester().schedule_frame_in(wait); return None; } - let side = self.active_side_parent_thread_id().is_some() - || self.chooser_focus() == Some(super::sidebar::PaneFocus::Sidebar); + let side = self.sidebar_has_focus(); let can_switch = self.chooser_focus().is_some() || self.switchable_side_child().is_some(); let can_size = super::side_pane::split_layout(self, tui, area).is_some(); let can_resize = can_size && side && self.zoomed_thread.is_none(); diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 05e54d7fd7..decedcce69 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -60,6 +60,7 @@ const PANE_DIRECTION: u32 = 1 << 24; const OPEN_PANE: u32 = 1 << 25; const FOCUSED_SIDE_PANE: u32 = 1 << 26; const SIDEBAR_CHOOSER: u32 = 1 << 27; +const SIDEBAR_RESIZE: u32 = 1 << 28; const TRANSCRIPT_BROWSING: u32 = 1 << 14; @@ -174,6 +175,10 @@ impl KeymapContextSet { Self(self.0 | OPEN_PANE) } + pub(crate) const fn with_sidebar_resize(self) -> Self { + Self(self.0 | SIDEBAR_RESIZE) + } + pub(crate) const fn with_focused_side_pane(self) -> Self { Self(self.0 | FOCUSED_SIDE_PANE) } @@ -199,11 +204,11 @@ impl KeymapContextSet { let required = match action.action { "focus_pane_left" | "focus_pane_right" => PANE_DIRECTION, "move_divider_left" | "move_divider_right" => FOCUSED_SIDE_PANE, - "toggle_pane_zoom" - | "equalize_pane_widths" + "toggle_pane_zoom" => OPEN_PANE, + "equalize_pane_widths" | "minimize_pane_width" | "maximize_pane_width" - | "resize_panes" => OPEN_PANE, + | "resize_panes" => SIDEBAR_RESIZE, "focus_main_composer" => MAIN_COMPOSER_FOCUS, "focus_sidebar_content" | "focus_sidebar_tabs" => SIDE_PANE | SIDEBAR_CHOOSER, _ => SIDE_PANE, diff --git a/codex-rs/tui/src/sidebar_chooser.rs b/codex-rs/tui/src/sidebar_chooser.rs index 20edc7d357..dd731a5deb 100644 --- a/codex-rs/tui/src/sidebar_chooser.rs +++ b/codex-rs/tui/src/sidebar_chooser.rs @@ -19,6 +19,15 @@ fn body_row(area: Rect, y: u16) -> Rect { Rect::new(area.x + 1, y, area.width.saturating_sub(3), 1) } +pub(crate) fn resize_hint(full: &str, width: u16) -> Line<'_> { + let full = Line::from(full); + if full.width() <= usize::from(width) { + full.dim() + } else { + Line::from("←/→ resize · Enter/Esc done").dim() + } +} + pub(crate) fn footer(keymap: &RuntimeKeymap, area: Rect) -> footer_hint::HintLine { let hide = keymap .primary_hint(KeymapContext::Global, "hide_pane") @@ -84,6 +93,7 @@ pub(crate) fn paint( keymap: &RuntimeKeymap, tabs: TabStrip<'_, ThreadId>, choice: usize, + resize: Option<&str>, ) { Clear.render(area, buf); if area.is_empty() { @@ -143,6 +153,10 @@ pub(crate) fn paint( } } if footer_y > area.y { - footer(keymap, area).line.render(row(footer_y), buf); + let line = match resize { + Some(hint) => resize_hint(hint, row(footer_y).width), + None => footer(keymap, area).line, + }; + line.render(row(footer_y), buf); } }