mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
Support multiple fork popup overlays
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
169
codex-rs/tui/src/app/fork_session_overlay_stack.rs
Normal file
169
codex-rs/tui/src/app/fork_session_overlay_stack.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
╰─────────────│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────╯
|
||||
Reference in New Issue
Block a user