mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Add PTY-backed fork session popup overlay
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
989
codex-rs/tui/src/app/fork_session_overlay.rs
Normal file
989
codex-rs/tui/src/app/fork_session_overlay.rs
Normal 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;
|
||||
230
codex-rs/tui/src/app/fork_session_overlay_vt100_tests.rs
Normal file
230
codex-rs/tui/src/app/fork_session_overlay_vt100_tests.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
457
codex-rs/tui/src/app/fork_session_terminal.rs
Normal file
457
codex-rs/tui/src/app/fork_session_terminal.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────╯
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
117
codex-rs/tui/src/vt100_backend.rs
Normal file
117
codex-rs/tui/src/vt100_backend.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
71
codex-rs/tui/src/vt100_render.rs
Normal file
71
codex-rs/tui/src/vt100_render.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user