From ffc441bb5dac9ecc30bf2b95c25d8b5189032aa6 Mon Sep 17 00:00:00 2001 From: Rakan El Khalil Date: Sat, 14 Mar 2026 22:01:30 -0700 Subject: [PATCH] Support multiple fork popup overlays --- codex-rs/tui/src/app.rs | 11 +- codex-rs/tui/src/app/fork_session_overlay.rs | 422 +++++++++++++----- .../tui/src/app/fork_session_overlay_mouse.rs | 85 +++- .../tui/src/app/fork_session_overlay_stack.rs | 169 +++++++ .../app/fork_session_overlay_vt100_tests.rs | 5 +- ..._fork_session_overlay_multiple_popups.snap | 27 ++ 6 files changed, 564 insertions(+), 155 deletions(-) create mode 100644 codex-rs/tui/src/app/fork_session_overlay_stack.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__fork_session_overlay__tests__fork_session_overlay_multiple_popups.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b5dc2af27b..e5f7f5ff96 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -156,6 +156,7 @@ mod app_server_requests; mod loaded_threads; mod fork_session_overlay; mod fork_session_overlay_mouse; +mod fork_session_overlay_stack; mod fork_session_terminal; mod pending_interactive_replay; @@ -164,6 +165,7 @@ use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::fork_session_overlay::ForkSessionOverlayState; +use self::fork_session_overlay_stack::ForkSessionOverlayStack; use self::pending_interactive_replay::PendingInteractiveReplayState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; @@ -956,7 +958,7 @@ pub(crate) struct App { // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, - pub(crate) fork_session_overlay: Option, + pub(crate) fork_session_overlay: Option, pub(crate) deferred_history_lines: Vec>, has_emitted_history_lines: bool, @@ -4110,13 +4112,6 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::ForkCurrentSession => { - if self.fork_session_overlay.is_some() { - self.chat_widget.add_error_message( - "A forked session overlay is already open. Press Ctrl+] then q to return to the original session." - .to_string(), - ); - return Ok(AppRunControl::Continue); - } self.session_telemetry.counter( "codex.thread.fork", /*inc*/ 1, diff --git a/codex-rs/tui/src/app/fork_session_overlay.rs b/codex-rs/tui/src/app/fork_session_overlay.rs index 196e677854..32e1d57d0d 100644 --- a/codex-rs/tui/src/app/fork_session_overlay.rs +++ b/codex-rs/tui/src/app/fork_session_overlay.rs @@ -29,8 +29,10 @@ use crate::vt100_backend::VT100Backend; use crate::vt100_render::render_screen; use super::fork_session_overlay_mouse::OverlayMouseAction; -use super::fork_session_overlay_mouse::PopupDragState; use super::fork_session_overlay_mouse::overlay_mouse_action; +use super::fork_session_overlay_stack::ForkSessionOverlayStack; +use super::fork_session_overlay_stack::ForkSessionOverlayState; +use super::fork_session_overlay_stack::OverlayFocusedPane; use super::fork_session_terminal::ForkSessionTerminal; const DEFAULT_POPUP_WIDTH_NUMERATOR: u16 = 2; @@ -41,9 +43,11 @@ const POPUP_MIN_WIDTH: u16 = 44; const POPUP_MIN_HEIGHT: u16 = 10; const POPUP_HORIZONTAL_MARGIN: u16 = 2; const POPUP_VERTICAL_MARGIN: u16 = 1; +const POPUP_CASCADE_STEP_X: u16 = 4; +const POPUP_CASCADE_STEP_Y: u16 = 2; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -enum OverlayCommandState { +pub(crate) enum OverlayCommandState { #[default] PassThrough, AwaitingPrefix, @@ -51,21 +55,6 @@ enum OverlayCommandState { Resize, } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -enum OverlayFocusedPane { - Background, - #[default] - Popup, -} - -pub(crate) struct ForkSessionOverlayState { - pub(crate) terminal: ForkSessionTerminal, - popup: Rect, - command_state: OverlayCommandState, - focused_pane: OverlayFocusedPane, - drag_state: Option, -} - fn popup_size_bounds(area: Rect) -> Rect { let horizontal_margin = POPUP_HORIZONTAL_MARGIN.min(area.width.saturating_sub(1) / 2); let vertical_margin = POPUP_VERTICAL_MARGIN.min(area.height.saturating_sub(1) / 2); @@ -112,6 +101,13 @@ fn default_popup_rect(area: Rect) -> Rect { ) } +fn stacked_popup_rect(area: Rect, existing_popups: usize) -> Rect { + let popup = default_popup_rect(area); + let dx = i32::from(POPUP_CASCADE_STEP_X) * existing_popups as i32; + let dy = i32::from(POPUP_CASCADE_STEP_Y) * existing_popups as i32; + move_popup_rect(area, popup, dx, dy) +} + fn clamp_popup_rect(area: Rect, popup: Rect) -> Rect { let (min_width, max_width) = popup_width_bounds(area); let (min_height, max_height) = popup_height_bounds(area); @@ -340,7 +336,12 @@ impl App { ) -> Result<()> { tui.clear_pending_history_lines(); let size = tui.terminal.size()?; - let popup = default_popup_rect(Rect::new(0, 0, size.width, size.height)); + let area = Rect::new(0, 0, size.width, size.height); + let existing_popups = self + .fork_session_overlay + .as_ref() + .map_or(0, |stack| stack.popups().len()); + let popup = stacked_popup_rect(area, existing_popups); let terminal_size = popup_terminal_size(popup); let program = std::env::current_exe()?.to_string_lossy().into_owned(); let env = child_overlay_env(std::env::vars().collect::>()); @@ -355,22 +356,31 @@ impl App { ) .await?; - self.fork_session_overlay = Some(ForkSessionOverlayState { + let popup_state = ForkSessionOverlayState { terminal, popup, command_state: OverlayCommandState::PassThrough, - focused_pane: OverlayFocusedPane::Popup, drag_state: None, - }); - tui.set_mouse_capture_enabled(true)?; + }; + if let Some(stack) = self.fork_session_overlay.as_mut() { + stack.push_popup(popup_state); + } else { + self.fork_session_overlay = Some(ForkSessionOverlayStack::new(popup_state)); + tui.set_mouse_capture_enabled(true)?; + } tui.frame_requester().schedule_frame(); Ok(()) } pub(crate) async fn close_fork_session_overlay(&mut self, tui: &mut tui::Tui) -> Result<()> { - self.fork_session_overlay = None; - tui.set_mouse_capture_enabled(false)?; - self.restore_inline_view_after_fork_overlay_close(tui)?; + if let Some(stack) = self.fork_session_overlay.as_mut() { + let _ = stack.close_active_popup(); + if stack.is_empty() { + self.fork_session_overlay = None; + tui.set_mouse_capture_enabled(false)?; + self.restore_inline_view_after_fork_overlay_close(tui)?; + } + } tui.frame_requester().schedule_frame(); Ok(()) } @@ -386,24 +396,31 @@ impl App { let mut forward_key = None; let viewport = tui.terminal.size()?; let area = Rect::new(0, 0, viewport.width, viewport.height); - if let Some(state) = self.fork_session_overlay.as_mut() { + if let Some(stack) = self.fork_session_overlay.as_mut() { let is_ctrl_prefix = matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && matches!(key_event.code, KeyCode::Char(']')) && key_event.modifiers.contains(KeyModifiers::CONTROL); - match state.command_state { + let Some(command_state) = stack.active_popup().map(|popup| popup.command_state) + else { + return Ok(()); + }; + match command_state { OverlayCommandState::PassThrough => { if focus_toggle_shortcut(key_event) { - state.focused_pane = match state.focused_pane { + let focused_pane = match stack.focused_pane() { OverlayFocusedPane::Background => OverlayFocusedPane::Popup, OverlayFocusedPane::Popup => OverlayFocusedPane::Background, }; + stack.set_focused_pane(focused_pane); tui.frame_requester().schedule_frame(); } else if is_ctrl_prefix { - state.command_state = OverlayCommandState::AwaitingPrefix; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = OverlayCommandState::AwaitingPrefix; + } tui.frame_requester().schedule_frame(); } else { - match state.focused_pane { + match stack.focused_pane() { OverlayFocusedPane::Background => { self.handle_key_event(tui, key_event).await; } @@ -420,31 +437,39 @@ impl App { key_event.code, KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down ) { - if let Some((dx, dy)) = move_popup_delta(key_event) { - state.command_state = OverlayCommandState::Move; - state.popup = move_popup_rect(area, state.popup, dx, dy); + if let Some((dx, dy)) = move_popup_delta(key_event) + && let Some(popup) = stack.active_popup_mut() + { + popup.command_state = OverlayCommandState::Move; + popup.popup = move_popup_rect(area, popup.popup, dx, dy); } } else if matches!( key_event.code, KeyCode::Char('=') | KeyCode::Char('+') | KeyCode::Char('-') ) { - state.command_state = OverlayCommandState::Resize; let delta = match key_event.code { KeyCode::Char('=') | KeyCode::Char('+') => 1, KeyCode::Char('-') => -1, _ => unreachable!(), }; - state.popup = resize_all_edges(area, state.popup, delta); + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = OverlayCommandState::Resize; + popup.popup = resize_all_edges(area, popup.popup, delta); + } } else { match key_event.code { KeyCode::Char('m') | KeyCode::Char('M') => { - state.command_state = OverlayCommandState::Move; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = OverlayCommandState::Move; + } } KeyCode::Char('r') | KeyCode::Char('R') => { - state.command_state = OverlayCommandState::Resize; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = OverlayCommandState::Resize; + } } KeyCode::Char('o') | KeyCode::Char('O') => { - state.focused_pane = match state.focused_pane { + let focused_pane = match stack.focused_pane() { OverlayFocusedPane::Background => { OverlayFocusedPane::Popup } @@ -452,7 +477,7 @@ impl App { OverlayFocusedPane::Background } }; - state.command_state = OverlayCommandState::PassThrough; + stack.set_focused_pane(focused_pane); } KeyCode::Char('q') | KeyCode::Char('Q') => { close_overlay = true; @@ -466,19 +491,26 @@ impl App { } KeyCode::Char(']') => { if is_ctrl_prefix { - state.command_state = - OverlayCommandState::PassThrough; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = + OverlayCommandState::PassThrough; + } } else { forward_key = Some(KeyEvent::new( KeyCode::Char(']'), KeyModifiers::CONTROL, )); - state.command_state = - OverlayCommandState::PassThrough; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = + OverlayCommandState::PassThrough; + } } } KeyCode::Esc | KeyCode::Enter => { - state.command_state = OverlayCommandState::PassThrough; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = + OverlayCommandState::PassThrough; + } } _ => { if is_ctrl_prefix { @@ -487,7 +519,10 @@ impl App { KeyModifiers::CONTROL, )); } - state.command_state = OverlayCommandState::PassThrough; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = + OverlayCommandState::PassThrough; + } } } } @@ -498,13 +533,20 @@ impl App { if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { if is_ctrl_prefix { - state.command_state = OverlayCommandState::PassThrough; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = OverlayCommandState::PassThrough; + } } else if let Some((dx, dy)) = move_popup_delta(key_event) { - state.popup = move_popup_rect(area, state.popup, dx, dy); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = move_popup_rect(area, popup.popup, dx, dy); + } } else { match key_event.code { KeyCode::Esc | KeyCode::Enter => { - state.command_state = OverlayCommandState::PassThrough; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = + OverlayCommandState::PassThrough; + } } _ => {} } @@ -516,7 +558,9 @@ impl App { if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { if is_ctrl_prefix { - state.command_state = OverlayCommandState::PassThrough; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = OverlayCommandState::PassThrough; + } } else { match key_event.code { KeyCode::Left => { @@ -528,8 +572,10 @@ impl App { } else { -1 }; - state.popup = - resize_left_edge(area, state.popup, delta); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_left_edge(area, popup.popup, delta); + } } KeyCode::Right => { let delta = if key_event @@ -540,8 +586,10 @@ impl App { } else { 1 }; - state.popup = - resize_right_edge(area, state.popup, delta); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_right_edge(area, popup.popup, delta); + } } KeyCode::Up => { let delta = if key_event @@ -552,7 +600,10 @@ impl App { } else { -1 }; - state.popup = resize_top_edge(area, state.popup, delta); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_top_edge(area, popup.popup, delta); + } } KeyCode::Down => { let delta = if key_event @@ -563,41 +614,75 @@ impl App { } else { 1 }; - state.popup = - resize_bottom_edge(area, state.popup, delta); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_bottom_edge(area, popup.popup, delta); + } } KeyCode::Char('h') => { - state.popup = resize_left_edge(area, state.popup, -1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_left_edge(area, popup.popup, -1); + } } KeyCode::Char('H') => { - state.popup = resize_left_edge(area, state.popup, 1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_left_edge(area, popup.popup, 1); + } } KeyCode::Char('j') => { - state.popup = resize_bottom_edge(area, state.popup, 1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_bottom_edge(area, popup.popup, 1); + } } KeyCode::Char('J') => { - state.popup = resize_bottom_edge(area, state.popup, -1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_bottom_edge(area, popup.popup, -1); + } } KeyCode::Char('k') => { - state.popup = resize_top_edge(area, state.popup, -1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_top_edge(area, popup.popup, -1); + } } KeyCode::Char('K') => { - state.popup = resize_top_edge(area, state.popup, 1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = resize_top_edge(area, popup.popup, 1); + } } KeyCode::Char('l') => { - state.popup = resize_right_edge(area, state.popup, 1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_right_edge(area, popup.popup, 1); + } } KeyCode::Char('L') => { - state.popup = resize_right_edge(area, state.popup, -1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_right_edge(area, popup.popup, -1); + } } KeyCode::Char('=') | KeyCode::Char('+') => { - state.popup = resize_all_edges(area, state.popup, 1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_all_edges(area, popup.popup, 1); + } } KeyCode::Char('-') => { - state.popup = resize_all_edges(area, state.popup, -1); + if let Some(popup) = stack.active_popup_mut() { + popup.popup = + resize_all_edges(area, popup.popup, -1); + } } KeyCode::Esc | KeyCode::Enter => { - state.command_state = OverlayCommandState::PassThrough; + if let Some(popup) = stack.active_popup_mut() { + popup.command_state = + OverlayCommandState::PassThrough; + } } _ => {} } @@ -610,18 +695,21 @@ impl App { if close_overlay { self.close_fork_session_overlay(tui).await?; } else if let Some(key_event) = forward_key - && let Some(state) = self.fork_session_overlay.as_ref() + && let Some(stack) = self.fork_session_overlay.as_ref() + && let Some(popup) = stack.active_popup() { - let _ = state.terminal.handle_key_event(key_event).await; + let _ = popup.terminal.handle_key_event(key_event).await; } } TuiEvent::Paste(pasted) => { - if let Some(state) = self.fork_session_overlay.as_ref() { + if let Some(stack) = self.fork_session_overlay.as_ref() { let pasted = pasted.replace("\r", "\n"); - match state.focused_pane { + match stack.focused_pane() { OverlayFocusedPane::Background => self.chat_widget.handle_paste(pasted), OverlayFocusedPane::Popup => { - let _ = state.terminal.handle_paste(&pasted).await; + if let Some(popup) = stack.active_popup() { + let _ = popup.terminal.handle_paste(&pasted).await; + } } } } @@ -629,40 +717,53 @@ impl App { TuiEvent::Mouse(mouse_event) => { let viewport = tui.terminal.size()?; let area = Rect::new(0, 0, viewport.width, viewport.height); - if let Some(state) = self.fork_session_overlay.as_mut() { - match overlay_mouse_action(area, state.popup, state.drag_state, mouse_event) { + if let Some(stack) = self.fork_session_overlay.as_mut() { + let popup_rects = stack + .popups() + .iter() + .map(|popup| popup.popup) + .collect::>(); + let drag_state = stack.active_popup().and_then(|popup| popup.drag_state); + match overlay_mouse_action(area, &popup_rects, drag_state, mouse_event) { OverlayMouseAction::Ignore => {} OverlayMouseAction::FocusBackground => { - state.focused_pane = OverlayFocusedPane::Background; - state.command_state = OverlayCommandState::PassThrough; - state.drag_state = None; + stack.set_focused_pane(OverlayFocusedPane::Background); tui.frame_requester().schedule_frame(); } - OverlayMouseAction::FocusPopup(drag_state) => { - state.focused_pane = OverlayFocusedPane::Popup; - state.command_state = OverlayCommandState::PassThrough; - state.drag_state = Some(drag_state); + OverlayMouseAction::FocusPopup { + popup_index, + drag_state, + } => { + if let Some(popup) = stack.bring_popup_to_front(popup_index) { + popup.drag_state = Some(drag_state); + } tui.frame_requester().schedule_frame(); } OverlayMouseAction::MovePopup(popup) => { - state.focused_pane = OverlayFocusedPane::Popup; - state.command_state = OverlayCommandState::PassThrough; - state.popup = popup; + if let Some(active_popup) = stack.active_popup_mut() { + active_popup.popup = popup; + } tui.frame_requester().schedule_frame(); } OverlayMouseAction::EndDrag => { - state.drag_state = None; + if let Some(active_popup) = stack.active_popup_mut() { + active_popup.drag_state = None; + } } } } } TuiEvent::Draw => { - if self - .fork_session_overlay - .as_ref() - .is_some_and(|state| state.terminal.exit_code().is_some()) - { - self.close_fork_session_overlay(tui).await?; + let close_all_overlays = if let Some(stack) = self.fork_session_overlay.as_mut() { + let _ = stack.remove_exited_popups(); + stack.is_empty() + } else { + false + }; + if close_all_overlays { + self.fork_session_overlay = None; + tui.set_mouse_capture_enabled(false)?; + self.restore_inline_view_after_fork_overlay_close(tui)?; return Ok(()); } if self.backtrack_render_pending { @@ -670,12 +771,13 @@ impl App { self.render_transcript_once(tui); } self.chat_widget.maybe_post_pending_notification(tui); - let skip_draw_for_background_paste_burst = - self.chat_widget - .handle_paste_burst_tick(tui.frame_requester()) - && self.fork_session_overlay.as_ref().is_some_and(|state| { - state.focused_pane == OverlayFocusedPane::Background - }); + let skip_draw_for_background_paste_burst = self + .chat_widget + .handle_paste_burst_tick(tui.frame_requester()) + && self + .fork_session_overlay + .as_ref() + .is_some_and(super::fork_session_overlay_stack::ForkSessionOverlayStack::has_background_focus); if skip_draw_for_background_paste_burst { return Ok(()); } @@ -749,7 +851,7 @@ impl App { let background_focused = self .fork_session_overlay .as_ref() - .is_some_and(|state| state.focused_pane == OverlayFocusedPane::Background); + .is_some_and(ForkSessionOverlayStack::has_background_focus); let Ok(mut terminal) = crate::custom_terminal::Terminal::with_options(VT100Backend::new( area.width, @@ -789,29 +891,45 @@ impl App { &mut self, frame: &mut Frame<'_>, ) -> Option<(u16, u16)> { - let state = self.fork_session_overlay.as_mut()?; - state.popup = clamp_popup_rect(frame.area(), state.popup); - let popup = state.popup; - Clear.render(popup, frame.buffer); + let stack = self.fork_session_overlay.as_mut()?; + let popup_focused = stack.focused_pane() == OverlayFocusedPane::Popup; + let active_popup_index = stack.active_popup_index()?; + let mut active_cursor = None; - let exit_code = state.terminal.exit_code(); - let block = popup_block(exit_code, state.command_state, state.focused_pane); - let inner = block.inner(popup); - block.render(popup, frame.buffer); + for (popup_index, state) in stack.popups_mut().iter_mut().enumerate() { + state.popup = clamp_popup_rect(frame.area(), state.popup); + let popup = state.popup; + Clear.render(popup, frame.buffer); - if inner.is_empty() { - return None; + let is_active_popup = popup_index == active_popup_index; + let exit_code = state.terminal.exit_code(); + let block = popup_block( + exit_code, + state.command_state, + if is_active_popup && popup_focused { + OverlayFocusedPane::Popup + } else { + OverlayFocusedPane::Background + }, + ); + let inner = block.inner(popup); + block.render(popup, frame.buffer); + + if inner.is_empty() { + continue; + } + + state.terminal.resize(codex_utils_pty::TerminalSize { + rows: inner.height.max(1), + cols: inner.width.max(1), + }); + let cursor = state.terminal.render(inner, frame.buffer); + if is_active_popup && popup_focused { + active_cursor = cursor; + } } - state.terminal.resize(codex_utils_pty::TerminalSize { - rows: inner.height.max(1), - cols: inner.width.max(1), - }); - let cursor = state.terminal.render(inner, frame.buffer); - match state.focused_pane { - OverlayFocusedPane::Background => None, - OverlayFocusedPane::Popup => cursor, - } + active_cursor } fn build_fork_session_overlay_args(&self, thread_id: codex_protocol::ThreadId) -> Vec { @@ -1110,13 +1228,12 @@ mod tests { \r\n\ ready for a fresh turn\r\n", ); - app.fork_session_overlay = Some(ForkSessionOverlayState { + app.fork_session_overlay = Some(ForkSessionOverlayStack::new(ForkSessionOverlayState { terminal: ForkSessionTerminal::for_test(parser, None), popup: default_popup_rect(Rect::new(0, 0, 100, 28)), command_state: OverlayCommandState::PassThrough, - focused_pane: OverlayFocusedPane::Popup, drag_state: None, - }); + })); let area = Rect::new(0, 0, 100, 28); let mut buf = Buffer::empty(area); @@ -1132,6 +1249,61 @@ ready for a fresh turn\r\n", insta::assert_snapshot!("fork_session_overlay_popup", snapshot_buffer(&buf)); } + #[tokio::test] + async fn fork_session_overlay_multiple_popups_snapshot() { + let mut app = make_test_app().await; + app.transcript_cells = vec![Arc::new(crate::history_cell::new_user_prompt( + "background session".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + ))]; + + let mut first_popup = vt100::Parser::new(12, 50, 0); + first_popup.process( + b"\x1b[32mFirst fork session\x1b[0m\r\n\ +\r\n\ +> /tmp/first\r\n", + ); + + let mut second_popup = vt100::Parser::new(12, 50, 0); + second_popup.process( + b"\x1b[32mSecond fork session\x1b[0m\r\n\ +\r\n\ +> /tmp/second\r\n", + ); + + let mut stack = ForkSessionOverlayStack::new(ForkSessionOverlayState { + terminal: ForkSessionTerminal::for_test(first_popup, None), + popup: Rect::new(14, 7, 52, 12), + command_state: OverlayCommandState::PassThrough, + drag_state: None, + }); + stack.push_popup(ForkSessionOverlayState { + terminal: ForkSessionTerminal::for_test(second_popup, None), + popup: Rect::new(28, 11, 52, 12), + command_state: OverlayCommandState::PassThrough, + drag_state: None, + }); + app.fork_session_overlay = Some(stack); + + let area = Rect::new(0, 0, 100, 28); + let mut buf = Buffer::empty(area); + let mut frame = Frame { + cursor_position: None, + viewport_area: area, + buffer: &mut buf, + }; + + app.render_fork_session_background(&mut frame); + let _ = app.render_fork_session_overlay_frame(&mut frame); + + insta::assert_snapshot!( + "fork_session_overlay_multiple_popups", + snapshot_buffer(&buf) + ); + } + #[tokio::test] async fn fork_session_overlay_popup_background_focus_snapshot() { let mut app = make_test_app().await; @@ -1150,13 +1322,14 @@ ready for a fresh turn\r\n", \r\n\ ready for a fresh turn\r\n", ); - app.fork_session_overlay = Some(ForkSessionOverlayState { + let mut stack = ForkSessionOverlayStack::new(ForkSessionOverlayState { terminal: ForkSessionTerminal::for_test(parser, None), popup: default_popup_rect(Rect::new(0, 0, 100, 28)), command_state: OverlayCommandState::PassThrough, - focused_pane: OverlayFocusedPane::Background, drag_state: None, }); + stack.set_focused_pane(OverlayFocusedPane::Background); + app.fork_session_overlay = Some(stack); let area = Rect::new(0, 0, 100, 28); let mut buf = Buffer::empty(area); @@ -1194,13 +1367,14 @@ ready for a fresh turn\r\n", Vec::new(), Vec::new(), ); - app.fork_session_overlay = Some(ForkSessionOverlayState { + let mut stack = ForkSessionOverlayStack::new(ForkSessionOverlayState { terminal: ForkSessionTerminal::for_test(vt100::Parser::new(1, 1, 0), None), popup: default_popup_rect(Rect::new(0, 0, 80, 18)), command_state: OverlayCommandState::PassThrough, - focused_pane: OverlayFocusedPane::Background, drag_state: None, }); + stack.set_focused_pane(OverlayFocusedPane::Background); + app.fork_session_overlay = Some(stack); let area = Rect::new(0, 0, 80, 18); let mut buf = Buffer::empty(area); diff --git a/codex-rs/tui/src/app/fork_session_overlay_mouse.rs b/codex-rs/tui/src/app/fork_session_overlay_mouse.rs index 858ed86356..9cd97017f1 100644 --- a/codex-rs/tui/src/app/fork_session_overlay_mouse.rs +++ b/codex-rs/tui/src/app/fork_session_overlay_mouse.rs @@ -13,30 +13,40 @@ pub(crate) struct PopupDragState { pub(crate) enum OverlayMouseAction { Ignore, FocusBackground, - FocusPopup(PopupDragState), + FocusPopup { + popup_index: usize, + drag_state: PopupDragState, + }, MovePopup(Rect), EndDrag, } pub(crate) fn overlay_mouse_action( area: Rect, - popup: Rect, + popups: &[Rect], drag_state: Option, mouse_event: MouseEvent, ) -> OverlayMouseAction { match mouse_event.kind { MouseEventKind::Down(MouseButton::Left) => { - if popup_contains_position(popup, mouse_event.column, mouse_event.row) { - OverlayMouseAction::FocusPopup(PopupDragState { - column_offset: mouse_event.column.saturating_sub(popup.x), - row_offset: mouse_event.row.saturating_sub(popup.y), - }) + if let Some((popup_index, popup)) = + hit_popup(popups, mouse_event.column, mouse_event.row) + { + OverlayMouseAction::FocusPopup { + popup_index, + drag_state: PopupDragState { + column_offset: mouse_event.column.saturating_sub(popup.x), + row_offset: mouse_event.row.saturating_sub(popup.y), + }, + } } else { OverlayMouseAction::FocusBackground } } MouseEventKind::Drag(MouseButton::Left) => { - if let Some(drag_state) = drag_state { + if let Some(drag_state) = drag_state + && let Some(popup) = popups.last().copied() + { let max_x = area.right().saturating_sub(popup.width); let max_y = area.bottom().saturating_sub(popup.height); let x = mouse_event @@ -68,6 +78,15 @@ fn popup_contains_position(popup: Rect, column: u16, row: u16) -> bool { column >= popup.x && column < popup.right() && row >= popup.y && row < popup.bottom() } +fn hit_popup(popups: &[Rect], column: u16, row: u16) -> Option<(usize, Rect)> { + popups + .iter() + .copied() + .enumerate() + .rev() + .find(|(_, popup)| popup_contains_position(*popup, column, row)) +} + #[cfg(test)] mod tests { use super::*; @@ -75,7 +94,7 @@ mod tests { #[test] fn mouse_down_outside_popup_focuses_background() { - let popup = Rect::new(20, 8, 40, 16); + let popups = [Rect::new(20, 8, 40, 16)]; let area = Rect::new(0, 0, 120, 40); let mouse_event = MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), @@ -85,14 +104,14 @@ mod tests { }; assert_eq!( - overlay_mouse_action(area, popup, None, mouse_event), + overlay_mouse_action(area, &popups, None, mouse_event), OverlayMouseAction::FocusBackground ); } #[test] fn mouse_down_inside_popup_starts_drag() { - let popup = Rect::new(20, 8, 40, 16); + let popups = [Rect::new(20, 8, 40, 16)]; let area = Rect::new(0, 0, 120, 40); let mouse_event = MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), @@ -102,18 +121,21 @@ mod tests { }; assert_eq!( - overlay_mouse_action(area, popup, None, mouse_event), - OverlayMouseAction::FocusPopup(PopupDragState { - column_offset: 7, - row_offset: 2, - }) + overlay_mouse_action(area, &popups, None, mouse_event), + OverlayMouseAction::FocusPopup { + popup_index: 0, + drag_state: PopupDragState { + column_offset: 7, + row_offset: 2, + }, + } ); } #[test] fn mouse_drag_moves_popup_and_clamps_to_viewport() { let area = Rect::new(0, 0, 120, 40); - let popup = Rect::new(20, 8, 40, 16); + let popups = [Rect::new(20, 8, 40, 16)]; let drag_state = PopupDragState { column_offset: 7, row_offset: 2, @@ -126,14 +148,14 @@ mod tests { }; assert_eq!( - overlay_mouse_action(area, popup, Some(drag_state), mouse_event), + overlay_mouse_action(area, &popups, Some(drag_state), mouse_event), OverlayMouseAction::MovePopup(Rect::new(80, 24, 40, 16)) ); } #[test] fn mouse_up_ends_drag() { - let popup = Rect::new(20, 8, 40, 16); + let popups = [Rect::new(20, 8, 40, 16)]; let area = Rect::new(0, 0, 120, 40); let mouse_event = MouseEvent { kind: MouseEventKind::Up(MouseButton::Left), @@ -143,8 +165,31 @@ mod tests { }; assert_eq!( - overlay_mouse_action(area, popup, None, mouse_event), + overlay_mouse_action(area, &popups, None, mouse_event), OverlayMouseAction::EndDrag ); } + + #[test] + fn mouse_down_hits_topmost_popup_first() { + let popups = [Rect::new(20, 8, 40, 16), Rect::new(30, 12, 40, 16)]; + let area = Rect::new(0, 0, 120, 40); + let mouse_event = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 35, + row: 15, + modifiers: crossterm::event::KeyModifiers::NONE, + }; + + assert_eq!( + overlay_mouse_action(area, &popups, None, mouse_event), + OverlayMouseAction::FocusPopup { + popup_index: 1, + drag_state: PopupDragState { + column_offset: 5, + row_offset: 3, + }, + } + ); + } } diff --git a/codex-rs/tui/src/app/fork_session_overlay_stack.rs b/codex-rs/tui/src/app/fork_session_overlay_stack.rs new file mode 100644 index 0000000000..7f746d5af1 --- /dev/null +++ b/codex-rs/tui/src/app/fork_session_overlay_stack.rs @@ -0,0 +1,169 @@ +use ratatui::layout::Rect; + +use super::fork_session_overlay::OverlayCommandState; +use super::fork_session_overlay_mouse::PopupDragState; +use super::fork_session_terminal::ForkSessionTerminal; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum OverlayFocusedPane { + Background, + #[default] + Popup, +} + +pub(crate) struct ForkSessionOverlayState { + pub(crate) terminal: ForkSessionTerminal, + pub(crate) popup: Rect, + pub(crate) command_state: OverlayCommandState, + pub(crate) drag_state: Option, +} + +pub(crate) struct ForkSessionOverlayStack { + popups: Vec, + focused_pane: OverlayFocusedPane, +} + +impl ForkSessionOverlayStack { + pub(crate) fn new(popup: ForkSessionOverlayState) -> Self { + Self { + popups: vec![popup], + focused_pane: OverlayFocusedPane::Popup, + } + } + + pub(crate) fn popups(&self) -> &[ForkSessionOverlayState] { + &self.popups + } + + pub(crate) fn popups_mut(&mut self) -> &mut [ForkSessionOverlayState] { + &mut self.popups + } + + pub(crate) fn active_popup(&self) -> Option<&ForkSessionOverlayState> { + self.popups.last() + } + + pub(crate) fn active_popup_mut(&mut self) -> Option<&mut ForkSessionOverlayState> { + self.popups.last_mut() + } + + pub(crate) fn active_popup_index(&self) -> Option { + self.popups.len().checked_sub(1) + } + + pub(crate) fn focused_pane(&self) -> OverlayFocusedPane { + self.focused_pane + } + + pub(crate) fn has_background_focus(&self) -> bool { + self.focused_pane == OverlayFocusedPane::Background + } + + pub(crate) fn push_popup(&mut self, popup: ForkSessionOverlayState) { + self.clear_active_interaction(); + self.popups.push(popup); + self.focused_pane = OverlayFocusedPane::Popup; + } + + pub(crate) fn set_focused_pane(&mut self, focused_pane: OverlayFocusedPane) { + if focused_pane == OverlayFocusedPane::Popup && self.popups.is_empty() { + return; + } + self.clear_active_interaction(); + self.focused_pane = focused_pane; + } + + pub(crate) fn bring_popup_to_front( + &mut self, + index: usize, + ) -> Option<&mut ForkSessionOverlayState> { + if index >= self.popups.len() { + return None; + } + self.clear_active_interaction(); + if index + 1 != self.popups.len() { + let popup = self.popups.remove(index); + self.popups.push(popup); + } + self.focused_pane = OverlayFocusedPane::Popup; + self.popups.last_mut() + } + + pub(crate) fn close_active_popup(&mut self) -> Option { + self.clear_active_interaction(); + let closed = self.popups.pop(); + if self.popups.is_empty() { + self.focused_pane = OverlayFocusedPane::Background; + } + closed + } + + pub(crate) fn remove_exited_popups(&mut self) -> bool { + let before = self.popups.len(); + self.popups + .retain(|popup| popup.terminal.exit_code().is_none()); + if self.popups.is_empty() { + self.focused_pane = OverlayFocusedPane::Background; + } + self.popups.len() != before + } + + pub(crate) fn is_empty(&self) -> bool { + self.popups.is_empty() + } + + fn clear_active_interaction(&mut self) { + if let Some(active_popup) = self.popups.last_mut() { + active_popup.command_state = OverlayCommandState::PassThrough; + active_popup.drag_state = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::fork_session_overlay::OverlayCommandState; + use pretty_assertions::assert_eq; + + fn popup(x: u16, y: u16) -> ForkSessionOverlayState { + ForkSessionOverlayState { + terminal: ForkSessionTerminal::for_test(vt100::Parser::new(1, 1, 0), None), + popup: Rect::new(x, y, 40, 16), + command_state: OverlayCommandState::PassThrough, + drag_state: None, + } + } + + #[test] + fn bring_popup_to_front_makes_clicked_popup_active() { + let mut stack = ForkSessionOverlayStack::new(popup(10, 10)); + stack.push_popup(popup(20, 20)); + stack.push_popup(popup(30, 30)); + + let active = stack + .bring_popup_to_front(0) + .expect("bring first popup to front"); + + assert_eq!(active.popup, Rect::new(10, 10, 40, 16)); + assert_eq!(stack.active_popup_index(), Some(2)); + assert_eq!(stack.focused_pane(), OverlayFocusedPane::Popup); + } + + #[test] + fn close_active_popup_keeps_stack_alive_until_last_popup() { + let mut stack = ForkSessionOverlayStack::new(popup(10, 10)); + stack.push_popup(popup(20, 20)); + + let closed = stack.close_active_popup().expect("close topmost popup"); + + assert_eq!(closed.popup, Rect::new(20, 20, 40, 16)); + assert_eq!(stack.popups().len(), 1); + assert_eq!(stack.focused_pane(), OverlayFocusedPane::Popup); + + stack.close_active_popup().expect("close final popup"); + + assert!(stack.is_empty()); + assert_eq!(stack.focused_pane(), OverlayFocusedPane::Background); + } +} diff --git a/codex-rs/tui/src/app/fork_session_overlay_vt100_tests.rs b/codex-rs/tui/src/app/fork_session_overlay_vt100_tests.rs index aabae8a7c2..52975da1a7 100644 --- a/codex-rs/tui/src/app/fork_session_overlay_vt100_tests.rs +++ b/codex-rs/tui/src/app/fork_session_overlay_vt100_tests.rs @@ -210,13 +210,12 @@ async fn fork_session_overlay_open_from_inline_viewport_snapshot() { Terminal::with_options(VT100Backend::new(width, height)).expect("create vt100 terminal"); terminal.set_viewport_area(Rect::new(0, height - 7, width, 7)); - app.fork_session_overlay = Some(ForkSessionOverlayState { + app.fork_session_overlay = Some(ForkSessionOverlayStack::new(ForkSessionOverlayState { terminal: ForkSessionTerminal::for_test(child, None), popup: default_popup_rect(Rect::new(0, 0, width, height)), command_state: OverlayCommandState::PassThrough, - focused_pane: OverlayFocusedPane::Popup, drag_state: None, - }); + })); let mut pending_history_lines = Vec::new(); draw_vt100_terminal_frame(&mut terminal, &mut pending_history_lines, height, |frame| { diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__fork_session_overlay__tests__fork_session_overlay_multiple_popups.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__fork_session_overlay__tests__fork_session_overlay_multiple_popups.snap new file mode 100644 index 0000000000..f490adae19 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__fork_session_overlay__tests__fork_session_overlay_multiple_popups.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/app/fork_session_overlay.rs +expression: snapshot_buffer(&buf) +--- +╭─────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: gpt-5.3-codex /model to change │ +│ directory: ~/code/codex/codex-rs/tui │ +╰─────────────────────────────────────────────╯ + + ╭ fork session running ctrl+] prefix───────────────╮ +› background s│First fork session │ + │ │ + │> /tmp/first │ + │ ╭ fork session running ctrl+] prefix───────────────╮ +› Ask Codex to│ │Second fork session │ + │ │ │ + ? for shortc│ │> /tmp/second │ 100% context left + │ │ │ + │ │ │ + │ │ │ + ╰─────────────│ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────╯