Compare commits

...

9 Commits

Author SHA1 Message Date
Rakan El Khalil
eb31c9619f test(tui): annotate popup stack test args 2026-04-02 14:12:15 -07:00
Rakan El Khalil
5938a9e5b5 Fix fork popup mouse support 2026-04-02 13:17:08 -07:00
Rakan El Khalil
f5c15fa5ce Skip terminal probes in fork popup child 2026-04-02 12:45:54 -07:00
Rakan El Khalil
c17c8433f8 Fix fork popup rebase drift on main 2026-04-02 12:45:54 -07:00
Rakan El Khalil
ffc441bb5d Support multiple fork popup overlays 2026-04-02 12:45:54 -07:00
Rakan El Khalil
4910cdea38 Add mouse controls to fork popup overlay 2026-04-02 12:45:54 -07:00
Rakan El Khalil
7503033ab7 Improve fork popup focus and background replay 2026-04-02 12:45:53 -07:00
Rakan El Khalil
d6e8a05212 Allow switching focus between fork popup and background 2026-04-02 12:45:53 -07:00
Rakan El Khalil
18f0e36fe1 Add PTY-backed fork session popup overlay 2026-04-02 12:45:53 -07:00
24 changed files with 3187 additions and 51 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

@@ -153,12 +153,17 @@ use uuid::Uuid;
mod agent_navigation;
mod app_server_adapter;
mod app_server_requests;
mod fork_session_overlay;
mod fork_session_overlay_mouse;
mod fork_session_overlay_stack;
mod fork_session_terminal;
mod loaded_threads;
mod pending_interactive_replay;
use self::agent_navigation::AgentNavigationDirection;
use self::agent_navigation::AgentNavigationState;
use self::app_server_requests::PendingAppServerRequests;
use self::fork_session_overlay_stack::ForkSessionOverlayStack;
use self::loaded_threads::find_loaded_subagent_threads_for_primary;
use self::pending_interactive_replay::PendingInteractiveReplayState;
@@ -952,6 +957,7 @@ pub(crate) struct App {
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
pub(crate) fork_session_overlay: Option<ForkSessionOverlayStack>,
pub(crate) deferred_history_lines: Vec<Line<'static>>,
has_emitted_history_lines: bool,
@@ -1468,7 +1474,11 @@ impl App {
let width = tui.terminal.last_known_screen_size.width;
let header_lines = self.clear_ui_header_lines(width);
if !header_lines.is_empty() {
tui.insert_history_lines(header_lines);
if self.overlay.is_some() {
self.deferred_history_lines.extend(header_lines);
} else if self.fork_session_overlay.is_none() {
tui.insert_history_lines(header_lines);
}
self.has_emitted_history_lines = true;
}
}
@@ -3193,6 +3203,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 +3712,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,11 +3903,15 @@ 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, app_server, event)
.await?;
} else {
match event {
TuiEvent::Key(key_event) => {
self.handle_key_event(tui, app_server, key_event).await;
}
TuiEvent::Mouse(_) => {}
TuiEvent::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
@@ -3946,6 +3962,13 @@ 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;
}
@@ -3957,6 +3980,13 @@ impl App {
.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() {
@@ -4086,48 +4116,36 @@ impl App {
/*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(),
/*id_str_if_uuid*/ 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 {
@@ -4160,8 +4178,11 @@ impl App {
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {
} else if self.fork_session_overlay.is_none() {
tui.insert_history_lines(display);
} else {
// While the fork overlay is open, rebuild the background from
// transcript state instead of mutating the live terminal.
}
}
}
@@ -5193,9 +5214,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 +5813,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 +5830,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 +9044,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 +9099,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,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use ratatui::layout::Rect;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct PopupDragState {
column_offset: u16,
row_offset: u16,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum OverlayMouseAction {
Ignore,
FocusBackground,
FocusPopup {
popup_index: usize,
drag_state: PopupDragState,
},
MovePopup(Rect),
EndDrag,
}
pub(crate) fn overlay_mouse_action(
area: Rect,
popups: &[Rect],
drag_state: Option<PopupDragState>,
mouse_event: MouseEvent,
) -> OverlayMouseAction {
match mouse_event.kind {
MouseEventKind::Down(MouseButton::Left) => {
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
&& 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
.column
.saturating_sub(drag_state.column_offset)
.clamp(area.x, max_x);
let y = mouse_event
.row
.saturating_sub(drag_state.row_offset)
.clamp(area.y, max_y);
OverlayMouseAction::MovePopup(Rect::new(x, y, popup.width, popup.height))
} else {
OverlayMouseAction::Ignore
}
}
MouseEventKind::Up(MouseButton::Left) => OverlayMouseAction::EndDrag,
MouseEventKind::Down(_)
| MouseEventKind::Up(_)
| MouseEventKind::Drag(_)
| MouseEventKind::Moved
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollUp
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => OverlayMouseAction::Ignore,
}
}
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::*;
use pretty_assertions::assert_eq;
#[test]
fn mouse_down_outside_popup_focuses_background() {
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),
column: 4,
row: 3,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert_eq!(
overlay_mouse_action(area, &popups, /*drag_state*/ None, mouse_event),
OverlayMouseAction::FocusBackground
);
}
#[test]
fn mouse_down_inside_popup_starts_drag() {
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),
column: 27,
row: 10,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert_eq!(
overlay_mouse_action(area, &popups, /*drag_state*/ 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 popups = [Rect::new(20, 8, 40, 16)];
let drag_state = PopupDragState {
column_offset: 7,
row_offset: 2,
};
let mouse_event = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 118,
row: 39,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert_eq!(
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 popups = [Rect::new(20, 8, 40, 16)];
let area = Rect::new(0, 0, 120, 40);
let mouse_event = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 27,
row: 10,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert_eq!(
overlay_mouse_action(area, &popups, /*drag_state*/ 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, /*drag_state*/ None, mouse_event),
OverlayMouseAction::FocusPopup {
popup_index: 1,
drag_state: PopupDragState {
column_offset: 5,
row_offset: 3,
},
}
);
}
}

View File

@@ -0,0 +1,172 @@
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),
/*exit_code*/ None,
),
popup: Rect::new(/*x*/ x, /*y*/ 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(/*x*/ 10, /*y*/ 10));
stack.push_popup(popup(/*x*/ 20, /*y*/ 20));
stack.push_popup(popup(/*x*/ 30, /*y*/ 30));
let active = stack
.bring_popup_to_front(/*index*/ 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(/*x*/ 10, /*y*/ 10));
stack.push_popup(popup(/*x*/ 20, /*y*/ 20));
let closed = stack.close_active_popup().expect("close topmost popup");
assert_eq!(closed.popup, Rect::new(20, 20, 40, 16));
assert_eq!(stack.popups().len(), 1);
assert_eq!(stack.focused_pane(), OverlayFocusedPane::Popup);
stack.close_active_popup().expect("close final popup");
assert!(stack.is_empty());
assert_eq!(stack.focused_pane(), OverlayFocusedPane::Background);
}
}

View File

@@ -0,0 +1,238 @@
use super::super::FeedbackAudience;
use super::super::WindowsSandboxState;
use super::super::agent_navigation::AgentNavigationState;
use super::*;
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_config::types::ApprovalsReviewer;
use codex_core::config::ConfigOverrides;
use codex_login::CodexAuth;
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.to_path_buf(), 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(),
/*account_id*/ None,
/*account_email*/ None,
/*auth_mode*/ None,
"test_originator".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
SessionSource::Cli,
);
App {
model_catalog: chat_widget.model_catalog(),
session_telemetry,
app_event_tx,
chat_widget,
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)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
remote_app_server_url: None,
remote_app_server_auth_token: None,
pending_update_action: None,
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,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: Default::default(),
}
}
fn configure_chat_widget(app: &mut App) {
let rollout_file = NamedTempFile::new().expect("rollout file");
app.chat_widget.handle_codex_event(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,
approvals_reviewer: ApprovalsReviewer::User,
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(ForkSessionOverlayStack::new(ForkSessionOverlayState {
terminal: ForkSessionTerminal::for_test(child, /*exit_code*/ None),
popup: default_popup_rect(Rect::new(0, 0, width, height)),
command_state: OverlayCommandState::PassThrough,
drag_state: None,
}));
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,460 @@
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),
/*application_cursor*/ false
),
Some(vec![0x03])
);
}
#[test]
fn alt_left_uses_word_motion_fallback() {
assert_eq!(
encode_key_event(
KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
/*application_cursor*/ false,
),
Some(b"\x1bb".to_vec())
);
}
#[test]
fn bracketed_paste_wraps_contents() {
assert_eq!(
encode_paste(/*bracketed_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,18 @@
---
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: /tmp/project │
╰─────────────────────────────────────────────╯
background session
Ask Codex to do anything
gpt-5.3-codex default · 100% left · /tmp/project

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: /tmp/project │
╰─────────────────────────────────────────────╯
Ask Codex to do anything
gpt-5.3-codex default · 100% left · /tmp/project

View File

@@ -0,0 +1,27 @@
---
source: tui/src/app/fork_session_overlay.rs
expression: snapshot_buffer(&buf)
---
╭─────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ model: gpt-5.3-codex /model to change │
│ directory: /tmp/project │
╰─────────────────────────────────────────────╯
╭ fork session running ctrl+] prefix───────────────╮
background s│First fork session │
│ │
│> /tmp/first │
│ ╭ fork session running ctrl+] prefix───────────────╮
Ask Codex to│ │Second fork session │
│ │ │
gpt-5.3-code│ │> /tmp/second │
│ │ │
│ │ │
│ │ │
╰─────────────│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────╯

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: /tmp/project │
╰─────────────────────────────────────────────╯
╭ fork session running ctrl+] prefix───────────────────────────╮
│Independent Codex session │
background sessi│ │
│> /tmp/worktree │
│ │
│ready for a fresh turn │
Ask Codex to do │ │
│ │
gpt-5.3-codex de│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────╯

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: /tmp/project │
╰─────────────────────────────────────────────╯
╭ fork session running ctrl+] prefix───────────────────────────╮
│Independent Codex session │
background sessi│ │
│> /tmp/worktree │
│ │
│ready for a fresh turn │
Ask Codex to do │ │
│ │
gpt-5.3-codex de│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────╯

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

@@ -251,14 +251,35 @@ impl App {
/// Re-render the full transcript into the terminal scrollback in one call.
/// Useful when switching sessions to ensure prior history remains visible.
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
if !self.transcript_cells.is_empty() {
let width = tui.terminal.last_known_screen_size.width;
for cell in &self.transcript_cells {
tui.insert_history_lines(cell.display_lines(width));
}
let width = tui.terminal.last_known_screen_size.width;
let lines = self.transcript_history_lines(width);
if !lines.is_empty() {
tui.insert_history_lines(lines);
}
}
pub(crate) fn transcript_history_lines(&self, width: u16) -> Vec<ratatui::text::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("".into());
} else {
has_lines = true;
}
}
lines.append(&mut display);
}
lines
}
/// Initialize backtrack state and show composer hint.
fn prime_backtrack(&mut self) {
self.backtrack.primed = true;

View File

@@ -96,6 +96,7 @@ pub(crate) async fn run_cwd_selection_prompt(
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Mouse(_) => {}
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {

View File

@@ -153,8 +153,12 @@ 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;
#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))]
mod voice;
mod vt100_backend;
mod vt100_render;
#[cfg(target_os = "linux")]
#[allow(dead_code)]
mod voice {

View File

@@ -152,6 +152,7 @@ pub(crate) async fn run_model_migration_prompt(
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Mouse(_) => {}
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
let _ = alt.tui.draw(u16::MAX, |frame| {

View File

@@ -457,6 +457,7 @@ pub(crate) async fn run_onboarding_app(
TuiEvent::Key(key_event) => {
onboarding_screen.handle_key_event(key_event);
}
TuiEvent::Mouse(_) => {}
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}

View File

@@ -1,9 +1,13 @@
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";
pub(crate) const PARENT_FG_RGB_ENV_VAR: &str = "CODEX_TUI_PARENT_FG_RGB";
pub(crate) const SKIP_DEFAULT_COLOR_PROBE_ENV_VAR: &str = "CODEX_TUI_SKIP_DEFAULT_COLOR_PROBE";
fn bump_palette_version() {
DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed);
@@ -70,11 +74,11 @@ pub fn default_colors() -> Option<DefaultColors> {
}
pub fn default_fg() -> Option<(u8, u8, u8)> {
default_colors().map(|c| c.fg)
env_default_fg().or_else(|| default_colors().map(|c| c.fg))
}
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 +89,39 @@ pub fn palette_version() -> u64 {
DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed)
}
fn should_skip_default_color_probe() -> bool {
matches!(
env::var(SKIP_DEFAULT_COLOR_PROBE_ENV_VAR).ok().as_deref(),
Some("1" | "true" | "TRUE" | "True")
)
}
fn inherited_default_colors() -> Option<DefaultColors> {
Some(DefaultColors {
fg: env_default_fg()?,
bg: env_default_bg()?,
})
}
fn env_default_fg() -> Option<(u8, u8, u8)> {
parse_rgb_env(&env::var(PARENT_FG_RGB_ENV_VAR).ok()?)
}
fn env_default_bg() -> Option<(u8, u8, u8)> {
parse_rgb_env(&env::var(PARENT_BG_RGB_ENV_VAR).ok()?)
}
fn parse_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;
@@ -132,11 +169,31 @@ mod imp {
pub(super) fn default_colors() -> Option<DefaultColors> {
let cache = default_colors_cache();
let mut cache = cache.lock().ok()?;
if let Some(colors) = super::inherited_default_colors() {
cache.attempted = true;
cache.value = Some(colors);
return Some(colors);
}
if super::should_skip_default_color_probe() {
cache.attempted = true;
cache.value = None;
return None;
}
cache.get_or_init_with(|| query_default_colors().unwrap_or_default())
}
pub(super) fn requery_default_colors() {
if let Ok(mut cache) = default_colors_cache().lock() {
if let Some(colors) = super::inherited_default_colors() {
cache.attempted = true;
cache.value = Some(colors);
return;
}
if super::should_skip_default_color_probe() {
cache.attempted = true;
cache.value = None;
return;
}
// Don't try to refresh if the cache is already attempted and failed.
if cache.attempted && cache.value.is_none() {
return;
@@ -437,3 +494,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_rgb_env;
use pretty_assertions::assert_eq;
#[test]
fn parse_rgb_env_accepts_rgb_triplet() {
assert_eq!(parse_rgb_env("12,34,56"), Some((12, 34, 56)));
}
#[test]
fn parse_rgb_env_rejects_invalid_values() {
assert_eq!(parse_rgb_env("12,34"), None);
assert_eq!(parse_rgb_env("12,34,56,78"), None);
assert_eq!(parse_rgb_env("12,nope,56"), None);
}
}

View File

@@ -16,10 +16,13 @@ use crossterm::Command;
use crossterm::SynchronizedUpdate;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::DisableFocusChange;
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableBracketedPaste;
use crossterm::event::EnableFocusChange;
use crossterm::event::EnableMouseCapture;
use crossterm::event::KeyEvent;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::MouseEvent;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::terminal::EnterAlternateScreen;
@@ -58,6 +61,8 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME
/// A type alias for the terminal type used in this application
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
pub(crate) const PARENT_ENHANCED_KEYS_SUPPORTED_ENV_VAR: &str =
"CODEX_TUI_PARENT_ENHANCED_KEYS_SUPPORTED";
pub fn set_modes() -> Result<()> {
execute!(stdout(), EnableBracketedPaste)?;
@@ -129,6 +134,7 @@ fn restore_common(should_disable_raw_mode: bool) -> Result<()> {
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
execute!(stdout(), DisableBracketedPaste)?;
let _ = execute!(stdout(), DisableFocusChange);
let _ = execute!(stdout(), DisableMouseCapture);
if should_disable_raw_mode {
disable_raw_mode()?;
}
@@ -223,6 +229,17 @@ pub fn init() -> Result<Terminal> {
Ok(tui)
}
fn inherited_enhanced_keys_supported() -> Option<bool> {
match std::env::var(PARENT_ENHANCED_KEYS_SUPPORTED_ENV_VAR)
.ok()?
.as_str()
{
"1" | "true" | "TRUE" | "True" => Some(true),
"0" | "false" | "FALSE" | "False" => Some(false),
_ => None,
}
}
fn set_panic_hook() {
let hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
@@ -234,6 +251,7 @@ fn set_panic_hook() {
#[derive(Clone, Debug)]
pub enum TuiEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Paste(String),
Draw,
}
@@ -255,6 +273,7 @@ pub struct Tui {
notification_backend: Option<DesktopNotificationBackend>,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
mouse_capture_enabled: bool,
}
impl Tui {
@@ -264,7 +283,11 @@ impl Tui {
// Detect keyboard enhancement support before any EventStream is created so the
// crossterm poller can acquire its lock without contention.
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
let enhanced_keys_supported = if let Some(supported) = inherited_enhanced_keys_supported() {
supported
} else {
supports_keyboard_enhancement().unwrap_or(false)
};
// Cache this to avoid contention with the event reader.
supports_color::on_cached(supports_color::Stream::Stdout);
let _ = crate::terminal_palette::default_colors();
@@ -283,6 +306,7 @@ impl Tui {
enhanced_keys_supported,
notification_backend: Some(detect_backend(NotificationMethod::default())),
alt_screen_enabled: true,
mouse_capture_enabled: false,
}
}
@@ -291,6 +315,11 @@ impl Tui {
self.alt_screen_enabled = enabled;
}
pub fn set_mouse_capture_enabled(&mut self, enabled: bool) -> Result<()> {
self.mouse_capture_enabled = enabled;
self.apply_mouse_capture_state()
}
pub fn set_notification_method(&mut self, method: NotificationMethod) {
self.notification_backend = Some(detect_backend(method));
}
@@ -333,6 +362,7 @@ impl Tui {
// Leave alt screen if active to avoid conflicts with external program `f`.
let was_alt_screen = self.is_alt_screen_active();
let mouse_capture_enabled = self.mouse_capture_enabled;
if was_alt_screen {
let _ = self.leave_alt_screen();
}
@@ -346,6 +376,9 @@ impl Tui {
if let Err(err) = set_modes() {
tracing::warn!("failed to re-enable terminal modes after external program: {err}");
}
if mouse_capture_enabled && let Err(err) = self.apply_mouse_capture_state() {
tracing::warn!("failed to re-enable mouse capture after external program: {err}");
}
// After the external program `f` finishes, reset terminal state and flush any buffered keypresses.
flush_terminal_input_buffer();
@@ -357,6 +390,15 @@ impl Tui {
output
}
fn apply_mouse_capture_state(&mut self) -> Result<()> {
if self.mouse_capture_enabled {
execute!(self.terminal.backend_mut(), EnableMouseCapture)?;
} else {
execute!(self.terminal.backend_mut(), DisableMouseCapture)?;
}
Ok(())
}
/// Emit a desktop notification now if the terminal is unfocused.
/// Returns true if a notification was posted.
pub fn notify(&mut self, message: impl AsRef<str>) -> bool {

View File

@@ -172,11 +172,11 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
/// Poll the shared crossterm stream for the next mapped `TuiEvent`.
///
/// This skips events we don't use (mouse events, etc.) and keeps polling until it yields
/// This skips events we don't use and keeps polling until it yields
/// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops
/// the underlying stream and returns `Pending` to fully release stdin.
pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll<Option<TuiEvent>> {
// Some crossterm events map to None (e.g. FocusLost, mouse); loop so we keep polling
// Some crossterm events map to None (e.g. FocusLost); loop so we keep polling
// until we return a mapped event, hit Pending, or see EOF/error.
loop {
let poll_result = {
@@ -233,7 +233,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
}
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.).
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use.
fn map_crossterm_event(&mut self, event: Event) -> Option<TuiEvent> {
match event {
Event::Key(key_event) => {
@@ -244,6 +244,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
Some(TuiEvent::Key(key_event))
}
Event::Mouse(mouse_event) => Some(TuiEvent::Mouse(mouse_event)),
Event::Resize(_, _) => Some(TuiEvent::Draw),
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
Event::FocusGained => {
@@ -255,7 +256,6 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
self.terminal_focused.store(false, Ordering::Relaxed);
None
}
_ => None,
}
}
}
@@ -297,6 +297,9 @@ mod tests {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use pretty_assertions::assert_eq;
use std::task::Context;
use std::task::Poll;
@@ -408,6 +411,26 @@ mod tests {
}
}
#[tokio::test(flavor = "current_thread")]
async fn mouse_event_is_forwarded() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream(broker, draw_rx, terminal_focused);
let expected_mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 12,
row: 7,
modifiers: KeyModifiers::NONE,
};
handle.send(Ok(Event::Mouse(expected_mouse)));
let next = stream.next().await.unwrap();
match next {
TuiEvent::Mouse(mouse) => assert_eq!(mouse, expected_mouse),
other => panic!("expected mouse event, got {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn draw_and_key_events_yield_both() {
let (broker, handle, draw_tx, draw_rx, terminal_focused) = setup();

View File

@@ -56,6 +56,7 @@ pub(crate) async fn run_update_prompt_if_needed(
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Mouse(_) => {}
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {

View File

@@ -0,0 +1,113 @@
use std::fmt;
use std::io;
use std::io::Write;
use ratatui::backend::Backend;
use ratatui::backend::ClearType;
use ratatui::backend::CrosstermBackend;
use ratatui::backend::WindowSize;
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)),
}
}