Support multiple fork popup overlays

This commit is contained in:
Rakan El Khalil
2026-03-14 22:01:30 -07:00
parent 4910cdea38
commit ffc441bb5d
6 changed files with 564 additions and 155 deletions

View File

@@ -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<Overlay>,
pub(crate) fork_session_overlay: Option<ForkSessionOverlayState>,
pub(crate) fork_session_overlay: Option<ForkSessionOverlayStack>,
pub(crate) deferred_history_lines: Vec<Line<'static>>,
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,

View File

@@ -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<PopupDragState>,
}
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::<HashMap<_, _>>());
@@ -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::<Vec<_>>();
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<String> {
@@ -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);

View File

@@ -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<PopupDragState>,
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,
},
}
);
}
}

View File

@@ -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<PopupDragState>,
}
pub(crate) struct ForkSessionOverlayStack {
popups: Vec<ForkSessionOverlayState>,
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<usize> {
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<ForkSessionOverlayState> {
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);
}
}

View File

@@ -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| {

View File

@@ -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
│ │ │
│ │ │
│ │ │
╰─────────────│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────╯