mirror of
https://github.com/openai/codex.git
synced 2026-05-06 20:36:33 +00:00
Compare commits
9 Commits
pr20432
...
re/fork-po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb31c9619f | ||
|
|
5938a9e5b5 | ||
|
|
f5c15fa5ce | ||
|
|
c17c8433f8 | ||
|
|
ffc441bb5d | ||
|
|
4910cdea38 | ||
|
|
7503033ab7 | ||
|
|
d6e8a05212 | ||
|
|
18f0e36fe1 |
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
1539
codex-rs/tui/src/app/fork_session_overlay.rs
Normal file
1539
codex-rs/tui/src/app/fork_session_overlay.rs
Normal file
File diff suppressed because it is too large
Load Diff
195
codex-rs/tui/src/app/fork_session_overlay_mouse.rs
Normal file
195
codex-rs/tui/src/app/fork_session_overlay_mouse.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
172
codex-rs/tui/src/app/fork_session_overlay_stack.rs
Normal file
172
codex-rs/tui/src/app/fork_session_overlay_stack.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
238
codex-rs/tui/src/app/fork_session_overlay_vt100_tests.rs
Normal file
238
codex-rs/tui/src/app/fork_session_overlay_vt100_tests.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
460
codex-rs/tui/src/app/fork_session_terminal.rs
Normal file
460
codex-rs/tui/src/app/fork_session_terminal.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
╰─────────────│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────╯
|
||||
@@ -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│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────╯
|
||||
@@ -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│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────╯
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
113
codex-rs/tui/src/vt100_backend.rs
Normal file
113
codex-rs/tui/src/vt100_backend.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
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