Add PTY-backed fork session popup overlay

This commit is contained in:
Rakan El Khalil
2026-03-14 00:02:43 -07:00
parent 5d64e58a38
commit 18f0e36fe1
12 changed files with 2052 additions and 39 deletions

View File

@@ -50,6 +50,7 @@ codex-utils-cli = { workspace = true }
codex-utils-elapsed = { workspace = true }
codex-utils-fuzzy-match = { workspace = true }
codex-utils-oss = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-sandbox-summary = { workspace = true }
codex-utils-sleep-inhibitor = { workspace = true }
codex-utils-string = { workspace = true }
@@ -105,6 +106,7 @@ unicode-width = { workspace = true }
url = { workspace = true }
webbrowser = { workspace = true }
uuid = { workspace = true }
vt100 = { workspace = true }
codex-windows-sandbox = { workspace = true }
tokio-util = { workspace = true, features = ["time"] }
@@ -134,12 +136,10 @@ codex-cli = { workspace = true }
codex-core = { workspace = true }
codex-mcp = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
codex-utils-pty = { workspace = true }
assert_matches = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
insta = { workspace = true }
pretty_assertions = { workspace = true }
rand = { workspace = true }
serial_test = { workspace = true }
vt100 = { workspace = true }
uuid = { workspace = true }

View File

@@ -154,12 +154,15 @@ mod agent_navigation;
mod app_server_adapter;
mod app_server_requests;
mod loaded_threads;
mod fork_session_overlay;
mod fork_session_terminal;
mod pending_interactive_replay;
use self::agent_navigation::AgentNavigationDirection;
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::pending_interactive_replay::PendingInteractiveReplayState;
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
@@ -952,6 +955,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) deferred_history_lines: Vec<Line<'static>>,
has_emitted_history_lines: bool,
@@ -1504,6 +1508,7 @@ impl App {
fn reset_app_ui_state_after_clear(&mut self) {
self.overlay = None;
self.fork_session_overlay = None;
self.transcript_cells.clear();
self.deferred_history_lines.clear();
self.has_emitted_history_lines = false;
@@ -3172,6 +3177,7 @@ impl App {
fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
self.overlay = None;
self.fork_session_overlay = None;
self.transcript_cells.clear();
self.deferred_history_lines.clear();
self.has_emitted_history_lines = false;
@@ -3193,6 +3199,7 @@ impl App {
self.primary_session_configured = None;
self.pending_primary_events.clear();
self.pending_app_server_requests.clear();
self.fork_session_overlay = None;
self.chat_widget.set_pending_thread_approvals(Vec::new());
self.sync_active_agent_label();
}
@@ -3701,6 +3708,7 @@ impl App {
enhanced_keys_supported,
transcript_cells: Vec::new(),
overlay: None,
fork_session_overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
@@ -3891,6 +3899,9 @@ impl App {
if self.overlay.is_some() {
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
} else if self.fork_session_overlay.is_some() {
self.handle_fork_session_overlay_tui_event(tui, event)
.await?;
} else {
match event {
TuiEvent::Key(key_event) => {
@@ -3946,17 +3957,38 @@ impl App {
) -> Result<AppRunControl> {
match event {
AppEvent::NewSession => {
if self.fork_session_overlay.is_some() {
self.chat_widget.add_error_message(
"Press Ctrl+] then q to close the forked session overlay before starting a new session."
.to_string(),
);
return Ok(AppRunControl::Continue);
}
self.start_fresh_session_with_summary_hint(tui, app_server)
.await;
}
AppEvent::ClearUi => {
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
if self.fork_session_overlay.is_some() {
self.chat_widget.add_error_message(
"Press Ctrl+] then q to close the forked session overlay before clearing the UI."
.to_string(),
);
return Ok(AppRunControl::Continue);
}
self.clear_terminal_ui(tui, false)?;
self.reset_app_ui_state_after_clear();
self.start_fresh_session_with_summary_hint(tui, app_server)
.await;
}
AppEvent::OpenResumePicker => {
if self.fork_session_overlay.is_some() {
self.chat_widget.add_error_message(
"Press Ctrl+] then q to close the forked session overlay before resuming another session."
.to_string(),
);
return Ok(AppRunControl::Continue);
}
let picker_app_server = match crate::start_app_server_for_picker(
&self.config,
&match self.remote_app_server_url.clone() {
@@ -4081,53 +4113,43 @@ 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,
&[("source", "slash_command")],
);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
if let Some(thread_id) = self.chat_widget.thread_id() {
if let Some(path) = self.chat_widget.rollout_path() {
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
match app_server.fork_thread(self.config.clone(), thread_id).await {
Ok(forked) => {
self.shutdown_current_thread(app_server).await;
match self
.replace_chat_widget_with_app_server_thread(tui, app_server, forked)
.await
{
Ok(()) => {
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
// Fresh threads expose a precomputed path, but the file is
// materialized lazily on first user message.
if path.exists() {
match crate::resolve_session_thread_id(path.as_path(), None).await {
Some(thread_id) => {
if let Err(err) =
self.open_fork_session_overlay(tui, thread_id).await
{
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to attach to forked app-server thread: {err}"
"Failed to open forked session overlay from {path_display}: {err}"
));
}
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to fork current session through the app server: {err}"
));
None => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to read session metadata from {path_display}."
));
}
}
}
} else {
@@ -5193,9 +5215,23 @@ impl App {
self.chat_widget.open_approvals_popup();
}
AppEvent::OpenAgentPicker => {
if self.fork_session_overlay.is_some() {
self.chat_widget.add_error_message(
"Press Ctrl+] then q to close the forked session overlay before switching threads."
.to_string(),
);
return Ok(AppRunControl::Continue);
}
self.open_agent_picker(app_server).await;
}
AppEvent::SelectAgentThread(thread_id) => {
if self.fork_session_overlay.is_some() {
self.chat_widget.add_error_message(
"Press Ctrl+] then q to close the forked session overlay before switching threads."
.to_string(),
);
return Ok(AppRunControl::Continue);
}
self.select_agent_thread(tui, app_server, thread_id).await?;
}
AppEvent::OpenSkillsList => {
@@ -5778,6 +5814,7 @@ impl App {
let allow_agent_word_motion_fallback = !self.enhanced_keys_supported
&& self.chat_widget.composer_text_with_pending().is_empty();
if self.overlay.is_none()
&& self.fork_session_overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
// Alt+Left/Right are also natural word-motion keys in the composer. Keep agent
// fast-switch available only once the draft is empty so editing behavior wins whenever
@@ -5794,6 +5831,7 @@ impl App {
return;
}
if self.overlay.is_none()
&& self.fork_session_overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
// Mirror the previous-agent rule above: empty drafts may use these keys for thread
// switching, but non-empty drafts keep them for expected word-wise cursor motion.
@@ -9007,6 +9045,7 @@ guardian_approval = true
file_search,
transcript_cells: Vec::new(),
overlay: None,
fork_session_overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
enhanced_keys_supported: false,
@@ -9061,6 +9100,7 @@ guardian_approval = true
file_search,
transcript_cells: Vec::new(),
overlay: None,
fork_session_overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
enhanced_keys_supported: false,

View File

@@ -0,0 +1,989 @@
use std::collections::HashMap;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Clear;
use ratatui::widgets::Widget;
use crate::app::App;
use crate::chatwidget::ExternalEditorState;
use crate::custom_terminal::Frame;
use crate::insert_history::insert_history_lines;
use crate::render::renderable::Renderable;
use crate::terminal_palette::PARENT_BG_RGB_ENV_VAR;
use crate::tui;
use crate::tui::TuiEvent;
use crate::vt100_backend::VT100Backend;
use crate::vt100_render::render_screen;
use super::fork_session_terminal::ForkSessionTerminal;
const DEFAULT_POPUP_WIDTH_NUMERATOR: u16 = 2;
const DEFAULT_POPUP_WIDTH_DENOMINATOR: u16 = 3;
const DEFAULT_POPUP_HEIGHT_NUMERATOR: u16 = 3;
const DEFAULT_POPUP_HEIGHT_DENOMINATOR: u16 = 5;
const POPUP_MIN_WIDTH: u16 = 44;
const POPUP_MIN_HEIGHT: u16 = 10;
const POPUP_HORIZONTAL_MARGIN: u16 = 2;
const POPUP_VERTICAL_MARGIN: u16 = 1;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum OverlayCommandState {
#[default]
PassThrough,
AwaitingPrefix,
Move,
Resize,
}
pub(crate) struct ForkSessionOverlayState {
pub(crate) terminal: ForkSessionTerminal,
popup: Rect,
command_state: OverlayCommandState,
}
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);
Rect::new(
area.x + horizontal_margin,
area.y + vertical_margin,
area.width.saturating_sub(horizontal_margin * 2).max(1),
area.height.saturating_sub(vertical_margin * 2).max(1),
)
}
fn popup_width_bounds(area: Rect) -> (u16, u16) {
let bounds = popup_size_bounds(area);
let min_width = POPUP_MIN_WIDTH.min(bounds.width).max(1);
let max_width = bounds.width.max(min_width);
(min_width, max_width)
}
fn popup_height_bounds(area: Rect) -> (u16, u16) {
let bounds = popup_size_bounds(area);
let min_height = POPUP_MIN_HEIGHT.min(bounds.height).max(1);
let max_height = bounds.height.max(min_height);
(min_height, max_height)
}
fn default_popup_rect(area: Rect) -> Rect {
let bounds = popup_size_bounds(area);
let width = bounds
.width
.saturating_mul(DEFAULT_POPUP_WIDTH_NUMERATOR)
/ DEFAULT_POPUP_WIDTH_DENOMINATOR;
let width = width.min(bounds.width).max(POPUP_MIN_WIDTH.min(bounds.width).max(1));
let height = bounds
.height
.saturating_mul(DEFAULT_POPUP_HEIGHT_NUMERATOR)
/ DEFAULT_POPUP_HEIGHT_DENOMINATOR;
let height = height
.min(bounds.height)
.max(POPUP_MIN_HEIGHT.min(bounds.height).max(1));
Rect::new(
bounds.x + bounds.width.saturating_sub(width) / 2,
bounds.y + bounds.height.saturating_sub(height) / 2,
width,
height,
)
}
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);
let width = popup.width.clamp(min_width, max_width);
let height = popup.height.clamp(min_height, max_height);
let max_x = area.right().saturating_sub(width);
let max_y = area.bottom().saturating_sub(height);
let x = popup.x.clamp(area.x, max_x);
let y = popup.y.clamp(area.y, max_y);
Rect::new(x, y, width, height)
}
fn move_popup_rect(area: Rect, popup: Rect, dx: i32, dy: i32) -> Rect {
let popup = clamp_popup_rect(area, popup);
let max_x = i32::from(area.right().saturating_sub(popup.width));
let max_y = i32::from(area.bottom().saturating_sub(popup.height));
let next_x = (i32::from(popup.x) + dx).clamp(i32::from(area.x), max_x);
let next_y = (i32::from(popup.y) + dy).clamp(i32::from(area.y), max_y);
Rect::new(next_x as u16, next_y as u16, popup.width, popup.height)
}
fn move_popup_delta(key_event: KeyEvent) -> Option<(i32, i32)> {
let step = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
5
} else {
1
};
match key_event.code {
KeyCode::Left | KeyCode::Char('h') => Some((-step, 0)),
KeyCode::Right | KeyCode::Char('l') => Some((step, 0)),
KeyCode::Up | KeyCode::Char('k') => Some((0, -step)),
KeyCode::Down | KeyCode::Char('j') => Some((0, step)),
_ => None,
}
}
fn resize_left_edge(area: Rect, popup: Rect, delta: i32) -> Rect {
let popup = clamp_popup_rect(area, popup);
let (min_width, max_width) = popup_width_bounds(area);
let right = i32::from(popup.right());
let min_left = (right - i32::from(max_width)).max(i32::from(area.x));
let max_left = right - i32::from(min_width);
let next_left = (i32::from(popup.x) + delta).clamp(min_left, max_left);
Rect::new(next_left as u16, popup.y, (right - next_left) as u16, popup.height)
}
fn resize_right_edge(area: Rect, popup: Rect, delta: i32) -> Rect {
let popup = clamp_popup_rect(area, popup);
let (min_width, max_width) = popup_width_bounds(area);
let left = i32::from(popup.x);
let min_right = left + i32::from(min_width);
let max_right = (left + i32::from(max_width)).min(i32::from(area.right()));
let next_right = (i32::from(popup.right()) + delta).clamp(min_right, max_right);
Rect::new(popup.x, popup.y, (next_right - left) as u16, popup.height)
}
fn resize_top_edge(area: Rect, popup: Rect, delta: i32) -> Rect {
let popup = clamp_popup_rect(area, popup);
let (min_height, max_height) = popup_height_bounds(area);
let bottom = i32::from(popup.bottom());
let min_top = (bottom - i32::from(max_height)).max(i32::from(area.y));
let max_top = bottom - i32::from(min_height);
let next_top = (i32::from(popup.y) + delta).clamp(min_top, max_top);
Rect::new(popup.x, next_top as u16, popup.width, (bottom - next_top) as u16)
}
fn resize_bottom_edge(area: Rect, popup: Rect, delta: i32) -> Rect {
let popup = clamp_popup_rect(area, popup);
let (min_height, max_height) = popup_height_bounds(area);
let top = i32::from(popup.y);
let min_bottom = top + i32::from(min_height);
let max_bottom = (top + i32::from(max_height)).min(i32::from(area.bottom()));
let next_bottom = (i32::from(popup.bottom()) + delta).clamp(min_bottom, max_bottom);
Rect::new(popup.x, popup.y, popup.width, (next_bottom - top) as u16)
}
fn resize_all_edges(area: Rect, popup: Rect, delta: i32) -> Rect {
let popup = resize_left_edge(area, popup, -delta);
let popup = resize_right_edge(area, popup, delta);
let popup = resize_top_edge(area, popup, -delta);
resize_bottom_edge(area, popup, delta)
}
fn popup_hint(command_state: OverlayCommandState) -> Vec<Span<'static>> {
match command_state {
OverlayCommandState::PassThrough => vec!["ctrl+] prefix".dim()],
OverlayCommandState::AwaitingPrefix => {
vec![
"m move".yellow(),
" ".into(),
"r resize".yellow(),
" ".into(),
"q close".yellow(),
" ".into(),
"] send ^]".dim(),
]
}
OverlayCommandState::Move => {
vec![
"move".yellow().bold(),
" ".into(),
"hjkl/arrows".dim(),
" ".into(),
"shift faster".dim(),
" ".into(),
"enter done".dim(),
]
}
OverlayCommandState::Resize => {
vec![
"resize".yellow().bold(),
" ".into(),
"hjkl HJKL +/-".dim(),
" ".into(),
"arrows too".dim(),
" ".into(),
"enter done".dim(),
]
}
}
}
fn popup_block(exit_code: Option<i32>, command_state: OverlayCommandState) -> Block<'static> {
let status = match exit_code {
Some(code) => format!("exited {code}").red().bold(),
None => "running".green().bold(),
};
let mut title = vec![
" fork session ".bold().cyan(),
" ".into(),
status,
" ".into(),
];
title.extend(popup_hint(command_state));
let title = Line::from(title);
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
.title(title)
}
fn popup_terminal_size(popup: Rect) -> codex_utils_pty::TerminalSize {
let inner = popup_block(None, OverlayCommandState::PassThrough).inner(popup);
codex_utils_pty::TerminalSize {
rows: inner.height.max(1),
cols: inner.width.max(1),
}
}
fn append_config_override(args: &mut Vec<String>, key: &str, value: impl std::fmt::Display) {
args.push("-c".to_string());
args.push(format!("{key}={value}"));
}
fn parent_bg_rgb_env_value(bg: (u8, u8, u8)) -> String {
let (red, green, blue) = bg;
format!("{red},{green},{blue}")
}
fn child_overlay_env(mut env: HashMap<String, String>) -> HashMap<String, String> {
for key in [
"TMUX",
"TMUX_PANE",
"ZELLIJ",
"ZELLIJ_SESSION_NAME",
"ZELLIJ_VERSION",
] {
env.remove(key);
}
if let Some(bg) = crate::terminal_palette::default_bg() {
env.insert(PARENT_BG_RGB_ENV_VAR.to_string(), parent_bg_rgb_env_value(bg));
}
env
}
fn sandbox_mode_override(policy: &codex_protocol::protocol::SandboxPolicy) -> &'static str {
match policy {
codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } => "read-only",
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
codex_protocol::protocol::SandboxPolicy::DangerFullAccess
| codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } => "danger-full-access",
}
}
impl App {
pub(crate) async fn open_fork_session_overlay(
&mut self,
tui: &mut tui::Tui,
thread_id: codex_protocol::ThreadId,
) -> Result<()> {
let size = tui.terminal.size()?;
let popup = default_popup_rect(Rect::new(0, 0, size.width, size.height));
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<_, _>>());
let args = self.build_fork_session_overlay_args(thread_id);
let terminal = ForkSessionTerminal::spawn(
&program,
&args,
&self.config.cwd,
env,
terminal_size,
tui.frame_requester(),
)
.await?;
self.fork_session_overlay = Some(ForkSessionOverlayState {
terminal,
popup,
command_state: OverlayCommandState::PassThrough,
});
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;
self.restore_inline_view_after_fork_overlay_close(tui)?;
tui.frame_requester().schedule_frame();
Ok(())
}
pub(crate) async fn handle_fork_session_overlay_tui_event(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<()> {
match event {
TuiEvent::Key(key_event) => {
let mut close_overlay = false;
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() {
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 {
OverlayCommandState::PassThrough => {
if is_ctrl_prefix {
state.command_state = OverlayCommandState::AwaitingPrefix;
tui.frame_requester().schedule_frame();
} else {
forward_key = Some(key_event);
}
}
OverlayCommandState::AwaitingPrefix => {
if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
if matches!(
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);
}
} 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);
} else {
match key_event.code {
KeyCode::Char('m') | KeyCode::Char('M') => {
state.command_state = OverlayCommandState::Move;
}
KeyCode::Char('r') | KeyCode::Char('R') => {
state.command_state = OverlayCommandState::Resize;
}
KeyCode::Char('q') | KeyCode::Char('Q') => {
close_overlay = true;
}
KeyCode::Char('d')
if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
{
close_overlay = true;
}
KeyCode::Char(']') => {
if is_ctrl_prefix {
state.command_state = OverlayCommandState::PassThrough;
} else {
forward_key = Some(KeyEvent::new(
KeyCode::Char(']'),
KeyModifiers::CONTROL,
));
state.command_state = OverlayCommandState::PassThrough;
}
}
KeyCode::Esc | KeyCode::Enter => {
state.command_state = OverlayCommandState::PassThrough;
}
_ => {
if is_ctrl_prefix {
forward_key = Some(KeyEvent::new(
KeyCode::Char(']'),
KeyModifiers::CONTROL,
));
}
state.command_state = OverlayCommandState::PassThrough;
}
}
}
tui.frame_requester().schedule_frame();
}
}
OverlayCommandState::Move => {
if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
if is_ctrl_prefix {
state.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);
} else {
match key_event.code {
KeyCode::Esc | KeyCode::Enter => {
state.command_state = OverlayCommandState::PassThrough;
}
_ => {}
}
}
tui.frame_requester().schedule_frame();
}
}
OverlayCommandState::Resize => {
if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
if is_ctrl_prefix {
state.command_state = OverlayCommandState::PassThrough;
} else {
match key_event.code {
KeyCode::Left => {
let delta = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
1
} else {
-1
};
state.popup = resize_left_edge(area, state.popup, delta);
}
KeyCode::Right => {
let delta = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
-1
} else {
1
};
state.popup = resize_right_edge(area, state.popup, delta);
}
KeyCode::Up => {
let delta = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
1
} else {
-1
};
state.popup = resize_top_edge(area, state.popup, delta);
}
KeyCode::Down => {
let delta = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
-1
} else {
1
};
state.popup = resize_bottom_edge(area, state.popup, delta);
}
KeyCode::Char('h') => {
state.popup = resize_left_edge(area, state.popup, -1);
}
KeyCode::Char('H') => {
state.popup = resize_left_edge(area, state.popup, 1);
}
KeyCode::Char('j') => {
state.popup = resize_bottom_edge(area, state.popup, 1);
}
KeyCode::Char('J') => {
state.popup = resize_bottom_edge(area, state.popup, -1);
}
KeyCode::Char('k') => {
state.popup = resize_top_edge(area, state.popup, -1);
}
KeyCode::Char('K') => {
state.popup = resize_top_edge(area, state.popup, 1);
}
KeyCode::Char('l') => {
state.popup = resize_right_edge(area, state.popup, 1);
}
KeyCode::Char('L') => {
state.popup = resize_right_edge(area, state.popup, -1);
}
KeyCode::Char('=') | KeyCode::Char('+') => {
state.popup = resize_all_edges(area, state.popup, 1);
}
KeyCode::Char('-') => {
state.popup = resize_all_edges(area, state.popup, -1);
}
KeyCode::Esc | KeyCode::Enter => {
state.command_state = OverlayCommandState::PassThrough;
}
_ => {}
}
}
tui.frame_requester().schedule_frame();
}
}
}
}
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 _ = state.terminal.handle_key_event(key_event).await;
}
}
TuiEvent::Paste(pasted) => {
if let Some(state) = self.fork_session_overlay.as_ref() {
let pasted = pasted.replace("\r", "\n");
let _ = state.terminal.handle_paste(&pasted).await;
}
}
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?;
return Ok(());
}
if self.backtrack_render_pending {
self.backtrack_render_pending = false;
self.render_transcript_once(tui);
}
self.chat_widget.maybe_post_pending_notification(tui);
self.chat_widget.pre_draw_tick();
let terminal_height = tui.terminal.size()?.height;
tui.draw(terminal_height, |frame| {
self.render_fork_session_background(frame);
if let Some((x, y)) = self.render_fork_session_overlay_frame(frame) {
frame.set_cursor_position((x, y));
}
})?;
if self.chat_widget.external_editor_state() == ExternalEditorState::Requested {
self.chat_widget
.set_external_editor_state(ExternalEditorState::Active);
self.app_event_tx
.send(crate::app_event::AppEvent::LaunchExternalEditor);
}
}
}
Ok(())
}
fn restore_inline_view_after_fork_overlay_close(&mut self, tui: &mut tui::Tui) -> Result<()> {
let size = tui.terminal.size()?;
let viewport_height = self.chat_widget.desired_height(size.width).min(size.height);
tui.clear_pending_history_lines();
tui.terminal
.set_viewport_area(Rect::new(0, 0, size.width, viewport_height));
tui.terminal.clear_visible_screen()?;
self.has_emitted_history_lines = false;
self.render_transcript_once(tui);
self.has_emitted_history_lines = !self.transcript_cells.is_empty();
Ok(())
}
fn visible_history_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut has_lines = false;
for cell in &self.transcript_cells {
let mut display = cell.display_lines(width);
if display.is_empty() {
continue;
}
if !cell.is_stream_continuation() {
if has_lines {
lines.push(Line::from(""));
} else {
has_lines = true;
}
}
lines.append(&mut display);
}
lines.extend(self.deferred_history_lines.iter().cloned());
lines
}
fn background_history_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let header_lines = self.clear_ui_header_lines(width);
let transcript_has_session_header = self
.transcript_cells
.first()
.map(|cell| cell.display_lines(width))
.is_some_and(|display_lines| display_lines.starts_with(&header_lines));
let active_cell_has_session_header = self
.chat_widget
.active_cell_transcript_lines(width)
.is_some_and(|active_lines| active_lines.starts_with(&header_lines));
if !transcript_has_session_header && !active_cell_has_session_header {
lines.extend(header_lines);
}
let mut history_lines = self.visible_history_lines(width);
if !lines.is_empty() && !history_lines.is_empty() {
lines.push(Line::from(""));
}
lines.append(&mut history_lines);
lines
}
fn render_fork_session_background(&self, frame: &mut Frame<'_>) {
let area = frame.area();
Clear.render(area, frame.buffer);
let height = self.chat_widget.desired_height(area.width).min(area.height).max(1);
let background_viewport = Rect::new(area.x, area.y, area.width, height);
let Ok(mut terminal) = crate::custom_terminal::Terminal::with_options(VT100Backend::new(
area.width,
area.height,
)) else {
self.chat_widget.render(background_viewport, frame.buffer);
return;
};
terminal.set_viewport_area(background_viewport);
let history_lines = self.background_history_lines(area.width);
if !history_lines.is_empty() {
let _ = insert_history_lines(&mut terminal, history_lines);
}
let _ = terminal.draw(|offscreen_frame| {
self.chat_widget.render(offscreen_frame.area(), offscreen_frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(offscreen_frame.area()) {
offscreen_frame.set_cursor_position((x, y));
}
});
let _ = render_screen(terminal.backend().vt100().screen(), area, frame.buffer);
}
pub(crate) fn render_fork_session_overlay_frame(
&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 exit_code = state.terminal.exit_code();
let block = popup_block(exit_code, state.command_state);
let inner = block.inner(popup);
block.render(popup, frame.buffer);
if inner.is_empty() {
return None;
}
state.terminal.resize(codex_utils_pty::TerminalSize {
rows: inner.height.max(1),
cols: inner.width.max(1),
});
state.terminal.render(inner, frame.buffer)
}
fn build_fork_session_overlay_args(&self, thread_id: codex_protocol::ThreadId) -> Vec<String> {
let mut args = vec!["fork".to_string(), thread_id.to_string()];
for (key, value) in &self.cli_kv_overrides {
append_config_override(&mut args, key, value);
}
if let Some(profile) = self.active_profile.as_ref() {
args.push("-p".to_string());
args.push(profile.clone());
}
args.push("-C".to_string());
args.push(self.config.cwd.display().to_string());
args.push("-m".to_string());
args.push(self.chat_widget.current_model().to_string());
if let Some(effort) = self.config.model_reasoning_effort {
append_config_override(&mut args, "model_reasoning_effort", effort);
}
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
&& let Ok(value) = toml::Value::try_from(*policy)
{
append_config_override(&mut args, "approval_policy", value);
}
if let Some(policy) = self.runtime_sandbox_policy_override.as_ref() {
append_config_override(&mut args, "sandbox_mode", sandbox_mode_override(policy));
}
args
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::app_backtrack::BacktrackState;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::file_search::FileSearchManager;
use codex_core::CodexAuth;
use codex_core::config::ConfigOverrides;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tempfile::tempdir;
use super::super::FeedbackAudience;
use super::super::WindowsSandboxState;
use super::super::agent_navigation::AgentNavigationState;
fn snapshot_buffer(buf: &Buffer) -> String {
let mut lines = Vec::new();
for y in 0..buf.area.height {
let mut line = String::new();
for x in 0..buf.area.width {
line.push_str(buf[(x, y)].symbol());
}
while line.ends_with(' ') {
line.pop();
}
lines.push(line);
}
lines.join("\n")
}
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
let server = Arc::new(
codex_core::test_support::thread_manager_with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
),
);
let auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::from_api_key("Test API Key"),
);
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = SessionTelemetry::new(
ThreadId::new(),
model.as_str(),
model.as_str(),
None,
None,
None,
"test_originator".to_string(),
false,
"test".to_string(),
SessionSource::Cli,
);
App {
server,
session_telemetry,
app_event_tx,
chat_widget,
auth_manager,
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
fork_session_overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
pending_update_action: None,
suppress_shutdown_complete: false,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
}
}
#[tokio::test]
async fn build_fork_overlay_args_include_live_model_and_runtime_overrides() {
let temp_dir = tempdir().expect("tempdir");
let mut app = make_test_app().await;
app.active_profile = Some("dev".to_string());
app.config.cwd = temp_dir.path().join("project");
app.chat_widget.set_model("gpt-5.4");
app.on_update_reasoning_effort(Some(codex_protocol::openai_models::ReasoningEffort::High));
app.runtime_approval_policy_override =
Some(codex_protocol::protocol::AskForApproval::Never);
app.runtime_sandbox_policy_override =
Some(codex_protocol::protocol::SandboxPolicy::DangerFullAccess);
let args = app.build_fork_session_overlay_args(ThreadId::new());
assert_eq!(args[0], "fork");
assert!(args.iter().any(|arg| arg == "-p"));
assert!(args.iter().any(|arg| arg == "dev"));
assert!(args.iter().any(|arg| arg == "-m"));
assert!(args.iter().any(|arg| arg == "gpt-5.4"));
assert!(args.iter().any(|arg| arg == "approval_policy=\"never\""));
assert!(
args.iter()
.any(|arg| arg == "sandbox_mode=danger-full-access")
);
}
#[test]
fn child_overlay_env_strips_terminal_multiplexer_markers() {
let env = child_overlay_env(HashMap::from([
("PATH".to_string(), "/usr/bin".to_string()),
("TMUX".to_string(), "1".to_string()),
("TMUX_PANE".to_string(), "%1".to_string()),
("ZELLIJ".to_string(), "1".to_string()),
("ZELLIJ_SESSION_NAME".to_string(), "codex".to_string()),
("ZELLIJ_VERSION".to_string(), "0.44.0".to_string()),
]));
assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string()));
assert_eq!(env.get("TMUX"), None);
assert_eq!(env.get("TMUX_PANE"), None);
assert_eq!(env.get("ZELLIJ"), None);
assert_eq!(env.get("ZELLIJ_SESSION_NAME"), None);
assert_eq!(env.get("ZELLIJ_VERSION"), None);
}
#[test]
fn parent_bg_rgb_env_value_formats_rgb_triplet() {
assert_eq!(parent_bg_rgb_env_value((12, 34, 56)), "12,34,56");
}
#[test]
fn move_popup_rect_clamps_within_viewport() {
let area = Rect::new(0, 0, 100, 28);
let popup = default_popup_rect(area);
let moved = move_popup_rect(area, popup, -100, 100);
assert_eq!(moved, Rect::new(0, 13, 64, 15));
}
#[test]
fn move_popup_delta_uses_shift_for_faster_steps() {
assert_eq!(
move_popup_delta(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
Some((-1, 0))
);
assert_eq!(
move_popup_delta(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT)),
Some((0, 5))
);
assert_eq!(
move_popup_delta(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
None
);
}
#[test]
fn resize_popup_rect_respects_min_and_max_bounds() {
let area = Rect::new(0, 0, 100, 28);
let popup = default_popup_rect(area);
let shrunk = resize_all_edges(area, popup, -100);
let grown = resize_all_edges(area, popup, 100);
assert_eq!(shrunk, Rect::new(38, 11, 44, 10));
assert_eq!(grown, Rect::new(0, 0, 96, 26));
}
#[test]
fn resize_popup_rect_can_grow_beyond_default_cap_on_large_viewports() {
let area = Rect::new(0, 0, 140, 40);
let popup = default_popup_rect(area);
let grown = resize_all_edges(area, popup, 100);
assert_eq!(grown, Rect::new(0, 0, 136, 38));
}
#[test]
fn default_popup_rect_scales_with_large_viewports() {
let area = Rect::new(0, 0, 180, 50);
assert_eq!(default_popup_rect(area), Rect::new(31, 11, 117, 28));
}
#[tokio::test]
async fn fork_session_overlay_popup_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 parser = vt100::Parser::new(18, 74, 0);
parser.process(
b"\x1b[32mIndependent Codex session\x1b[0m\r\n\
\r\n\
> /tmp/worktree\r\n\
\r\n\
ready for a fresh turn\r\n",
);
app.fork_session_overlay = Some(ForkSessionOverlayState {
terminal: ForkSessionTerminal::for_test(parser, None),
popup: default_popup_rect(Rect::new(0, 0, 100, 28)),
command_state: OverlayCommandState::PassThrough,
});
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_popup", snapshot_buffer(&buf));
}
#[tokio::test]
async fn fork_session_overlay_background_does_not_duplicate_live_header_snapshot() {
let app = make_test_app().await;
let area = Rect::new(0, 0, 100, 16);
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);
insta::assert_snapshot!(
"fork_session_overlay_background_live_header",
snapshot_buffer(&buf)
);
}
}
#[cfg(test)]
#[path = "fork_session_overlay_vt100_tests.rs"]
mod vt100_tests;

View File

@@ -0,0 +1,230 @@
use super::*;
use super::super::FeedbackAudience;
use super::super::WindowsSandboxState;
use super::super::agent_navigation::AgentNavigationState;
use crate::app::App;
use crate::app_backtrack::BacktrackState;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::custom_terminal::Terminal;
use crate::file_search::FileSearchManager;
use crate::history_cell;
use crate::history_cell::PlainHistoryCell;
use crate::test_backend::VT100Backend;
use codex_core::CodexAuth;
use codex_core::config::ConfigOverrides;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use insta::assert_snapshot;
use ratatui::backend::Backend;
use ratatui::layout::Rect;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::io::Result;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tempfile::NamedTempFile;
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
let server = Arc::new(
codex_core::test_support::thread_manager_with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
),
);
let auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::from_api_key("Test API Key"),
);
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = SessionTelemetry::new(
ThreadId::new(),
model.as_str(),
model.as_str(),
None,
None,
None,
"test_originator".to_string(),
false,
"test".to_string(),
SessionSource::Cli,
);
App {
server,
session_telemetry,
app_event_tx,
chat_widget,
auth_manager,
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
fork_session_overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
pending_update_action: None,
suppress_shutdown_complete: false,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
}
}
fn configure_chat_widget(app: &mut App) {
let rollout_file = NamedTempFile::new().expect("rollout file");
app.handle_codex_event_now(Event {
id: "configured".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-5.4".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/tmp/worktree"),
reasoning_effort: Some(ReasoningEffortConfig::XHigh),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
}),
});
}
fn vt100_contents(terminal: &Terminal<VT100Backend>) -> String {
terminal.backend().vt100().screen().contents()
}
fn draw_vt100_terminal_frame(
terminal: &mut Terminal<VT100Backend>,
pending_history_lines: &mut Vec<ratatui::text::Line<'static>>,
height: u16,
draw_fn: impl FnOnce(&mut crate::custom_terminal::Frame),
) -> Result<Rect> {
let size = terminal.size()?;
let mut area = terminal.viewport_area;
area.height = height.min(size.height);
area.width = size.width;
if area.bottom() > size.height {
terminal
.backend_mut()
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
area.y = size.height - area.height;
}
if area != terminal.viewport_area {
terminal.clear()?;
terminal.set_viewport_area(area);
}
if !pending_history_lines.is_empty() {
crate::insert_history::insert_history_lines(terminal, pending_history_lines.clone())?;
pending_history_lines.clear();
}
terminal.draw(|frame| {
draw_fn(frame);
})?;
Ok(area)
}
#[tokio::test]
#[ignore = "manual harness for inline viewport /fork overlay rendering"]
async fn fork_session_overlay_open_from_inline_viewport_snapshot() {
let width = 100;
let height = 28;
let mut app = make_test_app().await;
configure_chat_widget(&mut app);
app.chat_widget
.set_composer_text("Summarize recent commits".to_string(), Vec::new(), Vec::new());
app.transcript_cells = vec![
Arc::new(history_cell::new_user_prompt(
"count to 10".to_string(),
Vec::new(),
Vec::new(),
Vec::new(),
)),
Arc::new(PlainHistoryCell::new(
(1..=10)
.map(|n| n.to_string().into())
.collect::<Vec<ratatui::text::Line<'static>>>(),
)),
];
let mut child = vt100::Parser::new(18, 74, 0);
child.process(
b"\x1b[48;2;58;61;67m count to 10 \x1b[0m\r\n\
\r\n\
1\r\n\
2\r\n\
3\r\n\
4\r\n\
5\r\n\
6\r\n\
7\r\n\
8\r\n\
9\r\n\
10\r\n\
\r\n\
\x1b[38;2;94;129;172m\xE2\x80\xA2\x1b[0m Thread forked from 019cecc9-0318-74d3-a020-2a21605271f8\r\n\
\r\n\
\x1b[48;2;58;61;67m \x1b[0m> /skills to list available skills\r\n",
);
let mut terminal =
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 {
terminal: ForkSessionTerminal::for_test(child, None),
popup: default_popup_rect(Rect::new(0, 0, width, height)),
command_state: OverlayCommandState::PassThrough,
});
let mut pending_history_lines = Vec::new();
draw_vt100_terminal_frame(&mut terminal, &mut pending_history_lines, height, |frame| {
app.render_fork_session_background(frame);
if let Some((x, y)) = app.render_fork_session_overlay_frame(frame) {
frame.set_cursor_position((x, y));
}
})
.expect("draw overlay frame");
assert_snapshot!(
"fork_session_overlay_open_from_inline_viewport_vt100",
vt100_contents(&terminal)
);
}

View File

@@ -0,0 +1,457 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex;
use codex_utils_pty::ProcessHandle;
use codex_utils_pty::SpawnedProcess;
use codex_utils_pty::TerminalSize;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use tokio::select;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::tui::FrameRequester;
use crate::vt100_render::render_screen;
const CURSOR_POSITION_REQUEST: &[u8] = b"\x1b[6n";
const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~";
const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~";
const TERMINAL_SCROLLBACK: usize = 2_048;
struct SharedTerminalState {
parser: vt100::Parser,
exit_code: Option<i32>,
}
struct ForkSessionTerminalIo {
session: ProcessHandle,
writer_tx: mpsc::Sender<Vec<u8>>,
update_task: JoinHandle<()>,
}
pub(crate) struct ForkSessionTerminal {
shared: Arc<Mutex<SharedTerminalState>>,
io: Option<ForkSessionTerminalIo>,
last_size: Option<TerminalSize>,
}
impl ForkSessionTerminal {
pub(crate) async fn spawn(
program: &str,
args: &[String],
cwd: &Path,
env: HashMap<String, String>,
size: TerminalSize,
frame_requester: FrameRequester,
) -> Result<Self> {
let SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = codex_utils_pty::spawn_pty_process(program, args, cwd, &env, &None, size)
.await
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
let writer_tx = session.writer_sender();
let shared = Arc::new(Mutex::new(SharedTerminalState {
parser: vt100::Parser::new(size.rows, size.cols, TERMINAL_SCROLLBACK),
exit_code: None,
}));
let update_task = spawn_terminal_update_task(
shared.clone(),
stdout_rx,
stderr_rx,
exit_rx,
writer_tx.clone(),
frame_requester,
);
Ok(Self {
shared,
io: Some(ForkSessionTerminalIo {
session,
writer_tx,
update_task,
}),
last_size: Some(size),
})
}
pub(crate) fn resize(&mut self, size: TerminalSize) {
if self.last_size == Some(size) {
return;
}
if let Ok(mut shared) = self.shared.lock() {
shared.parser.screen_mut().set_size(size.rows, size.cols);
}
if let Some(io) = self.io.as_ref() {
let _ = io.session.resize(size);
}
self.last_size = Some(size);
}
pub(crate) async fn handle_key_event(&self, key_event: KeyEvent) -> bool {
let Some(io) = self.io.as_ref() else {
return false;
};
if self.exit_code().is_some() {
return false;
}
let application_cursor = self.application_cursor();
let Some(bytes) = encode_key_event(key_event, application_cursor) else {
return false;
};
io.writer_tx.send(bytes).await.is_ok()
}
pub(crate) async fn handle_paste(&self, pasted: &str) -> bool {
let Some(io) = self.io.as_ref() else {
return false;
};
if self.exit_code().is_some() {
return false;
}
let bytes = {
let bracketed_paste = self.bracketed_paste();
encode_paste(bracketed_paste, pasted)
};
io.writer_tx.send(bytes).await.is_ok()
}
pub(crate) fn exit_code(&self) -> Option<i32> {
self.shared.lock().ok().and_then(|shared| shared.exit_code)
}
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) -> Option<(u16, u16)> {
if area.is_empty() {
return None;
}
let shared = self.shared.lock().ok()?;
render_screen(shared.parser.screen(), area, buf)
}
pub(crate) fn terminate(&mut self) {
if let Some(io) = self.io.take() {
io.update_task.abort();
io.session.terminate();
}
}
fn application_cursor(&self) -> bool {
self.shared
.lock()
.ok()
.is_some_and(|shared| shared.parser.screen().application_cursor())
}
fn bracketed_paste(&self) -> bool {
self.shared
.lock()
.ok()
.is_some_and(|shared| shared.parser.screen().bracketed_paste())
}
}
impl Drop for ForkSessionTerminal {
fn drop(&mut self) {
self.terminate();
}
}
fn spawn_terminal_update_task(
shared: Arc<Mutex<SharedTerminalState>>,
mut stdout_rx: mpsc::Receiver<Vec<u8>>,
mut stderr_rx: mpsc::Receiver<Vec<u8>>,
mut exit_rx: tokio::sync::oneshot::Receiver<i32>,
writer_tx: mpsc::Sender<Vec<u8>>,
frame_requester: FrameRequester,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut stdout_open = true;
let mut stderr_open = true;
let mut exit_open = true;
let mut request_tail = Vec::new();
loop {
select! {
stdout = stdout_rx.recv(), if stdout_open => match stdout {
Some(chunk) => {
process_output_chunk(
&shared,
&writer_tx,
&frame_requester,
&mut request_tail,
chunk,
).await;
}
None => {
stdout_open = false;
}
},
stderr = stderr_rx.recv(), if stderr_open => match stderr {
Some(chunk) => {
process_output_chunk(
&shared,
&writer_tx,
&frame_requester,
&mut request_tail,
chunk,
).await;
}
None => {
stderr_open = false;
}
},
exit = &mut exit_rx, if exit_open => {
let exit_code = exit.unwrap_or(-1);
if let Ok(mut shared) = shared.lock() {
shared.exit_code = Some(exit_code);
}
exit_open = false;
frame_requester.schedule_frame();
}
else => break,
}
if !stdout_open && !stderr_open && !exit_open {
break;
}
}
})
}
async fn process_output_chunk(
shared: &Arc<Mutex<SharedTerminalState>>,
writer_tx: &mpsc::Sender<Vec<u8>>,
frame_requester: &FrameRequester,
request_tail: &mut Vec<u8>,
chunk: Vec<u8>,
) {
let request_count = count_cursor_position_requests(request_tail, &chunk);
let responses = if let Ok(mut shared) = shared.lock() {
shared.parser.process(&chunk);
let (row, col) = shared.parser.screen().cursor_position();
vec![cursor_position_response(row, col); request_count]
} else {
Vec::new()
};
for response in responses {
if writer_tx.send(response).await.is_err() {
break;
}
}
frame_requester.schedule_frame();
}
fn count_cursor_position_requests(request_tail: &mut Vec<u8>, chunk: &[u8]) -> usize {
let mut combined = Vec::with_capacity(request_tail.len() + chunk.len());
combined.extend_from_slice(request_tail);
combined.extend_from_slice(chunk);
let request_count = combined
.windows(CURSOR_POSITION_REQUEST.len())
.filter(|window| *window == CURSOR_POSITION_REQUEST)
.count();
let keep = CURSOR_POSITION_REQUEST.len().saturating_sub(1);
if combined.len() > keep {
request_tail.clear();
request_tail.extend_from_slice(&combined[combined.len() - keep..]);
} else {
request_tail.clear();
request_tail.extend_from_slice(&combined);
}
request_count
}
fn cursor_position_response(row: u16, col: u16) -> Vec<u8> {
format!("\x1b[{};{}R", row + 1, col + 1).into_bytes()
}
fn encode_paste(bracketed_paste: bool, pasted: &str) -> Vec<u8> {
if !bracketed_paste {
return pasted.as_bytes().to_vec();
}
let mut bytes =
Vec::with_capacity(BRACKETED_PASTE_START.len() + pasted.len() + BRACKETED_PASTE_END.len());
bytes.extend_from_slice(BRACKETED_PASTE_START);
bytes.extend_from_slice(pasted.as_bytes());
bytes.extend_from_slice(BRACKETED_PASTE_END);
bytes
}
fn encode_key_event(key_event: KeyEvent, application_cursor: bool) -> Option<Vec<u8>> {
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return None;
}
let modifiers = key_event.modifiers;
let alt = modifiers.contains(KeyModifiers::ALT) && !crate::key_hint::is_altgr(modifiers);
let control =
modifiers.contains(KeyModifiers::CONTROL) && !crate::key_hint::is_altgr(modifiers);
let mut bytes = match key_event.code {
KeyCode::Backspace => vec![0x7f],
KeyCode::Enter => vec![b'\r'],
KeyCode::Left if alt => b"\x1bb".to_vec(),
KeyCode::Right if alt => b"\x1bf".to_vec(),
KeyCode::Left => {
if application_cursor {
b"\x1bOD".to_vec()
} else {
b"\x1b[D".to_vec()
}
}
KeyCode::Right => {
if application_cursor {
b"\x1bOC".to_vec()
} else {
b"\x1b[C".to_vec()
}
}
KeyCode::Up => {
if application_cursor {
b"\x1bOA".to_vec()
} else {
b"\x1b[A".to_vec()
}
}
KeyCode::Down => {
if application_cursor {
b"\x1bOB".to_vec()
} else {
b"\x1b[B".to_vec()
}
}
KeyCode::Home => b"\x1b[H".to_vec(),
KeyCode::End => b"\x1b[F".to_vec(),
KeyCode::PageUp => b"\x1b[5~".to_vec(),
KeyCode::PageDown => b"\x1b[6~".to_vec(),
KeyCode::Tab => vec![b'\t'],
KeyCode::BackTab => b"\x1b[Z".to_vec(),
KeyCode::Delete => b"\x1b[3~".to_vec(),
KeyCode::Insert => b"\x1b[2~".to_vec(),
KeyCode::Esc => b"\x1b".to_vec(),
KeyCode::Char(c) => {
if control && let Some(control_byte) = encode_control_char(c) {
vec![control_byte]
} else {
c.to_string().into_bytes()
}
}
_ => return None,
};
if alt && !matches!(key_event.code, KeyCode::Left | KeyCode::Right) {
let mut prefixed = Vec::with_capacity(bytes.len() + 1);
prefixed.push(0x1b);
prefixed.extend_from_slice(&bytes);
bytes = prefixed;
}
Some(bytes)
}
fn encode_control_char(c: char) -> Option<u8> {
match c {
'a' | 'A' => Some(0x01),
'b' | 'B' => Some(0x02),
'c' | 'C' => Some(0x03),
'd' | 'D' => Some(0x04),
'e' | 'E' => Some(0x05),
'f' | 'F' => Some(0x06),
'g' | 'G' => Some(0x07),
'h' | 'H' => Some(0x08),
'i' | 'I' => Some(0x09),
'j' | 'J' => Some(0x0a),
'k' | 'K' => Some(0x0b),
'l' | 'L' => Some(0x0c),
'm' | 'M' => Some(0x0d),
'n' | 'N' => Some(0x0e),
'o' | 'O' => Some(0x0f),
'p' | 'P' => Some(0x10),
'q' | 'Q' => Some(0x11),
'r' | 'R' => Some(0x12),
's' | 'S' => Some(0x13),
't' | 'T' => Some(0x14),
'u' | 'U' => Some(0x15),
'v' | 'V' => Some(0x16),
'w' | 'W' => Some(0x17),
'x' | 'X' => Some(0x18),
'y' | 'Y' => Some(0x19),
'z' | 'Z' => Some(0x1a),
'[' => Some(0x1b),
'\\' => Some(0x1c),
']' => Some(0x1d),
'^' => Some(0x1e),
'_' => Some(0x1f),
' ' => Some(0x00),
_ => None,
}
}
#[cfg(test)]
impl ForkSessionTerminal {
pub(crate) fn for_test(parser: vt100::Parser, exit_code: Option<i32>) -> Self {
Self {
shared: Arc::new(Mutex::new(SharedTerminalState { parser, exit_code })),
io: None,
last_size: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn ctrl_char_maps_to_control_byte() {
assert_eq!(
encode_key_event(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
false
),
Some(vec![0x03])
);
}
#[test]
fn alt_left_uses_word_motion_fallback() {
assert_eq!(
encode_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), false),
Some(b"\x1bb".to_vec())
);
}
#[test]
fn bracketed_paste_wraps_contents() {
assert_eq!(
encode_paste(true, "hello"),
b"\x1b[200~hello\x1b[201~".to_vec()
);
}
#[test]
fn cursor_position_requests_detect_across_chunk_boundaries() {
let mut request_tail = Vec::new();
assert_eq!(
count_cursor_position_requests(&mut request_tail, b"\x1b["),
0
);
assert_eq!(count_cursor_position_requests(&mut request_tail, b"6n"), 1);
}
}

View File

@@ -0,0 +1,15 @@
---
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 │
╰─────────────────────────────────────────────╯
Ask Codex to do anything
? for shortcuts 100% context left

View File

@@ -0,0 +1,25 @@
---
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────────────────────────╮
│Independent Codex session │
background sessi│ │
│> /tmp/worktree │
│ │
│ready for a fresh turn │
Ask Codex to do │ │
│ │
? for shortcuts │ │00% context left
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────╯

View File

@@ -0,0 +1,30 @@
---
source: tui/src/app/fork_session_overlay_vt100_tests.rs
expression: vt100_contents(&terminal)
---
╭─────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ model: gpt-5.4 xhigh /model to change │
│ directory: ~/code/codex/codex-rs/tui │
╰─────────────────────────────────────────────╯
╭ fork session running ctrl+] prefix────────────────────────╮
│ count to 10 │
count to 10 │ │
│1 │
│2 │
1 │3 │
2 │4 │
3 │5 │
4 │6 │
5 │7 │
6 │8 │
7 │9 │
8 │10 │
9 │ │
10 ╰──────────────────────────────────────────────────────────────╯
Summarize recent commits
gpt-5.4 xhigh · 100% left · /tmp/worktree

View File

@@ -153,7 +153,11 @@ pub mod update_action;
mod update_prompt;
mod updates;
mod version;
#[cfg(not(target_os = "linux"))]
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
mod voice;
mod vt100_backend;
mod vt100_render;
#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))]
mod voice;
#[cfg(target_os = "linux")]
#[allow(dead_code)]

View File

@@ -1,9 +1,11 @@
use crate::color::perceptual_distance;
use ratatui::style::Color;
use std::env;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
static DEFAULT_PALETTE_VERSION: AtomicU64 = AtomicU64::new(0);
pub(crate) const PARENT_BG_RGB_ENV_VAR: &str = "CODEX_TUI_PARENT_BG_RGB";
fn bump_palette_version() {
DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed);
@@ -74,7 +76,7 @@ pub fn default_fg() -> Option<(u8, u8, u8)> {
}
pub fn default_bg() -> Option<(u8, u8, u8)> {
default_colors().map(|c| c.bg)
env_default_bg().or_else(|| default_colors().map(|c| c.bg))
}
/// Returns a monotonic counter that increments whenever `requery_default_colors()` runs
@@ -85,6 +87,21 @@ pub fn palette_version() -> u64 {
DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed)
}
fn env_default_bg() -> Option<(u8, u8, u8)> {
parse_bg_rgb_env(&env::var(PARENT_BG_RGB_ENV_VAR).ok()?)
}
fn parse_bg_rgb_env(value: &str) -> Option<(u8, u8, u8)> {
let mut parts = value.split(',');
let red = parts.next()?.trim().parse().ok()?;
let green = parts.next()?.trim().parse().ok()?;
let blue = parts.next()?.trim().parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((red, green, blue))
}
#[cfg(all(unix, not(test)))]
mod imp {
use super::DefaultColors;
@@ -437,3 +454,21 @@ pub const XTERM_COLORS: [(u8, u8, u8); 256] = [
(228, 228, 228), // 254 Grey89
(238, 238, 238), // 255 Grey93
];
#[cfg(test)]
mod tests {
use super::parse_bg_rgb_env;
use pretty_assertions::assert_eq;
#[test]
fn parse_bg_rgb_env_accepts_rgb_triplet() {
assert_eq!(parse_bg_rgb_env("12,34,56"), Some((12, 34, 56)));
}
#[test]
fn parse_bg_rgb_env_rejects_invalid_values() {
assert_eq!(parse_bg_rgb_env("12,34"), None);
assert_eq!(parse_bg_rgb_env("12,34,56,78"), None);
assert_eq!(parse_bg_rgb_env("12,nope,56"), None);
}
}

View File

@@ -0,0 +1,117 @@
use std::fmt;
use std::io;
use std::io::Write;
use ratatui::backend::Backend;
use ratatui::backend::ClearType;
use ratatui::backend::WindowSize;
use ratatui::backend::CrosstermBackend;
use ratatui::buffer::Cell;
use ratatui::layout::Position;
use ratatui::layout::Size;
/// Wraps a Crossterm backend around a vt100 parser for off-screen rendering.
pub(crate) struct VT100Backend {
crossterm_backend: CrosstermBackend<vt100::Parser>,
}
impl VT100Backend {
pub(crate) fn new(width: u16, height: u16) -> Self {
crossterm::style::force_color_output(true);
Self {
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
}
}
pub(crate) fn vt100(&self) -> &vt100::Parser {
self.crossterm_backend.writer()
}
}
impl Write for VT100Backend {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.crossterm_backend.writer_mut().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.crossterm_backend.writer_mut().flush()
}
}
impl fmt::Display for VT100Backend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.crossterm_backend.writer().screen().contents())
}
}
impl Backend for VT100Backend {
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
self.crossterm_backend.draw(content)
}
fn hide_cursor(&mut self) -> io::Result<()> {
self.crossterm_backend.hide_cursor()
}
fn show_cursor(&mut self) -> io::Result<()> {
self.crossterm_backend.show_cursor()
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
Ok(self.vt100().screen().cursor_position().into())
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
self.crossterm_backend.set_cursor_position(position)
}
fn clear(&mut self) -> io::Result<()> {
self.crossterm_backend.clear()
}
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
self.crossterm_backend.clear_region(clear_type)
}
fn append_lines(&mut self, line_count: u16) -> io::Result<()> {
self.crossterm_backend.append_lines(line_count)
}
fn size(&self) -> io::Result<Size> {
let (rows, cols) = self.vt100().screen().size();
Ok(Size::new(cols, rows))
}
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: self.vt100().screen().size().into(),
pixels: Size {
width: 640,
height: 480,
},
})
}
fn flush(&mut self) -> io::Result<()> {
self.crossterm_backend.writer_mut().flush()
}
fn scroll_region_up(
&mut self,
region: std::ops::Range<u16>,
scroll_by: u16,
) -> io::Result<()> {
self.crossterm_backend.scroll_region_up(region, scroll_by)
}
fn scroll_region_down(
&mut self,
region: std::ops::Range<u16>,
scroll_by: u16,
) -> io::Result<()> {
self.crossterm_backend.scroll_region_down(region, scroll_by)
}
}

View File

@@ -0,0 +1,71 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use crate::terminal_palette::indexed_color;
use crate::terminal_palette::rgb_color;
pub(crate) fn render_screen(
screen: &vt100::Screen,
area: Rect,
buf: &mut Buffer,
) -> Option<(u16, u16)> {
if area.is_empty() {
return None;
}
for row in 0..area.height {
for col in 0..area.width {
let Some(cell) = screen.cell(row, col) else {
continue;
};
let mut fg = vt100_color_to_ratatui(cell.fgcolor());
let mut bg = vt100_color_to_ratatui(cell.bgcolor());
if cell.inverse() {
std::mem::swap(&mut fg, &mut bg);
}
let mut style = Style::default().fg(fg).bg(bg);
if cell.bold() {
style = style.add_modifier(Modifier::BOLD);
}
if cell.dim() {
style = style.add_modifier(Modifier::DIM);
}
if cell.italic() {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
style = style.add_modifier(Modifier::UNDERLINED);
}
let symbol = if cell.is_wide_continuation() || cell.contents().is_empty() {
" "
} else {
cell.contents()
};
buf[(area.x + col, area.y + row)]
.set_symbol(symbol)
.set_style(style);
}
}
if screen.hide_cursor() {
return None;
}
let (row, col) = screen.cursor_position();
if row >= area.height || col >= area.width {
return None;
}
Some((area.x + col, area.y + row))
}
fn vt100_color_to_ratatui(color: vt100::Color) -> Color {
match color {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(index) => indexed_color(index),
vt100::Color::Rgb(red, green, blue) => rgb_color((red, green, blue)),
}
}