From c07343f8a4590b4523aee767358b8d182df870ad Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 9 Feb 2026 18:51:54 -0300 Subject: [PATCH 01/32] feat(tui): add Alt+C hotkey for copying last agent response --- codex-rs/tui/src/chatwidget.rs | 41 ++++++++++ codex-rs/tui/src/clipboard_copy.rs | 116 +++++++++++++++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + 3 files changed, 158 insertions(+) create mode 100644 codex-rs/tui/src/clipboard_copy.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c110660549be..777c9a59904a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -779,6 +779,8 @@ pub(crate) struct ChatWidget { // Latest agent message observed during the active turn. App-server turn completion // notifications do not repeat this payload, so we promote it when the turn completes. pending_turn_copyable_output: Option, + /// Raw markdown of the most recently completed agent response. + last_agent_markdown: Option, running_commands: HashMap, collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, @@ -2278,6 +2280,7 @@ impl ChatWidget { self.agent_turn_running = true; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); + self.last_agent_markdown = None; self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; self.last_plan_progress = None; @@ -2306,11 +2309,17 @@ impl ChatWidget { fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { self.submit_pending_steers_after_interrupt = false; let copyable_turn_output = last_agent_message + .as_ref() .filter(|message| !message.trim().is_empty()) + .cloned() .or_else(|| self.pending_turn_copyable_output.take()); if let Some(message) = copyable_turn_output.as_ref() { self.last_copyable_output = Some(message.clone()); } + self.last_agent_markdown = last_agent_message + .as_ref() + .filter(|message| !message.is_empty()) + .cloned(); // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); if let Some(mut controller) = self.plan_stream_controller.take() @@ -4652,6 +4661,7 @@ impl ChatWidget { agent_turn_running: false, mcp_startup_status: None, pending_turn_copyable_output: None, + last_agent_markdown: None, mcp_startup_expected_servers: None, mcp_startup_ignore_updates_until_next_start: false, mcp_startup_allow_terminal_only_next_round: false, @@ -4759,6 +4769,16 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + // Alt+C - copy last agent response from the main view. + KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press, + .. + } => { + self.copy_last_agent_markdown(); + return; + } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -4993,6 +5013,27 @@ impl ChatWidget { false } + /// Copy the last agent response (raw markdown) to the system clipboard. + pub(crate) fn copy_last_agent_markdown(&mut self) { + match &self.last_agent_markdown { + Some(markdown) if !markdown.is_empty() => { + match crate::clipboard_copy::copy_to_clipboard(markdown) { + Ok(()) => self.add_to_history(history_cell::new_info_event( + "Copied last message to clipboard".into(), + None, + )), + Err(error) => self.add_to_history(history_cell::new_error_event(format!( + "Copy failed: {error}" + ))), + } + } + _ => self.add_to_history(history_cell::new_error_event( + "No agent response to copy".into(), + )), + } + self.request_redraw(); + } + fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs new file mode 100644 index 000000000000..679e785ec707 --- /dev/null +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -0,0 +1,116 @@ +use base64::Engine; +use std::io::Write; + +/// Copy text to the system clipboard. +/// +/// Over SSH, prefers OSC 52 so the text reaches the *local* terminal emulator's +/// clipboard rather than a remote X11/Wayland clipboard that the user cannot +/// access. On a local session, tries `arboard` (native clipboard) first and +/// falls back to OSC 52 if that fails. +/// +/// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others. +pub(crate) fn copy_to_clipboard(text: &str) -> Result<(), String> { + if is_ssh_session() { + // Over SSH the native clipboard writes to the remote machine which is + // useless. Prefer OSC 52 which travels through the SSH tunnel to the + // local terminal emulator. + _ = osc52_copy(text).map_err(|osc_err| { + tracing::warn!("OSC 52 clipboard copy failed: {osc_err}"); + }); + } + + match arboard_copy(text) { + Ok(()) => Ok(()), + Err(native_err) => { + tracing::warn!("native clipboard copy failed: {native_err}, falling back to OSC 52"); + osc52_copy(text).map_err(|osc_err| { + format!("native clipboard: {native_err}; OSC 52 fallback: {osc_err}") + }) + } + } +} + +/// Detect whether the current process is running inside an SSH session. +fn is_ssh_session() -> bool { + std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some() +} + +/// Run arboard with stderr suppressed. +/// +/// On macOS, `arboard::Clipboard::new()` initializes `NSPasteboard` which +/// triggers `os_log` / `NSLog` output on stderr. Because the TUI owns the +/// terminal, that stray output corrupts the display. We temporarily redirect +/// fd 2 to `/dev/null` around the call to keep the screen clean. +fn arboard_copy(text: &str) -> Result<(), String> { + let _guard = SuppressStderr::new(); + let mut clipboard = + arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; + clipboard + .set_text(text) + .map_err(|e| format!("failed to set clipboard text: {e}")) +} + +/// RAII guard that redirects stderr (fd 2) to `/dev/null` on creation and +/// restores the original fd on drop. +struct SuppressStderr { + saved_fd: Option, +} + +impl SuppressStderr { + fn new() -> Self { + unsafe { + // Save the current stderr fd. + let saved = libc::dup(2); + if saved < 0 { + return Self { saved_fd: None }; + } + // Open /dev/null and point fd 2 at it. + let devnull = libc::open(b"/dev/null\0".as_ptr().cast(), libc::O_WRONLY); + if devnull >= 0 { + libc::dup2(devnull, 2); + libc::close(devnull); + } + Self { + saved_fd: Some(saved), + } + } + } +} + +impl Drop for SuppressStderr { + fn drop(&mut self) { + if let Some(saved) = self.saved_fd { + unsafe { + libc::dup2(saved, 2); + libc::close(saved); + } + } + } +} + +/// Write text to the clipboard via the OSC 52 terminal escape sequence. +fn osc52_copy(text: &str) -> Result<(), String> { + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + let sequence = format!("\x1b]52;c;{encoded}\x07"); + let mut stdout = std::io::stdout().lock(); + stdout + .write_all(sequence.as_bytes()) + .map_err(|e| format!("failed to write OSC 52: {e}"))?; + stdout + .flush() + .map_err(|e| format!("failed to flush OSC 52: {e}")) +} + +#[cfg(test)] +mod tests { + #[test] + fn osc52_encoding_roundtrips() { + use base64::Engine; + let text = "# Hello\n\n```rust\nfn main() {}\n```\n"; + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&encoded) + .unwrap(); + assert_eq!(decoded, text.as_bytes()); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index facae979f9f4..2314e61575e0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -100,6 +100,7 @@ mod audio_device { mod bottom_pane; mod chatwidget; mod cli; +mod clipboard_copy; mod clipboard_paste; mod clipboard_text; mod collaboration_modes; From 859abbfcd32467e00b98861e5b03804a6686491a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 9 Feb 2026 22:31:16 -0300 Subject: [PATCH 02/32] feat(tui): add /copy command and harden copy behavior --- codex-rs/tui/src/chatwidget.rs | 21 ++++- codex-rs/tui/src/clipboard_copy.rs | 145 +++++++++++++++++++++++++++-- codex-rs/tui/src/slash_command.rs | 4 +- 3 files changed, 158 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 777c9a59904a..c235e41bca9a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5237,6 +5237,9 @@ impl ChatWidget { // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); // } + SlashCommand::Copy => { + self.copy_last_agent_markdown(); + } SlashCommand::Diff => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); @@ -6887,18 +6890,30 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), - EventMsg::AgentMessage(AgentMessageEvent { .. }) + EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) - && !self.is_review_mode => {} + && !self.is_review_mode => + { + if !message.is_empty() { + self.last_agent_markdown = Some(message); + } + } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if from_replay || self.is_review_mode => { + if !message.is_empty() { + self.last_agent_markdown = Some(message.clone()); + } // TODO(ccunningham): stop relying on legacy AgentMessage in review mode, // including thread-snapshot replay, and forward // ItemCompleted(TurnItem::AgentMessage(_)) instead. self.on_agent_message(message) } - EventMsg::AgentMessage(AgentMessageEvent { .. }) => {} + EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { + if !message.is_empty() { + self.last_agent_markdown = Some(message); + } + } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) } diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index 679e785ec707..7414c4de5ef8 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -3,27 +3,37 @@ use std::io::Write; /// Copy text to the system clipboard. /// -/// Over SSH, prefers OSC 52 so the text reaches the *local* terminal emulator's +/// Over SSH, uses OSC 52 so the text reaches the *local* terminal emulator's /// clipboard rather than a remote X11/Wayland clipboard that the user cannot /// access. On a local session, tries `arboard` (native clipboard) first and /// falls back to OSC 52 if that fails. /// /// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others. pub(crate) fn copy_to_clipboard(text: &str) -> Result<(), String> { - if is_ssh_session() { + copy_to_clipboard_with(text, is_ssh_session(), osc52_copy, arboard_copy) +} + +fn copy_to_clipboard_with( + text: &str, + ssh_session: bool, + osc52_copy_fn: impl Fn(&str) -> Result<(), String>, + arboard_copy_fn: impl Fn(&str) -> Result<(), String>, +) -> Result<(), String> { + if ssh_session { // Over SSH the native clipboard writes to the remote machine which is - // useless. Prefer OSC 52 which travels through the SSH tunnel to the + // useless. Use OSC 52, which travels through the SSH tunnel to the // local terminal emulator. - _ = osc52_copy(text).map_err(|osc_err| { - tracing::warn!("OSC 52 clipboard copy failed: {osc_err}"); + return osc52_copy_fn(text).map_err(|osc_err| { + tracing::warn!("OSC 52 clipboard copy failed over SSH: {osc_err}"); + format!("OSC 52 clipboard copy failed over SSH: {osc_err}") }); } - match arboard_copy(text) { + match arboard_copy_fn(text) { Ok(()) => Ok(()), Err(native_err) => { tracing::warn!("native clipboard copy failed: {native_err}, falling back to OSC 52"); - osc52_copy(text).map_err(|osc_err| { + osc52_copy_fn(text).map_err(|osc_err| { format!("native clipboard: {native_err}; OSC 52 fallback: {osc_err}") }) } @@ -103,6 +113,11 @@ fn osc52_copy(text: &str) -> Result<(), String> { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use std::cell::Cell; + + use super::copy_to_clipboard_with; + #[test] fn osc52_encoding_roundtrips() { use base64::Engine; @@ -113,4 +128,120 @@ mod tests { .unwrap(); assert_eq!(decoded, text.as_bytes()); } + + #[test] + fn ssh_uses_osc52_and_skips_native_on_success() { + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + true, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Ok(()) + }, + ); + + assert_eq!(result, Ok(())); + assert_eq!(osc_calls.get(), 1); + assert_eq!(native_calls.get(), 0); + } + + #[test] + fn ssh_returns_osc52_error_and_skips_native() { + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + true, + |_| { + osc_calls.set(osc_calls.get() + 1); + Err("blocked".into()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Ok(()) + }, + ); + + assert_eq!( + result, + Err("OSC 52 clipboard copy failed over SSH: blocked".into()) + ); + assert_eq!(osc_calls.get(), 1); + assert_eq!(native_calls.get(), 0); + } + + #[test] + fn local_uses_native_clipboard_first() { + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + false, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Ok(()) + }, + ); + + assert_eq!(result, Ok(())); + assert_eq!(osc_calls.get(), 0); + assert_eq!(native_calls.get(), 1); + } + + #[test] + fn local_falls_back_to_osc52_when_native_fails() { + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + false, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Err("native unavailable".into()) + }, + ); + + assert_eq!(result, Ok(())); + assert_eq!(osc_calls.get(), 1); + assert_eq!(native_calls.get(), 1); + } + + #[test] + fn local_reports_both_errors_when_native_and_osc52_fail() { + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + false, + |_| { + osc_calls.set(osc_calls.get() + 1); + Err("osc blocked".into()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Err("native unavailable".into()) + }, + ); + + assert_eq!( + result, + Err("native clipboard: native unavailable; OSC 52 fallback: osc blocked".into()) + ); + assert_eq!(osc_calls.get(), 1); + assert_eq!(native_calls.get(), 1); + } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index b1a6e97ad1a8..635dc317f883 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -33,8 +33,8 @@ pub enum SlashCommand { Collab, Agent, // Undo, - Diff, Copy, + Diff, Mention, Status, DebugConfig, @@ -81,8 +81,8 @@ impl SlashCommand { SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", + SlashCommand::Copy => "copy last response as markdown", SlashCommand::Diff => "show git diff (including untracked files)", - SlashCommand::Copy => "copy the latest Codex output to your clipboard", SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", From 5a8f7478bf0f9d40ff9952f93beaeabaffad8207 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 9 Feb 2026 22:49:18 -0300 Subject: [PATCH 03/32] fix(tui): keep copy source while new turn is running --- codex-rs/tui/src/chatwidget.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c235e41bca9a..15d7834bc139 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2280,7 +2280,6 @@ impl ChatWidget { self.agent_turn_running = true; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); - self.last_agent_markdown = None; self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; self.last_plan_progress = None; From 012c2c8a6764149452a3a0181764bd22c76d9f1c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 9 Feb 2026 23:11:01 -0300 Subject: [PATCH 04/32] fix(tui): fix copy source after transcript rollback --- codex-rs/tui/src/app_backtrack.rs | 32 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 54 +++++++++++++++++++++++++++--- codex-rs/tui/src/clipboard_copy.rs | 2 +- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index db1149e7694f..bc1db45a8c08 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -30,6 +30,7 @@ use std::sync::Arc; use crate::app::App; use crate::app_command::AppCommand; use crate::app_event::AppEvent; +use crate::history_cell::AgentMessageCell; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; @@ -480,6 +481,9 @@ impl App { if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) { return false; } + let remaining = agent_group_count(&self.transcript_cells); + self.chat_widget + .truncate_agent_turn_markdowns(remaining); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; true @@ -501,6 +505,9 @@ impl App { &mut self.transcript_cells, pending.selection.nth_user_message, ) { + let remaining = agent_group_count(&self.transcript_cells); + self.chat_widget + .truncate_agent_turn_markdowns(remaining); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; } @@ -635,6 +642,31 @@ fn user_positions_iter( .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) } +fn agent_group_count(cells: &[Arc]) -> usize { + agent_group_positions_iter(cells).count() +} + +fn agent_group_positions_iter( + cells: &[Arc], +) -> impl Iterator + '_ { + let session_start_type = TypeId::of::(); + let type_of = |cell: &Arc| cell.as_any().type_id(); + + let start = cells + .iter() + .rposition(|cell| type_of(cell) == session_start_type) + .map_or(0, |idx| idx + 1); + + cells + .iter() + .enumerate() + .skip(start) + .filter_map(move |(idx, cell)| { + let is_agent = cell.as_any().downcast_ref::().is_some(); + (is_agent && !cell.is_stream_continuation()).then_some(idx) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 15d7834bc139..377ffa8721bd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -781,6 +781,14 @@ pub(crate) struct ChatWidget { pending_turn_copyable_output: Option, /// Raw markdown of the most recently completed agent response. last_agent_markdown: Option, + /// Raw markdown for each completed agent response in this session timeline. + agent_turn_markdowns: Vec, + /// Whether this turn already emitted a full `AgentMessage`. + /// + /// Some models only provide `TurnComplete.last_agent_message`. This flag lets us use + /// `TurnComplete` as a fallback source without duplicating entries when `AgentMessage` was + /// already received in the same turn. + saw_agent_message_this_turn: bool, running_commands: HashMap, collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, @@ -1931,8 +1939,21 @@ impl ChatWidget { } } + fn record_agent_markdown(&mut self, message: &str) { + if message.is_empty() { + return; + } + let markdown = message.to_string(); + self.last_agent_markdown = Some(markdown.clone()); + self.agent_turn_markdowns.push(markdown); + self.saw_agent_message_this_turn = true; + } + // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) { + self.last_agent_markdown = None; + self.agent_turn_markdowns.clear(); + self.saw_agent_message_this_turn = false; self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(/*skills*/ None); @@ -2009,6 +2030,7 @@ impl ChatWidget { if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } + self.saw_agent_message_this_turn = false; self.submit_op(AppCommand::list_skills( Vec::new(), /*force_reload*/ true, @@ -2280,6 +2302,7 @@ impl ChatWidget { self.agent_turn_running = true; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); + self.saw_agent_message_this_turn = false; self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; self.last_plan_progress = None; @@ -2315,10 +2338,14 @@ impl ChatWidget { if let Some(message) = copyable_turn_output.as_ref() { self.last_copyable_output = Some(message.clone()); } - self.last_agent_markdown = last_agent_message + if let Some(message) = last_agent_message .as_ref() .filter(|message| !message.is_empty()) - .cloned(); + && !self.saw_agent_message_this_turn + { + self.record_agent_markdown(message); + } + self.saw_agent_message_this_turn = false; // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); if let Some(mut controller) = self.plan_stream_controller.take() @@ -4661,6 +4688,8 @@ impl ChatWidget { mcp_startup_status: None, pending_turn_copyable_output: None, last_agent_markdown: None, + agent_turn_markdowns: Vec::new(), + saw_agent_message_this_turn: false, mcp_startup_expected_servers: None, mcp_startup_ignore_updates_until_next_start: false, mcp_startup_allow_terminal_only_next_round: false, @@ -5033,6 +5062,21 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn truncate_agent_turn_markdowns(&mut self, remaining: usize) { + self.agent_turn_markdowns.truncate(remaining); + self.last_agent_markdown = self.agent_turn_markdowns.last().cloned(); + } + + #[cfg(test)] + pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { + self.last_agent_markdown.as_deref() + } + + #[cfg(test)] + pub(crate) fn agent_turn_markdown_count(&self) -> usize { + self.agent_turn_markdowns.len() + } + fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -6894,14 +6938,14 @@ impl ChatWidget { && !self.is_review_mode => { if !message.is_empty() { - self.last_agent_markdown = Some(message); + self.record_agent_markdown(&message); } } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if from_replay || self.is_review_mode => { if !message.is_empty() { - self.last_agent_markdown = Some(message.clone()); + self.record_agent_markdown(&message); } // TODO(ccunningham): stop relying on legacy AgentMessage in review mode, // including thread-snapshot replay, and forward @@ -6910,7 +6954,7 @@ impl ChatWidget { } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { if !message.is_empty() { - self.last_agent_markdown = Some(message); + self.record_agent_markdown(&message); } } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index 7414c4de5ef8..dce853092798 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -75,7 +75,7 @@ impl SuppressStderr { return Self { saved_fd: None }; } // Open /dev/null and point fd 2 at it. - let devnull = libc::open(b"/dev/null\0".as_ptr().cast(), libc::O_WRONLY); + let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY); if devnull >= 0 { libc::dup2(devnull, 2); libc::close(devnull); From f0e89af888affdec84ae303f9ae35438c689ddef Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 9 Feb 2026 23:21:56 -0300 Subject: [PATCH 05/32] fix(tui): gate clipboard backends for Android and macOS libc usage --- codex-rs/tui/src/clipboard_copy.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index dce853092798..ecc681c01561 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -51,6 +51,7 @@ fn is_ssh_session() -> bool { /// triggers `os_log` / `NSLog` output on stderr. Because the TUI owns the /// terminal, that stray output corrupts the display. We temporarily redirect /// fd 2 to `/dev/null` around the call to keep the screen clean. +#[cfg(not(target_os = "android"))] fn arboard_copy(text: &str) -> Result<(), String> { let _guard = SuppressStderr::new(); let mut clipboard = @@ -60,12 +61,19 @@ fn arboard_copy(text: &str) -> Result<(), String> { .map_err(|e| format!("failed to set clipboard text: {e}")) } +#[cfg(target_os = "android")] +fn arboard_copy(_text: &str) -> Result<(), String> { + Err("native clipboard unavailable on Android".to_string()) +} + /// RAII guard that redirects stderr (fd 2) to `/dev/null` on creation and /// restores the original fd on drop. +#[cfg(target_os = "macos")] struct SuppressStderr { saved_fd: Option, } +#[cfg(target_os = "macos")] impl SuppressStderr { fn new() -> Self { unsafe { @@ -87,6 +95,7 @@ impl SuppressStderr { } } +#[cfg(target_os = "macos")] impl Drop for SuppressStderr { fn drop(&mut self) { if let Some(saved) = self.saved_fd { @@ -98,6 +107,16 @@ impl Drop for SuppressStderr { } } +#[cfg(not(target_os = "macos"))] +struct SuppressStderr; + +#[cfg(not(target_os = "macos"))] +impl SuppressStderr { + fn new() -> Self { + Self + } +} + /// Write text to the clipboard via the OSC 52 terminal escape sequence. fn osc52_copy(text: &str) -> Result<(), String> { let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); From 5df469704f0cb0c7685b6a7bc245caf4048a91c9 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 00:35:00 -0300 Subject: [PATCH 06/32] fix(tui): harden copy source rollback and clipboard paths --- codex-rs/tui/src/app_backtrack.rs | 41 +++++++++++++++++++++++++- codex-rs/tui/src/chatwidget.rs | 10 +++++-- codex-rs/tui/src/clipboard_copy.rs | 46 +++++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index bc1db45a8c08..1bad9064c1f3 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -43,6 +43,8 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +const CONTEXT_COMPACTED_MARKER: &str = "Context compacted"; + /// Aggregates all backtrack-related state used by the App. #[derive(Default)] pub(crate) struct BacktrackState { @@ -663,10 +665,31 @@ fn agent_group_positions_iter( .skip(start) .filter_map(move |(idx, cell)| { let is_agent = cell.as_any().downcast_ref::().is_some(); - (is_agent && !cell.is_stream_continuation()).then_some(idx) + let is_copy_source_group = + is_agent && !cell.is_stream_continuation() && !is_compaction_marker_cell(cell); + is_copy_source_group.then_some(idx) }) } +fn is_compaction_marker_cell(cell: &Arc) -> bool { + let Some(agent_cell) = cell.as_any().downcast_ref::() else { + return false; + }; + let rendered_lines = crate::history_cell::HistoryCell::display_lines(agent_cell, u16::MAX); + let Some(first_line) = rendered_lines.first() else { + return false; + }; + + line_text(first_line).trim_start_matches(['•', ' ']).trim() == CONTEXT_COMPACTED_MARKER +} + +fn line_text(line: &ratatui::text::Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -863,4 +886,20 @@ mod tests { .collect(); assert_eq!(intro_text, "• intro"); } + + #[test] + fn agent_group_count_ignores_context_compacted_marker() { + let cells: Vec> = vec![ + Arc::new(AgentMessageCell::new(vec![Line::from("first")], true)) + as Arc, + Arc::new(AgentMessageCell::new( + vec![Line::from("Context compacted")], + true, + )) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("second")], true)) + as Arc, + ]; + + assert_eq!(agent_group_count(&cells), 2); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 377ffa8721bd..5a18d3fec074 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -253,6 +253,7 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; +const MAX_AGENT_COPY_HISTORY: usize = 256; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; /// Choose the keybinding used to edit the most-recently queued message. @@ -1943,9 +1944,12 @@ impl ChatWidget { if message.is_empty() { return; } - let markdown = message.to_string(); - self.last_agent_markdown = Some(markdown.clone()); - self.agent_turn_markdowns.push(markdown); + self.agent_turn_markdowns.push(message.to_string()); + if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { + let overflow = self.agent_turn_markdowns.len() - MAX_AGENT_COPY_HISTORY; + self.agent_turn_markdowns.drain(0..overflow); + } + self.last_agent_markdown = self.agent_turn_markdowns.last().cloned(); self.saw_agent_message_this_turn = true; } diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index ecc681c01561..ecd07600ffda 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -1,6 +1,11 @@ use base64::Engine; use std::io::Write; +const OSC52_MAX_RAW_BYTES: usize = 100_000; +#[cfg(target_os = "macos")] +static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock> = + std::sync::OnceLock::new(); + /// Copy text to the system clipboard. /// /// Over SSH, uses OSC 52 so the text reaches the *local* terminal emulator's @@ -53,6 +58,11 @@ fn is_ssh_session() -> bool { /// fd 2 to `/dev/null` around the call to keep the screen clean. #[cfg(not(target_os = "android"))] fn arboard_copy(text: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let _stderr_lock = STDERR_SUPPRESSION_MUTEX + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .map_err(|_| "stderr suppression lock poisoned".to_string())?; let _guard = SuppressStderr::new(); let mut clipboard = arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; @@ -119,8 +129,7 @@ impl SuppressStderr { /// Write text to the clipboard via the OSC 52 terminal escape sequence. fn osc52_copy(text: &str) -> Result<(), String> { - let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - let sequence = format!("\x1b]52;c;{encoded}\x07"); + let sequence = osc52_sequence(text)?; let mut stdout = std::io::stdout().lock(); stdout .write_all(sequence.as_bytes()) @@ -130,24 +139,53 @@ fn osc52_copy(text: &str) -> Result<(), String> { .map_err(|e| format!("failed to flush OSC 52: {e}")) } +fn osc52_sequence(text: &str) -> Result { + let raw_bytes = text.len(); + if raw_bytes > OSC52_MAX_RAW_BYTES { + return Err(format!( + "OSC 52 payload too large ({raw_bytes} bytes; max {OSC52_MAX_RAW_BYTES})" + )); + } + + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + Ok(format!("\x1b]52;c;{encoded}\x07")) +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use std::cell::Cell; + use super::OSC52_MAX_RAW_BYTES; use super::copy_to_clipboard_with; + use super::osc52_sequence; #[test] fn osc52_encoding_roundtrips() { use base64::Engine; let text = "# Hello\n\n```rust\nfn main() {}\n```\n"; - let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + let sequence = osc52_sequence(text).expect("OSC 52 sequence"); + let encoded = sequence + .trim_start_matches("\u{1b}]52;c;") + .trim_end_matches('\u{7}'); let decoded = base64::engine::general_purpose::STANDARD - .decode(&encoded) + .decode(encoded) .unwrap(); assert_eq!(decoded, text.as_bytes()); } + #[test] + fn osc52_rejects_payload_larger_than_limit() { + let text = "x".repeat(OSC52_MAX_RAW_BYTES + 1); + assert_eq!( + osc52_sequence(&text), + Err(format!( + "OSC 52 payload too large ({} bytes; max {OSC52_MAX_RAW_BYTES})", + OSC52_MAX_RAW_BYTES + 1 + )) + ); + } + #[test] fn ssh_uses_osc52_and_skips_native_on_success() { let osc_calls = Cell::new(0_u8); From a5877c178da3cbb0f0db6c2ea3924cbd75f30737 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 00:53:21 -0300 Subject: [PATCH 07/32] fix(tui): clear quit shortcut state on Alt+C copy --- codex-rs/tui/src/chatwidget.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5a18d3fec074..d0dd32c53af5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4808,6 +4808,9 @@ impl ChatWidget { kind: KeyEventKind::Press, .. } => { + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; self.copy_last_agent_markdown(); return; } From 85a68156d85371f04f7a82637de622a6dac566bd Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 01:34:15 -0300 Subject: [PATCH 08/32] fix(tui): align rollback copy state with bounded history --- codex-rs/tui/src/app_backtrack.rs | 38 ++++++++---------------------- codex-rs/tui/src/chatwidget.rs | 17 ++++++++++--- codex-rs/tui/src/clipboard_copy.rs | 10 ++++++-- 3 files changed, 32 insertions(+), 33 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 1bad9064c1f3..5cd73e976679 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -43,8 +43,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -const CONTEXT_COMPACTED_MARKER: &str = "Context compacted"; - /// Aggregates all backtrack-related state used by the App. #[derive(Default)] pub(crate) struct BacktrackState { @@ -480,12 +478,14 @@ impl App { /// /// Returns `true` when local transcript state changed. pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool { + let before_count = agent_group_count(&self.transcript_cells); if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) { return false; } let remaining = agent_group_count(&self.transcript_cells); + let removed = before_count.saturating_sub(remaining); self.chat_widget - .truncate_agent_turn_markdowns(remaining); + .drop_recent_agent_turn_markdowns(removed); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; true @@ -503,13 +503,15 @@ impl App { // Ignore rollbacks targeting a prior thread. return; } + let before_count = agent_group_count(&self.transcript_cells); if trim_transcript_cells_to_nth_user( &mut self.transcript_cells, pending.selection.nth_user_message, ) { let remaining = agent_group_count(&self.transcript_cells); + let removed = before_count.saturating_sub(remaining); self.chat_widget - .truncate_agent_turn_markdowns(remaining); + .drop_recent_agent_turn_markdowns(removed); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; } @@ -665,31 +667,11 @@ fn agent_group_positions_iter( .skip(start) .filter_map(move |(idx, cell)| { let is_agent = cell.as_any().downcast_ref::().is_some(); - let is_copy_source_group = - is_agent && !cell.is_stream_continuation() && !is_compaction_marker_cell(cell); + let is_copy_source_group = is_agent && !cell.is_stream_continuation(); is_copy_source_group.then_some(idx) }) } -fn is_compaction_marker_cell(cell: &Arc) -> bool { - let Some(agent_cell) = cell.as_any().downcast_ref::() else { - return false; - }; - let rendered_lines = crate::history_cell::HistoryCell::display_lines(agent_cell, u16::MAX); - let Some(first_line) = rendered_lines.first() else { - return false; - }; - - line_text(first_line).trim_start_matches(['•', ' ']).trim() == CONTEXT_COMPACTED_MARKER -} - -fn line_text(line: &ratatui::text::Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -892,9 +874,9 @@ mod tests { let cells: Vec> = vec![ Arc::new(AgentMessageCell::new(vec![Line::from("first")], true)) as Arc, - Arc::new(AgentMessageCell::new( - vec![Line::from("Context compacted")], - true, + Arc::new(crate::history_cell::new_info_event( + "Context compacted".to_string(), + None, )) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("second")], true)) as Arc, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d0dd32c53af5..d5be39df8bd6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2189,6 +2189,16 @@ impl ChatWidget { self.finalize_completed_assistant_message(Some(&message)); } + fn on_context_compacted(&mut self) { + self.flush_answer_stream_with_separator(); + self.handle_stream_finished(); + self.add_to_history(history_cell::new_info_event( + "Context compacted".to_owned(), + None, + )); + self.request_redraw(); + } + fn on_agent_message_delta(&mut self, delta: String) { self.handle_streaming_delta(delta); } @@ -5069,8 +5079,9 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn truncate_agent_turn_markdowns(&mut self, remaining: usize) { - self.agent_turn_markdowns.truncate(remaining); + pub(crate) fn drop_recent_agent_turn_markdowns(&mut self, count: usize) { + let keep_len = self.agent_turn_markdowns.len().saturating_sub(count); + self.agent_turn_markdowns.truncate(keep_len); self.last_agent_markdown = self.agent_turn_markdowns.last().cloned(); } @@ -7101,7 +7112,7 @@ impl ChatWidget { self.on_entered_review_mode(review_request, from_replay) } EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), - EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), + EventMsg::ContextCompacted(_) => self.on_context_compacted(), EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { call_id, model, diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index ecd07600ffda..041bdd73f8e2 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -94,10 +94,16 @@ impl SuppressStderr { } // Open /dev/null and point fd 2 at it. let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY); - if devnull >= 0 { - libc::dup2(devnull, 2); + if devnull < 0 { + libc::close(saved); + return Self { saved_fd: None }; + } + if libc::dup2(devnull, 2) < 0 { + libc::close(saved); libc::close(devnull); + return Self { saved_fd: None }; } + libc::close(devnull); Self { saved_fd: Some(saved), } From 2ba6538493ff56b4965b9a6e140276281c67051e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 02:01:05 -0300 Subject: [PATCH 09/32] fix(tui): align rollback copy history with completed turns --- codex-rs/tui/src/app_backtrack.rs | 15 ++++---- codex-rs/tui/src/chatwidget.rs | 64 +++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 5cd73e976679..d12eac9bac6f 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -30,6 +30,7 @@ use std::sync::Arc; use crate::app::App; use crate::app_command::AppCommand; use crate::app_event::AppEvent; +#[cfg(test)] use crate::history_cell::AgentMessageCell; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; @@ -478,14 +479,12 @@ impl App { /// /// Returns `true` when local transcript state changed. pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool { - let before_count = agent_group_count(&self.transcript_cells); if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) { return false; } - let remaining = agent_group_count(&self.transcript_cells); - let removed = before_count.saturating_sub(remaining); + let remaining_turns = user_count(&self.transcript_cells); self.chat_widget - .drop_recent_agent_turn_markdowns(removed); + .truncate_agent_turn_markdowns_to_turn_count(remaining_turns); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; true @@ -503,15 +502,13 @@ impl App { // Ignore rollbacks targeting a prior thread. return; } - let before_count = agent_group_count(&self.transcript_cells); if trim_transcript_cells_to_nth_user( &mut self.transcript_cells, pending.selection.nth_user_message, ) { - let remaining = agent_group_count(&self.transcript_cells); - let removed = before_count.saturating_sub(remaining); + let remaining_turns = user_count(&self.transcript_cells); self.chat_widget - .drop_recent_agent_turn_markdowns(removed); + .truncate_agent_turn_markdowns_to_turn_count(remaining_turns); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; } @@ -646,10 +643,12 @@ fn user_positions_iter( .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) } +#[cfg(test)] fn agent_group_count(cells: &[Arc]) -> usize { agent_group_positions_iter(cells).count() } +#[cfg(test)] fn agent_group_positions_iter( cells: &[Arc], ) -> impl Iterator + '_ { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d5be39df8bd6..82bd92c387c7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -784,6 +784,10 @@ pub(crate) struct ChatWidget { last_agent_markdown: Option, /// Raw markdown for each completed agent response in this session timeline. agent_turn_markdowns: Vec, + /// Turn ordinal for each entry in `agent_turn_markdowns`. + agent_turn_markdown_turn_ordinals: Vec, + /// Number of completed turns observed in this session timeline. + completed_turn_count: usize, /// Whether this turn already emitted a full `AgentMessage`. /// /// Some models only provide `TurnComplete.last_agent_message`. This flag lets us use @@ -1944,11 +1948,30 @@ impl ChatWidget { if message.is_empty() { return; } - self.agent_turn_markdowns.push(message.to_string()); + let turn_ordinal = self.completed_turn_count.saturating_add(1); + let message = message.to_string(); + if self + .agent_turn_markdown_turn_ordinals + .last() + .copied() + .is_some_and(|ordinal| ordinal == turn_ordinal) + { + if let Some(last) = self.agent_turn_markdowns.last_mut() { + *last = message; + } + } else { + self.agent_turn_markdowns.push(message); + self.agent_turn_markdown_turn_ordinals.push(turn_ordinal); + } if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { let overflow = self.agent_turn_markdowns.len() - MAX_AGENT_COPY_HISTORY; self.agent_turn_markdowns.drain(0..overflow); + self.agent_turn_markdown_turn_ordinals.drain(0..overflow); } + debug_assert_eq!( + self.agent_turn_markdowns.len(), + self.agent_turn_markdown_turn_ordinals.len() + ); self.last_agent_markdown = self.agent_turn_markdowns.last().cloned(); self.saw_agent_message_this_turn = true; } @@ -1957,6 +1980,8 @@ impl ChatWidget { fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) { self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); + self.agent_turn_markdown_turn_ordinals.clear(); + self.completed_turn_count = 0; self.saw_agent_message_this_turn = false; self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); @@ -2343,6 +2368,7 @@ impl ChatWidget { } fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { + let turn_was_running = self.agent_turn_running; self.submit_pending_steers_after_interrupt = false; let copyable_turn_output = last_agent_message .as_ref() @@ -2359,6 +2385,11 @@ impl ChatWidget { { self.record_agent_markdown(message); } + let should_advance_completed_turn_count = + turn_was_running || !self.saw_agent_message_this_turn; + if should_advance_completed_turn_count { + self.completed_turn_count = self.completed_turn_count.saturating_add(1); + } self.saw_agent_message_this_turn = false; // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); @@ -4703,6 +4734,8 @@ impl ChatWidget { pending_turn_copyable_output: None, last_agent_markdown: None, agent_turn_markdowns: Vec::new(), + agent_turn_markdown_turn_ordinals: Vec::new(), + completed_turn_count: 0, saw_agent_message_this_turn: false, mcp_startup_expected_servers: None, mcp_startup_ignore_updates_until_next_start: false, @@ -5079,9 +5112,20 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn drop_recent_agent_turn_markdowns(&mut self, count: usize) { - let keep_len = self.agent_turn_markdowns.len().saturating_sub(count); - self.agent_turn_markdowns.truncate(keep_len); + pub(crate) fn truncate_agent_turn_markdowns_to_turn_count( + &mut self, + remaining_turn_count: usize, + ) { + while self + .agent_turn_markdown_turn_ordinals + .last() + .copied() + .is_some_and(|ordinal| ordinal > remaining_turn_count) + { + self.agent_turn_markdown_turn_ordinals.pop(); + self.agent_turn_markdowns.pop(); + } + self.completed_turn_count = self.completed_turn_count.min(remaining_turn_count); self.last_agent_markdown = self.agent_turn_markdowns.last().cloned(); } @@ -6955,25 +6999,37 @@ impl ChatWidget { if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && !self.is_review_mode => { + let count_as_completed_turn = !self.agent_turn_running && !message.is_empty(); if !message.is_empty() { self.record_agent_markdown(&message); } + if count_as_completed_turn { + self.completed_turn_count = self.completed_turn_count.saturating_add(1); + } } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if from_replay || self.is_review_mode => { + let count_as_completed_turn = !self.agent_turn_running && !message.is_empty(); if !message.is_empty() { self.record_agent_markdown(&message); } + if count_as_completed_turn { + self.completed_turn_count = self.completed_turn_count.saturating_add(1); + } // TODO(ccunningham): stop relying on legacy AgentMessage in review mode, // including thread-snapshot replay, and forward // ItemCompleted(TurnItem::AgentMessage(_)) instead. self.on_agent_message(message) } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { + let count_as_completed_turn = !self.agent_turn_running && !message.is_empty(); if !message.is_empty() { self.record_agent_markdown(&message); } + if count_as_completed_turn { + self.completed_turn_count = self.completed_turn_count.saturating_add(1); + } } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) From 718977e92ef576d7dee627a6e97e7cd810e70f5a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 02:11:28 -0300 Subject: [PATCH 10/32] fix(tui): recover copy source after deep rollback --- codex-rs/tui/src/app_backtrack.rs | 42 ++++++++++++++++++++++++++++--- codex-rs/tui/src/chatwidget.rs | 10 ++++++++ codex-rs/tui/src/history_cell.rs | 13 ++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index d12eac9bac6f..4382affdb757 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -30,7 +30,6 @@ use std::sync::Arc; use crate::app::App; use crate::app_command::AppCommand; use crate::app_event::AppEvent; -#[cfg(test)] use crate::history_cell::AgentMessageCell; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; @@ -483,8 +482,9 @@ impl App { return false; } let remaining_turns = user_count(&self.transcript_cells); + let fallback_markdown = last_agent_markdown_from_transcript(&self.transcript_cells); self.chat_widget - .truncate_agent_turn_markdowns_to_turn_count(remaining_turns); + .truncate_agent_turn_markdowns_to_turn_count(remaining_turns, fallback_markdown); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; true @@ -507,8 +507,9 @@ impl App { pending.selection.nth_user_message, ) { let remaining_turns = user_count(&self.transcript_cells); + let fallback_markdown = last_agent_markdown_from_transcript(&self.transcript_cells); self.chat_widget - .truncate_agent_turn_markdowns_to_turn_count(remaining_turns); + .truncate_agent_turn_markdowns_to_turn_count(remaining_turns, fallback_markdown); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; } @@ -643,6 +644,41 @@ fn user_positions_iter( .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) } +fn last_agent_markdown_from_transcript( + cells: &[Arc], +) -> Option { + let session_start_type = TypeId::of::(); + let type_of = |cell: &Arc| cell.as_any().type_id(); + + let start = cells + .iter() + .rposition(|cell| type_of(cell) == session_start_type) + .map_or(0, |idx| idx + 1); + let visible_cells = &cells[start..]; + + let group_start = visible_cells.iter().rposition(|cell| { + cell.as_any().downcast_ref::().is_some() && !cell.is_stream_continuation() + })?; + + let mut blocks: Vec = Vec::new(); + for (offset, cell) in visible_cells[group_start..].iter().enumerate() { + let Some(agent_cell) = cell.as_any().downcast_ref::() else { + break; + }; + if offset > 0 && !agent_cell.is_stream_continuation() { + break; + } + blocks.push(agent_cell.plain_text()); + } + + let merged = blocks + .into_iter() + .filter(|block| !block.is_empty()) + .collect::>() + .join("\n"); + (!merged.is_empty()).then_some(merged) +} + #[cfg(test)] fn agent_group_count(cells: &[Arc]) -> usize { agent_group_positions_iter(cells).count() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 82bd92c387c7..07ebef2d5c24 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5115,6 +5115,7 @@ impl ChatWidget { pub(crate) fn truncate_agent_turn_markdowns_to_turn_count( &mut self, remaining_turn_count: usize, + transcript_fallback: Option, ) { while self .agent_turn_markdown_turn_ordinals @@ -5125,6 +5126,15 @@ impl ChatWidget { self.agent_turn_markdown_turn_ordinals.pop(); self.agent_turn_markdowns.pop(); } + if self.agent_turn_markdowns.is_empty() + && let Some(fallback) = transcript_fallback + .map(|fallback| fallback.trim().to_string()) + .filter(|fallback| !fallback.is_empty()) + { + self.agent_turn_markdowns.push(fallback); + self.agent_turn_markdown_turn_ordinals + .push(remaining_turn_count); + } self.completed_turn_count = self.completed_turn_count.min(remaining_turn_count); self.last_agent_markdown = self.agent_turn_markdowns.last().cloned(); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 67c7e9f98b57..7a18d2c8b857 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -460,6 +460,19 @@ impl AgentMessageCell { is_first_line, } } + + pub(crate) fn plain_text(&self) -> String { + self.lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } } impl HistoryCell for AgentMessageCell { From aaf90a0488f130ae2a7e8436a7258973aec1755c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 10:39:36 -0300 Subject: [PATCH 11/32] fix(tui): harden copy history state and OSC52 write path --- codex-rs/tui/src/chatwidget.rs | 54 ++++++++++++++++-------------- codex-rs/tui/src/clipboard_copy.rs | 33 ++++++++++++++++-- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 07ebef2d5c24..f6ab3a82f2d2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -783,9 +783,7 @@ pub(crate) struct ChatWidget { /// Raw markdown of the most recently completed agent response. last_agent_markdown: Option, /// Raw markdown for each completed agent response in this session timeline. - agent_turn_markdowns: Vec, - /// Turn ordinal for each entry in `agent_turn_markdowns`. - agent_turn_markdown_turn_ordinals: Vec, + agent_turn_markdowns: Vec, /// Number of completed turns observed in this session timeline. completed_turn_count: usize, /// Whether this turn already emitted a full `AgentMessage`. @@ -1027,6 +1025,12 @@ pub(crate) struct UserMessage { mention_bindings: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct AgentTurnMarkdown { + ordinal: usize, + markdown: String, +} + #[derive(Debug, Clone, PartialEq, Default)] struct ThreadComposerState { text: String, @@ -1949,30 +1953,28 @@ impl ChatWidget { return; } let turn_ordinal = self.completed_turn_count.saturating_add(1); - let message = message.to_string(); if self - .agent_turn_markdown_turn_ordinals + .agent_turn_markdowns .last() - .copied() - .is_some_and(|ordinal| ordinal == turn_ordinal) + .is_some_and(|entry| entry.ordinal == turn_ordinal) { if let Some(last) = self.agent_turn_markdowns.last_mut() { - *last = message; + last.markdown = message.to_string(); } } else { - self.agent_turn_markdowns.push(message); - self.agent_turn_markdown_turn_ordinals.push(turn_ordinal); + self.agent_turn_markdowns.push(AgentTurnMarkdown { + ordinal: turn_ordinal, + markdown: message.to_string(), + }); } if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { let overflow = self.agent_turn_markdowns.len() - MAX_AGENT_COPY_HISTORY; self.agent_turn_markdowns.drain(0..overflow); - self.agent_turn_markdown_turn_ordinals.drain(0..overflow); } - debug_assert_eq!( - self.agent_turn_markdowns.len(), - self.agent_turn_markdown_turn_ordinals.len() - ); - self.last_agent_markdown = self.agent_turn_markdowns.last().cloned(); + self.last_agent_markdown = self + .agent_turn_markdowns + .last() + .map(|entry| entry.markdown.clone()); self.saw_agent_message_this_turn = true; } @@ -1980,7 +1982,6 @@ impl ChatWidget { fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) { self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); - self.agent_turn_markdown_turn_ordinals.clear(); self.completed_turn_count = 0; self.saw_agent_message_this_turn = false; self.bottom_pane @@ -4734,7 +4735,6 @@ impl ChatWidget { pending_turn_copyable_output: None, last_agent_markdown: None, agent_turn_markdowns: Vec::new(), - agent_turn_markdown_turn_ordinals: Vec::new(), completed_turn_count: 0, saw_agent_message_this_turn: false, mcp_startup_expected_servers: None, @@ -5118,12 +5118,10 @@ impl ChatWidget { transcript_fallback: Option, ) { while self - .agent_turn_markdown_turn_ordinals + .agent_turn_markdowns .last() - .copied() - .is_some_and(|ordinal| ordinal > remaining_turn_count) + .is_some_and(|entry| entry.ordinal > remaining_turn_count) { - self.agent_turn_markdown_turn_ordinals.pop(); self.agent_turn_markdowns.pop(); } if self.agent_turn_markdowns.is_empty() @@ -5131,12 +5129,16 @@ impl ChatWidget { .map(|fallback| fallback.trim().to_string()) .filter(|fallback| !fallback.is_empty()) { - self.agent_turn_markdowns.push(fallback); - self.agent_turn_markdown_turn_ordinals - .push(remaining_turn_count); + self.agent_turn_markdowns.push(AgentTurnMarkdown { + ordinal: remaining_turn_count, + markdown: fallback, + }); } self.completed_turn_count = self.completed_turn_count.min(remaining_turn_count); - self.last_agent_markdown = self.agent_turn_markdowns.last().cloned(); + self.last_agent_markdown = self + .agent_turn_markdowns + .last() + .map(|entry| entry.markdown.clone()); } #[cfg(test)] diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index 041bdd73f8e2..d022e610fb70 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -136,11 +136,29 @@ impl SuppressStderr { /// Write text to the clipboard via the OSC 52 terminal escape sequence. fn osc52_copy(text: &str) -> Result<(), String> { let sequence = osc52_sequence(text)?; - let mut stdout = std::io::stdout().lock(); - stdout + #[cfg(unix)] + { + match std::fs::OpenOptions::new().write(true).open("/dev/tty") { + Ok(tty) => match write_osc52_to_writer(tty, &sequence) { + Ok(()) => return Ok(()), + Err(err) => tracing::debug!( + "failed to write OSC 52 to /dev/tty: {err}; falling back to stdout" + ), + }, + Err(err) => { + tracing::debug!("failed to open /dev/tty for OSC 52: {err}; falling back to stdout") + } + } + } + + write_osc52_to_writer(std::io::stdout().lock(), &sequence) +} + +fn write_osc52_to_writer(mut writer: impl Write, sequence: &str) -> Result<(), String> { + writer .write_all(sequence.as_bytes()) .map_err(|e| format!("failed to write OSC 52: {e}"))?; - stdout + writer .flush() .map_err(|e| format!("failed to flush OSC 52: {e}")) } @@ -165,6 +183,7 @@ mod tests { use super::OSC52_MAX_RAW_BYTES; use super::copy_to_clipboard_with; use super::osc52_sequence; + use super::write_osc52_to_writer; #[test] fn osc52_encoding_roundtrips() { @@ -192,6 +211,14 @@ mod tests { ); } + #[test] + fn write_osc52_to_writer_emits_sequence_verbatim() { + let sequence = "\u{1b}]52;c;aGVsbG8=\u{7}"; + let mut output = Vec::new(); + assert_eq!(write_osc52_to_writer(&mut output, sequence), Ok(())); + assert_eq!(output, sequence.as_bytes()); + } + #[test] fn ssh_uses_osc52_and_skips_native_on_success() { let osc_calls = Cell::new(0_u8); From 91d40a715ba597aedad526bea05469c5971f90e8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 12:07:09 -0300 Subject: [PATCH 12/32] fix(tui): rollback copy source selection after resume --- codex-rs/tui/src/chatwidget.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f6ab3a82f2d2..905df499ff32 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5124,15 +5124,19 @@ impl ChatWidget { { self.agent_turn_markdowns.pop(); } - if self.agent_turn_markdowns.is_empty() - && let Some(fallback) = transcript_fallback - .map(|fallback| fallback.trim().to_string()) - .filter(|fallback| !fallback.is_empty()) + if let Some(fallback) = transcript_fallback + .map(|fallback| fallback.trim().to_string()) + .filter(|fallback| !fallback.is_empty()) { - self.agent_turn_markdowns.push(AgentTurnMarkdown { - ordinal: remaining_turn_count, - markdown: fallback, - }); + if let Some(last) = self.agent_turn_markdowns.last_mut() { + last.ordinal = remaining_turn_count; + last.markdown = fallback; + } else { + self.agent_turn_markdowns.push(AgentTurnMarkdown { + ordinal: remaining_turn_count, + markdown: fallback, + }); + } } self.completed_turn_count = self.completed_turn_count.min(remaining_turn_count); self.last_agent_markdown = self From 5604062b6fbb498660fc10f5092732c2ca65d741 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 17:10:46 -0300 Subject: [PATCH 13/32] fix(tui): align copy-history rollback with user turns --- codex-rs/tui/src/chatwidget.rs | 47 ++++++++++++---------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 905df499ff32..d085ff244e39 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1952,7 +1952,13 @@ impl ChatWidget { if message.is_empty() { return; } - let turn_ordinal = self.completed_turn_count.saturating_add(1); + let turn_ordinal = if self.completed_turn_count == 0 && !self.agent_turn_running { + self.agent_turn_markdowns + .last() + .map_or(1, |entry| entry.ordinal.saturating_add(1)) + } else { + self.completed_turn_count + }; if self .agent_turn_markdowns .last() @@ -2369,7 +2375,6 @@ impl ChatWidget { } fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { - let turn_was_running = self.agent_turn_running; self.submit_pending_steers_after_interrupt = false; let copyable_turn_output = last_agent_message .as_ref() @@ -2386,11 +2391,6 @@ impl ChatWidget { { self.record_agent_markdown(message); } - let should_advance_completed_turn_count = - turn_was_running || !self.saw_agent_message_this_turn; - if should_advance_completed_turn_count { - self.completed_turn_count = self.completed_turn_count.saturating_add(1); - } self.saw_agent_message_this_turn = false; // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); @@ -5124,19 +5124,15 @@ impl ChatWidget { { self.agent_turn_markdowns.pop(); } - if let Some(fallback) = transcript_fallback - .map(|fallback| fallback.trim().to_string()) - .filter(|fallback| !fallback.is_empty()) + if self.agent_turn_markdowns.is_empty() + && let Some(fallback) = transcript_fallback + .map(|fallback| fallback.trim().to_string()) + .filter(|fallback| !fallback.is_empty()) { - if let Some(last) = self.agent_turn_markdowns.last_mut() { - last.ordinal = remaining_turn_count; - last.markdown = fallback; - } else { - self.agent_turn_markdowns.push(AgentTurnMarkdown { - ordinal: remaining_turn_count, - markdown: fallback, - }); - } + self.agent_turn_markdowns.push(AgentTurnMarkdown { + ordinal: remaining_turn_count, + markdown: fallback, + }); } self.completed_turn_count = self.completed_turn_count.min(remaining_turn_count); self.last_agent_markdown = self @@ -7015,37 +7011,25 @@ impl ChatWidget { if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && !self.is_review_mode => { - let count_as_completed_turn = !self.agent_turn_running && !message.is_empty(); if !message.is_empty() { self.record_agent_markdown(&message); } - if count_as_completed_turn { - self.completed_turn_count = self.completed_turn_count.saturating_add(1); - } } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if from_replay || self.is_review_mode => { - let count_as_completed_turn = !self.agent_turn_running && !message.is_empty(); if !message.is_empty() { self.record_agent_markdown(&message); } - if count_as_completed_turn { - self.completed_turn_count = self.completed_turn_count.saturating_add(1); - } // TODO(ccunningham): stop relying on legacy AgentMessage in review mode, // including thread-snapshot replay, and forward // ItemCompleted(TurnItem::AgentMessage(_)) instead. self.on_agent_message(message) } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { - let count_as_completed_turn = !self.agent_turn_running && !message.is_empty(); if !message.is_empty() { self.record_agent_markdown(&message); } - if count_as_completed_turn { - self.completed_turn_count = self.completed_turn_count.saturating_add(1); - } } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) @@ -7389,6 +7373,7 @@ impl ChatWidget { event.local_images, remote_image_urls, )); + self.completed_turn_count = self.completed_turn_count.saturating_add(1); } // User messages reset separator state so the next agent response doesn't add a stray break. From 009a4afed0dc6d1bf2c9d6ec35eb6ec147c152a6 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 17:50:36 -0300 Subject: [PATCH 14/32] fix(tui): copy completed plan output in plan mode Promote non-empty TurnItem::Plan text to the active copy source so Alt+C and /copy copy the final plan output instead of prior commentary in the same turn. Adds regression coverage for replayed user message + commentary + completed plan item flow. --- codex-rs/tui/src/chatwidget.rs | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d085ff244e39..fb5982526789 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -318,7 +318,6 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; -use crate::clipboard_text; use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -2272,6 +2271,7 @@ impl ChatWidget { }; if !plan_text.trim().is_empty() { self.last_copyable_output = Some(plan_text.clone()); + self.record_agent_markdown(&plan_text); } // Plan commit ticks can hide the status row; remember whether we streamed plan output so // completion can restore it once stream queues are idle. @@ -5375,32 +5375,7 @@ impl ChatWidget { }); } SlashCommand::Copy => { - let Some(text) = self.last_copyable_output.as_deref() else { - self.add_info_message( - "`/copy` is unavailable before the first Codex output or right after a rollback." - .to_string(), - /*hint*/ None, - ); - return; - }; - - let copy_result = clipboard_text::copy_text_to_clipboard(text); - - match copy_result { - Ok(()) => { - let hint = self.agent_turn_running.then_some( - "Current turn is still running; copied the latest completed output (not the in-progress response)." - .to_string(), - ); - self.add_info_message( - "Copied latest Codex output to clipboard.".to_string(), - hint, - ); - } - Err(err) => { - self.add_error_message(format!("Failed to copy to clipboard: {err}")) - } - } + self.copy_last_agent_markdown(); } SlashCommand::Mention => { self.insert_str("@"); From 12e4660e9416bdeee11faac228960f9f9926da71 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 18:39:32 -0300 Subject: [PATCH 15/32] fix(tui): align copy-source semantics and backtrack test Rename the per-turn copy-source flag to reflect non-AgentMessage sources, clarify ordinal docs for copy-history entries, and make the backtrack continuation-group test assert real merged output. --- codex-rs/tui/src/chatwidget.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fb5982526789..681553d03e0f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -790,7 +790,7 @@ pub(crate) struct ChatWidget { /// Some models only provide `TurnComplete.last_agent_message`. This flag lets us use /// `TurnComplete` as a fallback source without duplicating entries when `AgentMessage` was /// already received in the same turn. - saw_agent_message_this_turn: bool, + saw_copy_source_this_turn: bool, running_commands: HashMap, collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, @@ -1980,7 +1980,7 @@ impl ChatWidget { .agent_turn_markdowns .last() .map(|entry| entry.markdown.clone()); - self.saw_agent_message_this_turn = true; + self.saw_copy_source_this_turn = true; } // --- Small event handlers --- @@ -1988,7 +1988,7 @@ impl ChatWidget { self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); self.completed_turn_count = 0; - self.saw_agent_message_this_turn = false; + self.saw_copy_source_this_turn = false; self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(/*skills*/ None); @@ -2065,7 +2065,7 @@ impl ChatWidget { if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } - self.saw_agent_message_this_turn = false; + self.saw_copy_source_this_turn = false; self.submit_op(AppCommand::list_skills( Vec::new(), /*force_reload*/ true, @@ -2348,7 +2348,7 @@ impl ChatWidget { self.agent_turn_running = true; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); - self.saw_agent_message_this_turn = false; + self.saw_copy_source_this_turn = false; self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; self.last_plan_progress = None; @@ -2387,11 +2387,11 @@ impl ChatWidget { if let Some(message) = last_agent_message .as_ref() .filter(|message| !message.is_empty()) - && !self.saw_agent_message_this_turn + && !self.saw_copy_source_this_turn { self.record_agent_markdown(message); } - self.saw_agent_message_this_turn = false; + self.saw_copy_source_this_turn = false; // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); if let Some(mut controller) = self.plan_stream_controller.take() @@ -4736,7 +4736,7 @@ impl ChatWidget { last_agent_markdown: None, agent_turn_markdowns: Vec::new(), completed_turn_count: 0, - saw_agent_message_this_turn: false, + saw_copy_source_this_turn: false, mcp_startup_expected_servers: None, mcp_startup_ignore_updates_until_next_start: false, mcp_startup_allow_terminal_only_next_round: false, From 5d74a811d254ef9b1b0835494e85815f6eca3d2b Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 10 Feb 2026 18:48:36 -0300 Subject: [PATCH 16/32] fix(tui): capture review output for copy shortcuts Record rendered review output on ExitedReviewMode so Alt+C and /copy use the latest review content, including explanation-only and findings-based review results. Adds regression tests for both paths. --- codex-rs/tui/src/chatwidget.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 681553d03e0f..a3cc56e23d1a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7304,6 +7304,8 @@ impl ChatWidget { #[cfg(test)] fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { if let Some(output) = review.review_output { + let review_markdown = codex_core::review_format::render_review_output_text(&output); + self.record_agent_markdown(&review_markdown); self.flush_answer_stream_with_separator(); self.flush_interrupt_queue(); self.flush_active_cell(); From 50f73c3193237a095cc14b48bf3910d63b932995 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 6 Apr 2026 16:26:14 -0300 Subject: [PATCH 17/32] refactor(tui): replace copy output state with markdown history Remove the newer /copy cache and clipboard_text module so the rebased branch consistently uses the markdown-history implementation. Update slash command tests and snapshots to assert the markdown history state, including rollback truncation and item-completion fallback. --- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/chatwidget.rs | 48 +--- ...ts__slash_copy_no_output_info_message.snap | 5 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 6 +- .../src/chatwidget/tests/slash_commands.rs | 71 ++---- codex-rs/tui/src/clipboard_text.rs | 218 ------------------ codex-rs/tui/src/lib.rs | 1 - 7 files changed, 31 insertions(+), 319 deletions(-) delete mode 100644 codex-rs/tui/src/clipboard_text.rs diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 4382affdb757..ff68f54b8c22 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -31,6 +31,7 @@ use crate::app::App; use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a3cc56e23d1a..3f144e871fb5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -774,11 +774,6 @@ pub(crate) struct ChatWidget { stream_controller: Option, // Stream lifecycle controller for proposed plan output. plan_stream_controller: Option, - // Latest completed user-visible Codex output that `/copy` should place on the clipboard. - last_copyable_output: Option, - // Latest agent message observed during the active turn. App-server turn completion - // notifications do not repeat this payload, so we promote it when the turn completes. - pending_turn_copyable_output: Option, /// Raw markdown of the most recently completed agent response. last_agent_markdown: Option, /// Raw markdown for each completed agent response in this session timeline. @@ -1951,7 +1946,7 @@ impl ChatWidget { if message.is_empty() { return; } - let turn_ordinal = if self.completed_turn_count == 0 && !self.agent_turn_running { + let turn_ordinal = if self.completed_turn_count == 0 { self.agent_turn_markdowns .last() .map_or(1, |entry| entry.ordinal.saturating_add(1)) @@ -2026,8 +2021,6 @@ impl ChatWidget { } self.config.approvals_reviewer = event.approvals_reviewer; self.status_line_project_root_name_cache = None; - self.last_copyable_output = None; - self.pending_turn_copyable_output = None; let forked_from_id = event.forked_from_id; let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); @@ -2270,7 +2263,6 @@ impl ChatWidget { text }; if !plan_text.trim().is_empty() { - self.last_copyable_output = Some(plan_text.clone()); self.record_agent_markdown(&plan_text); } // Plan commit ticks can hide the status row; remember whether we streamed plan output so @@ -2356,7 +2348,6 @@ impl ChatWidget { self.plan_item_active = false; self.adaptive_chunking.reset(); self.plan_stream_controller = None; - self.pending_turn_copyable_output = None; self.turn_runtime_metrics = RuntimeMetricsSummary::default(); self.session_telemetry.reset_runtime_metrics(); self.bottom_pane.clear_quit_shortcut_hint(); @@ -2376,14 +2367,6 @@ impl ChatWidget { fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { self.submit_pending_steers_after_interrupt = false; - let copyable_turn_output = last_agent_message - .as_ref() - .filter(|message| !message.trim().is_empty()) - .cloned() - .or_else(|| self.pending_turn_copyable_output.take()); - if let Some(message) = copyable_turn_output.as_ref() { - self.last_copyable_output = Some(message.clone()); - } if let Some(message) = last_agent_message .as_ref() .filter(|message| !message.is_empty()) @@ -2451,7 +2434,7 @@ impl ChatWidget { self.maybe_send_next_queued_input(); // Emit a notification when the turn completes (suppressed if focused). self.notify(Notification::AgentTurnComplete { - response: copyable_turn_output.unwrap_or_default(), + response: last_agent_message.unwrap_or_default(), }); self.maybe_show_pending_rate_limit_prompt(); @@ -2797,7 +2780,6 @@ impl ChatWidget { self.adaptive_chunking.reset(); self.stream_controller = None; self.plan_stream_controller = None; - self.pending_turn_copyable_output = None; self.pending_status_indicator_restore = false; self.request_status_line_branch_refresh(); self.maybe_show_pending_rate_limit_prompt(); @@ -4125,8 +4107,8 @@ impl ChatWidget { self.finalize_completed_assistant_message( (!message.is_empty()).then_some(message.as_str()), ); - if self.agent_turn_running && !message.is_empty() { - self.pending_turn_copyable_output = Some(message.clone()); + if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) && !message.is_empty() { + self.record_agent_markdown(&message); } self.pending_status_indicator_restore = match item.phase { // Models that don't support preambles only output AgentMessageItems on turn completion. @@ -4720,7 +4702,6 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, - last_copyable_output: None, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), @@ -4732,7 +4713,6 @@ impl ChatWidget { unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, - pending_turn_copyable_output: None, last_agent_markdown: None, agent_turn_markdowns: Vec::new(), completed_turn_count: 0, @@ -5146,11 +5126,6 @@ impl ChatWidget { self.last_agent_markdown.as_deref() } - #[cfg(test)] - pub(crate) fn agent_turn_markdown_count(&self) -> usize { - self.agent_turn_markdowns.len() - } - fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -5374,9 +5349,6 @@ impl ChatWidget { tx.send(AppEvent::DiffResult(text)); }); } - SlashCommand::Copy => { - self.copy_last_agent_markdown(); - } SlashCommand::Mention => { self.insert_str("@"); } @@ -6664,13 +6636,13 @@ impl ChatWidget { | ServerNotification::McpServerOauthLoginCompleted(_) | ServerNotification::AppListUpdated(_) | ServerNotification::FsChanged(_) - | ServerNotification::ContextCompacted(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) | ServerNotification::ThreadRealtimeTranscriptUpdated(_) | ServerNotification::WindowsWorldWritableWarning(_) | ServerNotification::WindowsSandboxSetupCompleted(_) | ServerNotification::AccountLoginCompleted(_) => {} + ServerNotification::ContextCompacted(_) => self.on_context_compacted(), } } @@ -6678,10 +6650,7 @@ impl ChatWidget { self.on_list_skills(response); } - pub(crate) fn handle_thread_rolled_back(&mut self) { - self.last_copyable_output = None; - self.pending_turn_copyable_output = None; - } + pub(crate) fn handle_thread_rolled_back(&mut self) {} fn on_mcp_server_elicitation_request( &mut self, @@ -7175,11 +7144,6 @@ impl ChatWidget { EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), EventMsg::ThreadRolledBack(rollback) => { - // Conservatively clear `/copy` state on rollback. The app layer trims visible - // transcript cells, but we do not maintain rollback-aware raw-markdown history yet, - // so keeping the previous cache can return content that was just removed. - self.last_copyable_output = None; - self.pending_turn_copyable_output = None; if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { num_turns: rollback.num_turns, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap index 3ade0924ebbd..63e525741750 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap @@ -1,5 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/slash_commands.rs +assertion_line: 145 expression: rendered --- -• `/copy` is unavailable before the first Codex output or right after a rollback. +■ No agent response to copy diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d5b30bc042b5..126e9b0efc11 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -206,8 +206,10 @@ pub(super) async fn make_chatwidget_manual( plan_stream_controller: None, pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, - last_copyable_output: None, - pending_turn_copyable_output: None, + last_agent_markdown: None, + agent_turn_markdowns: Vec::new(), + completed_turn_count: 0, + saw_copy_source_this_turn: false, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e2fda4168531..40b9bacc2ebd 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -103,8 +103,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { }); assert_eq!( - chat.last_copyable_output, - Some("Final reply **markdown**".to_string()) + chat.last_agent_markdown_text(), + Some("Final reply **markdown**") ); } @@ -134,11 +134,11 @@ async fn slash_copy_state_tracks_plan_item_completion() { }), }); - assert_eq!(chat.last_copyable_output, Some(plan_text)); + assert_eq!(chat.last_agent_markdown_text(), Some(plan_text.as_str())); } #[tokio::test] -async fn slash_copy_reports_when_no_copyable_output_exists() { +async fn slash_copy_reports_when_no_agent_response_exists() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Copy); @@ -148,9 +148,7 @@ async fn slash_copy_reports_when_no_copyable_output_exists() { let rendered = lines_to_single_string(&cells[0]); assert_chatwidget_snapshot!("slash_copy_no_output_info_message", rendered); assert!( - rendered.contains( - "`/copy` is unavailable before the first Codex output or right after a rollback." - ), + rendered.contains("No agent response to copy"), "expected no-output message, got {rendered:?}" ); } @@ -171,8 +169,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { chat.on_task_started(); assert_eq!( - chat.last_copyable_output, - Some("Previous completed reply".to_string()) + chat.last_agent_markdown_text(), + Some("Previous completed reply") ); } @@ -189,16 +187,13 @@ async fn slash_copy_state_clears_on_thread_rollback() { duration_ms: None, }), }); - chat.handle_codex_event(Event { - id: "rollback-1".into(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), - }); + chat.truncate_agent_turn_markdowns_to_turn_count(/*remaining_turn_count*/ 0, None); - assert_eq!(chat.last_copyable_output, None); + assert_eq!(chat.last_agent_markdown_text(), None); } #[tokio::test] -async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_turn_complete() { +async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omits_text() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_codex_event_replay(Event { @@ -221,16 +216,9 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ }); let _ = drain_insert_history(&mut rx); - chat.dispatch_command(SlashCommand::Copy); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one info message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains( - "`/copy` is unavailable before the first Codex output or right after a rollback." - ), - "expected unavailable message, got {rendered:?}" + assert_eq!( + chat.last_agent_markdown_text(), + Some("Legacy final message") ); } @@ -265,20 +253,9 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( }); let _ = drain_insert_history(&mut rx); - chat.dispatch_command(SlashCommand::Copy); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one info message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - !rendered.contains( - "`/copy` is unavailable before the first Codex output or right after a rollback." - ), - "expected copy state to be available, got {rendered:?}" - ); assert_eq!( - chat.last_copyable_output, - Some("Legacy item final message".to_string()) + chat.last_agent_markdown_text(), + Some("Legacy item final message") ); } @@ -313,23 +290,9 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { }); let _ = drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "rollback-1".into(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), - }); - let _ = drain_insert_history(&mut rx); + chat.truncate_agent_turn_markdowns_to_turn_count(/*remaining_turn_count*/ 0, None); - chat.dispatch_command(SlashCommand::Copy); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one info message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains( - "`/copy` is unavailable before the first Codex output or right after a rollback." - ), - "expected rollback-cleared copy state message, got {rendered:?}" - ); + assert_eq!(chat.last_agent_markdown_text(), None); } #[tokio::test] diff --git a/codex-rs/tui/src/clipboard_text.rs b/codex-rs/tui/src/clipboard_text.rs deleted file mode 100644 index eee94b98ffb2..000000000000 --- a/codex-rs/tui/src/clipboard_text.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Clipboard text copy support for `/copy` in the TUI. -//! -//! This module owns the policy for getting plain text from the running Codex -//! process into the user's system clipboard. It prefers the direct native -//! clipboard path when the current machine is also the user's desktop, but it -//! intentionally changes strategy in environments where a "local" clipboard -//! would be the wrong one: SSH sessions use OSC 52 so the user's terminal can -//! proxy the copy back to the client, and WSL shells fall back to -//! `powershell.exe` because Linux-side clipboard providers often cannot reach -//! the Windows clipboard reliably. -//! -//! The module is deliberately narrow. It only handles text copy, returns -//! user-facing error strings for the chat UI, and does not try to expose a -//! reusable clipboard abstraction for the rest of the application. Image paste -//! and WSL environment detection live in neighboring modules. -//! -//! The main operational contract is that callers get one best-effort copy -//! attempt and a readable failure message. The selection between native copy, -//! OSC 52, and WSL fallback is centralized here so `/copy` does not have to -//! understand platform-specific clipboard behavior. - -#[cfg(not(target_os = "android"))] -use base64::Engine as _; -#[cfg(all(not(target_os = "android"), unix))] -use std::fs::OpenOptions; -#[cfg(not(target_os = "android"))] -use std::io::Write; -#[cfg(all(not(target_os = "android"), windows))] -use std::io::stdout; -#[cfg(all(not(target_os = "android"), target_os = "linux"))] -use std::process::Stdio; - -#[cfg(all(not(target_os = "android"), target_os = "linux"))] -use crate::clipboard_paste::is_probably_wsl; - -/// Copies user-visible text into the most appropriate clipboard for the -/// current environment. -/// -/// In a normal desktop session this targets the host clipboard through -/// `arboard`. In SSH sessions it emits an OSC 52 sequence instead, because the -/// process-local clipboard would belong to the remote machine rather than the -/// user's terminal. On Linux under WSL, a failed native copy falls back to -/// `powershell.exe` so the Windows clipboard still works when Linux clipboard -/// integrations are unavailable. -/// -/// The returned error is intended for display in the TUI rather than for -/// programmatic branching. Callers should treat it as user-facing text. A -/// caller that assumes a specific substring means a stable failure category -/// will be brittle if the fallback policy or wording changes later. -/// -/// # Errors -/// -/// Returns a descriptive error string when the selected clipboard mechanism is -/// unavailable or the fallback path also fails. -#[cfg(not(target_os = "android"))] -pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> { - if std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() { - return copy_via_osc52(text); - } - - let error = match arboard::Clipboard::new() { - Ok(mut clipboard) => match clipboard.set_text(text.to_string()) { - Ok(()) => return Ok(()), - Err(err) => format!("clipboard unavailable: {err}"), - }, - Err(err) => format!("clipboard unavailable: {err}"), - }; - - #[cfg(target_os = "linux")] - let error = if is_probably_wsl() { - match copy_via_wsl_clipboard(text) { - Ok(()) => return Ok(()), - Err(wsl_err) => format!("{error}; WSL fallback failed: {wsl_err}"), - } - } else { - error - }; - - Err(error) -} - -/// Writes text through OSC 52 so the controlling terminal can own the copy. -/// -/// This path exists for remote sessions where the process-local clipboard is -/// not the clipboard the user actually wants. On Unix it writes directly to the -/// controlling TTY so the escape sequence reaches the terminal even if stdout -/// is redirected; on Windows it writes to stdout because the console is the -/// transport. -#[cfg(not(target_os = "android"))] -fn copy_via_osc52(text: &str) -> Result<(), String> { - let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some()); - #[cfg(unix)] - let mut tty = OpenOptions::new() - .write(true) - .open("/dev/tty") - .map_err(|e| { - format!("clipboard unavailable: failed to open /dev/tty for OSC 52 copy: {e}") - })?; - #[cfg(unix)] - tty.write_all(sequence.as_bytes()).map_err(|e| { - format!("clipboard unavailable: failed to write OSC 52 escape sequence: {e}") - })?; - #[cfg(unix)] - tty.flush().map_err(|e| { - format!("clipboard unavailable: failed to flush OSC 52 escape sequence: {e}") - })?; - #[cfg(windows)] - stdout().write_all(sequence.as_bytes()).map_err(|e| { - format!("clipboard unavailable: failed to write OSC 52 escape sequence: {e}") - })?; - #[cfg(windows)] - stdout().flush().map_err(|e| { - format!("clipboard unavailable: failed to flush OSC 52 escape sequence: {e}") - })?; - Ok(()) -} - -/// Copies text into the Windows clipboard from a WSL process. -/// -/// This is a Linux-only fallback for the case where `arboard` cannot talk to -/// the Windows clipboard from inside WSL. It shells out to `powershell.exe`, -/// streams the text over stdin as UTF-8, and waits for the process to report -/// success before returning to the caller. -#[cfg(all(not(target_os = "android"), target_os = "linux"))] -fn copy_via_wsl_clipboard(text: &str) -> Result<(), String> { - let mut child = std::process::Command::new("powershell.exe") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .args([ - "-NoProfile", - "-Command", - "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; $ErrorActionPreference = 'Stop'; $text = [Console]::In.ReadToEnd(); Set-Clipboard -Value $text", - ]) - .spawn() - .map_err(|e| format!("clipboard unavailable: failed to spawn powershell.exe: {e}"))?; - - let Some(mut stdin) = child.stdin.take() else { - let _ = child.kill(); - let _ = child.wait(); - return Err("clipboard unavailable: failed to open powershell.exe stdin".to_string()); - }; - - if let Err(err) = stdin.write_all(text.as_bytes()) { - let _ = child.kill(); - let _ = child.wait(); - return Err(format!( - "clipboard unavailable: failed to write to powershell.exe: {err}" - )); - } - - drop(stdin); - - let output = child - .wait_with_output() - .map_err(|e| format!("clipboard unavailable: failed to wait for powershell.exe: {e}"))?; - - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if stderr.is_empty() { - let status = output.status; - Err(format!( - "clipboard unavailable: powershell.exe exited with status {status}" - )) - } else { - Err(format!( - "clipboard unavailable: powershell.exe failed: {stderr}" - )) - } - } -} - -/// Encodes text as an OSC 52 clipboard sequence. -/// -/// When `tmux` is true the sequence is wrapped in the tmux passthrough form so -/// nested terminals still receive the clipboard escape. -#[cfg(not(target_os = "android"))] -fn osc52_sequence(text: &str, tmux: bool) -> String { - let payload = base64::engine::general_purpose::STANDARD.encode(text); - if tmux { - format!("\x1bPtmux;\x1b\x1b]52;c;{payload}\x07\x1b\\") - } else { - format!("\x1b]52;c;{payload}\x07") - } -} - -/// Reports that clipboard text copy is unavailable on Android builds. -/// -/// The TUI's clipboard implementation depends on host integrations that are not -/// available in the supported Android/Termux environment. -#[cfg(target_os = "android")] -pub fn copy_text_to_clipboard(_text: &str) -> Result<(), String> { - Err("clipboard text copy is unsupported on Android".into()) -} - -#[cfg(all(test, not(target_os = "android")))] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn osc52_sequence_encodes_text_for_terminal_clipboard() { - assert_eq!( - osc52_sequence("hello", /*tmux*/ false), - "\u{1b}]52;c;aGVsbG8=\u{7}" - ); - } - - #[test] - fn osc52_sequence_wraps_tmux_passthrough() { - assert_eq!( - osc52_sequence("hello", /*tmux*/ true), - "\u{1b}Ptmux;\u{1b}\u{1b}]52;c;aGVsbG8=\u{7}\u{1b}\\" - ); - } -} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2314e61575e0..7ec0127f3b23 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -102,7 +102,6 @@ mod chatwidget; mod cli; mod clipboard_copy; mod clipboard_paste; -mod clipboard_text; mod collaboration_modes; mod color; pub(crate) mod custom_terminal; From 82b942038de8e4075818c30c8a71cb1989e38492 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 6 Apr 2026 17:17:56 -0300 Subject: [PATCH 18/32] fix(tui): keep linux clipboard owner alive Keep the native Linux `arboard::Clipboard` in a TUI-held lease after `/copy` succeeds so X11 and Wayland clipboard contents remain available while Codex is running. Thread the optional lease through copy handling, preserve the previous lease on failed copies, and cover the behavior in focused copy tests. --- codex-rs/tui/src/chatwidget.rs | 28 ++++-- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/slash_commands.rs | 34 ++++++++ codex-rs/tui/src/clipboard_copy.rs | 87 ++++++++++++++----- 4 files changed, 119 insertions(+), 31 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3f144e871fb5..c3853d30a67c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -774,6 +774,7 @@ pub(crate) struct ChatWidget { stream_controller: Option, // Stream lifecycle controller for proposed plan output. plan_stream_controller: Option, + clipboard_lease: Option, /// Raw markdown of the most recently completed agent response. last_agent_markdown: Option, /// Raw markdown for each completed agent response in this session timeline. @@ -4702,6 +4703,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + clipboard_lease: None, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), @@ -5073,18 +5075,26 @@ impl ChatWidget { /// Copy the last agent response (raw markdown) to the system clipboard. pub(crate) fn copy_last_agent_markdown(&mut self) { - match &self.last_agent_markdown { - Some(markdown) if !markdown.is_empty() => { - match crate::clipboard_copy::copy_to_clipboard(markdown) { - Ok(()) => self.add_to_history(history_cell::new_info_event( + self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); + } + + fn copy_last_agent_markdown_with( + &mut self, + copy_fn: impl FnOnce(&str) -> Result, String>, + ) { + match self.last_agent_markdown.clone() { + Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) { + Ok(lease) => { + self.clipboard_lease = lease; + self.add_to_history(history_cell::new_info_event( "Copied last message to clipboard".into(), None, - )), - Err(error) => self.add_to_history(history_cell::new_error_event(format!( - "Copy failed: {error}" - ))), + )); } - } + Err(error) => self.add_to_history(history_cell::new_error_event(format!( + "Copy failed: {error}" + ))), + }, _ => self.add_to_history(history_cell::new_error_event( "No agent response to copy".into(), )), diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 126e9b0efc11..01167f987411 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -204,6 +204,7 @@ pub(super) async fn make_chatwidget_manual( adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + clipboard_lease: None, pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, last_agent_markdown: None, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 40b9bacc2ebd..1e8016b2955b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -153,6 +153,40 @@ async fn slash_copy_reports_when_no_agent_response_exists() { ); } +#[tokio::test] +async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.last_agent_markdown = Some("copy me".to_string()); + + chat.copy_last_agent_markdown_with(|markdown| { + assert_eq!(markdown, "copy me"); + Ok(Some(crate::clipboard_copy::ClipboardLease::test())) + }); + + assert!(chat.clipboard_lease.is_some()); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one success message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Copied last message to clipboard"), + "expected success message, got {rendered:?}" + ); + + chat.copy_last_agent_markdown_with(|markdown| { + assert_eq!(markdown, "copy me"); + Err("blocked".into()) + }); + + assert!(chat.clipboard_lease.is_some()); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one failure message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Copy failed: blocked"), + "expected failure message, got {rendered:?}" + ); +} + #[tokio::test] async fn slash_copy_state_is_preserved_during_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index d022e610fb70..abf8cea32a60 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -14,31 +14,54 @@ static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock> = /// falls back to OSC 52 if that fails. /// /// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others. -pub(crate) fn copy_to_clipboard(text: &str) -> Result<(), String> { +pub(crate) fn copy_to_clipboard(text: &str) -> Result, String> { copy_to_clipboard_with(text, is_ssh_session(), osc52_copy, arboard_copy) } +/// Keeps a platform clipboard owner alive when the backend requires one. +pub(crate) struct ClipboardLease { + #[cfg(target_os = "linux")] + _clipboard: Option, +} + +impl ClipboardLease { + #[cfg(target_os = "linux")] + fn native_linux(clipboard: arboard::Clipboard) -> Self { + Self { + _clipboard: Some(clipboard), + } + } + + #[cfg(test)] + pub(crate) fn test() -> Self { + Self { + #[cfg(target_os = "linux")] + _clipboard: None, + } + } +} + fn copy_to_clipboard_with( text: &str, ssh_session: bool, osc52_copy_fn: impl Fn(&str) -> Result<(), String>, - arboard_copy_fn: impl Fn(&str) -> Result<(), String>, -) -> Result<(), String> { + arboard_copy_fn: impl Fn(&str) -> Result, String>, +) -> Result, String> { if ssh_session { // Over SSH the native clipboard writes to the remote machine which is // useless. Use OSC 52, which travels through the SSH tunnel to the // local terminal emulator. - return osc52_copy_fn(text).map_err(|osc_err| { + return osc52_copy_fn(text).map(|()| None).map_err(|osc_err| { tracing::warn!("OSC 52 clipboard copy failed over SSH: {osc_err}"); format!("OSC 52 clipboard copy failed over SSH: {osc_err}") }); } match arboard_copy_fn(text) { - Ok(()) => Ok(()), + Ok(lease) => Ok(lease), Err(native_err) => { tracing::warn!("native clipboard copy failed: {native_err}, falling back to OSC 52"); - osc52_copy_fn(text).map_err(|osc_err| { + osc52_copy_fn(text).map(|()| None).map_err(|osc_err| { format!("native clipboard: {native_err}; OSC 52 fallback: {osc_err}") }) } @@ -56,8 +79,8 @@ fn is_ssh_session() -> bool { /// triggers `os_log` / `NSLog` output on stderr. Because the TUI owns the /// terminal, that stray output corrupts the display. We temporarily redirect /// fd 2 to `/dev/null` around the call to keep the screen clean. -#[cfg(not(target_os = "android"))] -fn arboard_copy(text: &str) -> Result<(), String> { +#[cfg(all(not(target_os = "android"), not(target_os = "linux")))] +fn arboard_copy(text: &str) -> Result, String> { #[cfg(target_os = "macos")] let _stderr_lock = STDERR_SUPPRESSION_MUTEX .get_or_init(|| std::sync::Mutex::new(())) @@ -68,11 +91,28 @@ fn arboard_copy(text: &str) -> Result<(), String> { arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; clipboard .set_text(text) - .map_err(|e| format!("failed to set clipboard text: {e}")) + .map_err(|e| format!("failed to set clipboard text: {e}"))?; + Ok(None) +} + +/// Run arboard with stderr suppressed. +/// +/// On Linux/X11 and some Wayland setups, clipboard contents are served by the +/// process that last wrote them. Keep the `Clipboard` alive so the copied text +/// remains pasteable while the TUI is running. +#[cfg(target_os = "linux")] +fn arboard_copy(text: &str) -> Result, String> { + let _guard = SuppressStderr::new(); + let mut clipboard = + arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; + clipboard + .set_text(text) + .map_err(|e| format!("failed to set clipboard text: {e}"))?; + Ok(Some(ClipboardLease::native_linux(clipboard))) } #[cfg(target_os = "android")] -fn arboard_copy(_text: &str) -> Result<(), String> { +fn arboard_copy(_text: &str) -> Result, String> { Err("native clipboard unavailable on Android".to_string()) } @@ -232,11 +272,11 @@ mod tests { }, |_| { native_calls.set(native_calls.get() + 1); - Ok(()) + Ok(None) }, ); - assert_eq!(result, Ok(())); + assert!(matches!(result, Ok(None))); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 0); } @@ -254,14 +294,14 @@ mod tests { }, |_| { native_calls.set(native_calls.get() + 1); - Ok(()) + Ok(None) }, ); - assert_eq!( - result, - Err("OSC 52 clipboard copy failed over SSH: blocked".into()) - ); + let Err(error) = result else { + panic!("expected OSC 52 error"); + }; + assert_eq!(error, "OSC 52 clipboard copy failed over SSH: blocked"); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 0); } @@ -279,11 +319,11 @@ mod tests { }, |_| { native_calls.set(native_calls.get() + 1); - Ok(()) + Ok(Some(super::ClipboardLease::test())) }, ); - assert_eq!(result, Ok(())); + assert!(matches!(result, Ok(Some(_)))); assert_eq!(osc_calls.get(), 0); assert_eq!(native_calls.get(), 1); } @@ -305,7 +345,7 @@ mod tests { }, ); - assert_eq!(result, Ok(())); + assert!(matches!(result, Ok(None))); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 1); } @@ -327,9 +367,12 @@ mod tests { }, ); + let Err(error) = result else { + panic!("expected native and OSC 52 errors"); + }; assert_eq!( - result, - Err("native clipboard: native unavailable; OSC 52 fallback: osc blocked".into()) + error, + "native clipboard: native unavailable; OSC 52 fallback: osc blocked" ); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 1); From e66c276229ed377d0290f737280aa6670a162b99 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 6 Apr 2026 21:47:53 -0300 Subject: [PATCH 19/32] refactor(tui): remove stale rollback hook Remove the empty rollback callback now that copy-state rollback is owned by app_backtrack, and document the copy-state invariants. --- codex-rs/tui/src/app.rs | 1 - codex-rs/tui/src/app_backtrack.rs | 11 +++++++++++ codex-rs/tui/src/chatwidget.rs | 31 ++++++++++++++++++++++++------ codex-rs/tui/src/clipboard_copy.rs | 28 +++++++++++++++++++++++++++ codex-rs/tui/src/history_cell.rs | 5 +++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6986b9e59599..a05c2a10e41a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5695,7 +5695,6 @@ impl App { } } self.handle_backtrack_rollback_succeeded(num_turns); - self.chat_widget.handle_thread_rolled_back(); } fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index ff68f54b8c22..8e1706899039 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -645,6 +645,17 @@ fn user_positions_iter( .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) } +/// Reconstruct the plain text of the last agent response group from transcript cells. +/// +/// Used as a fallback when the ordinal-indexed markdown history has been fully +/// truncated by a rollback but the transcript still contains visible agent output. +/// Walks backward from the end of the visible portion (after the last session-start +/// marker) to find the final contiguous block of `AgentMessageCell`s, then joins +/// their display text. +/// +/// Because this uses `AgentMessageCell::plain_text()` (which joins rendered spans), +/// the result is display-level text rather than the original raw markdown. For most +/// responses these are identical. fn last_agent_markdown_from_transcript( cells: &[Arc], ) -> Option { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c3853d30a67c..56cf5aeb0b92 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -781,11 +781,12 @@ pub(crate) struct ChatWidget { agent_turn_markdowns: Vec, /// Number of completed turns observed in this session timeline. completed_turn_count: usize, - /// Whether this turn already emitted a full `AgentMessage`. + /// Whether this turn already produced a copyable response. /// - /// Some models only provide `TurnComplete.last_agent_message`. This flag lets us use - /// `TurnComplete` as a fallback source without duplicating entries when `AgentMessage` was - /// already received in the same turn. + /// `TurnComplete.last_agent_message` is a fallback source: use it only when no earlier + /// agent/plan/review item recorded copyable markdown for the turn. This gives item-level + /// sources precedence and avoids duplicating the same final answer when both event shapes are + /// emitted. saw_copy_source_this_turn: bool, running_commands: HashMap, collab_agent_metadata: HashMap, @@ -1020,9 +1021,17 @@ pub(crate) struct UserMessage { mention_bindings: Vec, } +/// A snapshot of the raw markdown for one completed agent turn. +/// +/// Entries are keyed by `ordinal` — the number of completed user turns at the +/// time the agent response was recorded. This allows rollbacks to truncate the +/// history by comparing ordinals against the remaining turn count without +/// maintaining a parallel index structure. #[derive(Clone, Debug, Eq, PartialEq)] struct AgentTurnMarkdown { + /// Monotonically increasing turn number derived from `completed_turn_count`. ordinal: usize, + /// The full raw markdown of the agent's response for this turn. markdown: String, } @@ -1943,6 +1952,12 @@ impl ChatWidget { } } + /// Record or update the raw markdown for the current agent turn. + /// + /// If the current turn already has an entry (same ordinal), it is overwritten + /// rather than appended — a turn's markdown is the *last* agent message seen, + /// not a concatenation. The history is bounded by `MAX_AGENT_COPY_HISTORY`; + /// overflow drains the oldest entries. fn record_agent_markdown(&mut self, message: &str) { if message.is_empty() { return; @@ -5102,6 +5117,12 @@ impl ChatWidget { self.request_redraw(); } + /// Trim the markdown history to match a rollback. + /// + /// Called by `app_backtrack` after transcript cells have been trimmed. Pops + /// entries whose ordinal exceeds `remaining_turn_count`, then uses + /// `transcript_fallback` (reconstructed from surviving `AgentMessageCell`s) if + /// the ordinal history is now empty but the transcript still has agent output. pub(crate) fn truncate_agent_turn_markdowns_to_turn_count( &mut self, remaining_turn_count: usize, @@ -6660,8 +6681,6 @@ impl ChatWidget { self.on_list_skills(response); } - pub(crate) fn handle_thread_rolled_back(&mut self) {} - fn on_mcp_server_elicitation_request( &mut self, request_id: codex_protocol::mcp::RequestId, diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index abf8cea32a60..ab3323c02fe6 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -1,6 +1,26 @@ +//! Clipboard copy backend for the TUI's `/copy` command and `Alt+C` hotkey. +//! +//! This module decides *how* to get text onto the user's clipboard based on the +//! current environment. The selection order is: +//! +//! 1. **SSH session** (`SSH_TTY` / `SSH_CONNECTION` set): use OSC 52 exclusively, +//! because the native clipboard belongs to the remote machine. +//! 2. **Local session**: try `arboard` (native clipboard) first; fall back to +//! OSC 52 if `arboard` fails (e.g. headless Linux, broken Wayland socket). +//! +//! On Linux, X11 and some Wayland compositors require the process that wrote the +//! clipboard to keep its handle open. `ClipboardLease` wraps the `arboard::Clipboard` +//! so callers can store it for the lifetime of the TUI. On other platforms the lease +//! is always `None`. +//! +//! The module is intentionally narrow: text copy only, user-facing error strings, +//! no reusable clipboard abstraction. Image paste lives in `clipboard_paste`. + use base64::Engine; use std::io::Write; +/// Maximum raw bytes we will base64-encode into an OSC 52 sequence. +/// Large payloads are rejected before encoding to avoid overwhelming the terminal. const OSC52_MAX_RAW_BYTES: usize = 100_000; #[cfg(target_os = "macos")] static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock> = @@ -19,6 +39,12 @@ pub(crate) fn copy_to_clipboard(text: &str) -> Result, St } /// Keeps a platform clipboard owner alive when the backend requires one. +/// +/// On Linux/X11 and some Wayland compositors, clipboard contents are served by the +/// owning process. Dropping the `arboard::Clipboard` before the user pastes causes +/// the content to vanish. Store this lease on the widget that triggered the copy so +/// the handle lives as long as the TUI does. On macOS, Android, and OSC 52 paths +/// the lease is `None` — those backends do not require process-lifetime ownership. pub(crate) struct ClipboardLease { #[cfg(target_os = "linux")] _clipboard: Option, @@ -41,6 +67,8 @@ impl ClipboardLease { } } +/// Core copy logic with injected backends, enabling deterministic unit tests +/// without touching real clipboards or terminal I/O. fn copy_to_clipboard_with( text: &str, ssh_session: bool, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 7a18d2c8b857..4c153c139e7d 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -461,6 +461,11 @@ impl AgentMessageCell { } } + /// Join all spans into unstyled plain text, one line per entry in `self.lines`. + /// + /// Used by `last_agent_markdown_from_transcript` to reconstruct copy-source text + /// from rendered transcript cells after a rollback. The result strips style + /// information but preserves whitespace and newlines. pub(crate) fn plain_text(&self) -> String { self.lines .iter() From 980cd28ea754bc1b5b646460e0696106271e4c7f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 6 Apr 2026 22:01:56 -0300 Subject: [PATCH 20/32] fix(tui): restore wsl clipboard fallback Restore the WSL PowerShell clipboard fallback after native copy fails so WSL users are not dependent on terminal OSC 52 support. Keep SSH on OSC 52 only, preserve native clipboard as the first local path, and cover the fallback ordering with injected backend tests. --- codex-rs/tui/src/clipboard_copy.rs | 242 +++++++++++++++++++++++++++-- 1 file changed, 230 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index ab3323c02fe6..0dfb674a410d 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -5,8 +5,9 @@ //! //! 1. **SSH session** (`SSH_TTY` / `SSH_CONNECTION` set): use OSC 52 exclusively, //! because the native clipboard belongs to the remote machine. -//! 2. **Local session**: try `arboard` (native clipboard) first; fall back to -//! OSC 52 if `arboard` fails (e.g. headless Linux, broken Wayland socket). +//! 2. **Local session**: try `arboard` (native clipboard) first. On WSL, fall back +//! to the Windows clipboard through PowerShell if `arboard` fails. Finally, fall +//! back to OSC 52 if no native/WSL clipboard path succeeds. //! //! On Linux, X11 and some Wayland compositors require the process that wrote the //! clipboard to keep its handle open. `ClipboardLease` wraps the `arboard::Clipboard` @@ -31,11 +32,18 @@ static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock> = /// Over SSH, uses OSC 52 so the text reaches the *local* terminal emulator's /// clipboard rather than a remote X11/Wayland clipboard that the user cannot /// access. On a local session, tries `arboard` (native clipboard) first and -/// falls back to OSC 52 if that fails. +/// falls back to WSL PowerShell, then OSC 52, if needed. /// /// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others. pub(crate) fn copy_to_clipboard(text: &str) -> Result, String> { - copy_to_clipboard_with(text, is_ssh_session(), osc52_copy, arboard_copy) + copy_to_clipboard_with( + text, + is_ssh_session(), + is_wsl_session(), + osc52_copy, + arboard_copy, + wsl_clipboard_copy, + ) } /// Keeps a platform clipboard owner alive when the backend requires one. @@ -43,8 +51,9 @@ pub(crate) fn copy_to_clipboard(text: &str) -> Result, St /// On Linux/X11 and some Wayland compositors, clipboard contents are served by the /// owning process. Dropping the `arboard::Clipboard` before the user pastes causes /// the content to vanish. Store this lease on the widget that triggered the copy so -/// the handle lives as long as the TUI does. On macOS, Android, and OSC 52 paths -/// the lease is `None` — those backends do not require process-lifetime ownership. +/// the handle lives as long as the TUI does. On non-Linux native paths and OSC 52 +/// paths the lease is `None` — those backends do not require process-lifetime +/// ownership. pub(crate) struct ClipboardLease { #[cfg(target_os = "linux")] _clipboard: Option, @@ -72,8 +81,10 @@ impl ClipboardLease { fn copy_to_clipboard_with( text: &str, ssh_session: bool, + wsl_session: bool, osc52_copy_fn: impl Fn(&str) -> Result<(), String>, arboard_copy_fn: impl Fn(&str) -> Result, String>, + wsl_copy_fn: impl Fn(&str) -> Result<(), String>, ) -> Result, String> { if ssh_session { // Over SSH the native clipboard writes to the remote machine which is @@ -88,6 +99,24 @@ fn copy_to_clipboard_with( match arboard_copy_fn(text) { Ok(lease) => Ok(lease), Err(native_err) => { + if wsl_session { + tracing::warn!( + "native clipboard copy failed: {native_err}, falling back to WSL PowerShell" + ); + match wsl_copy_fn(text) { + Ok(()) => return Ok(None), + Err(wsl_err) => { + tracing::warn!( + "WSL PowerShell clipboard copy failed: {wsl_err}, falling back to OSC 52" + ); + return osc52_copy_fn(text).map(|()| None).map_err(|osc_err| { + format!( + "native clipboard: {native_err}; WSL fallback: {wsl_err}; OSC 52 fallback: {osc_err}" + ) + }); + } + } + } tracing::warn!("native clipboard copy failed: {native_err}, falling back to OSC 52"); osc52_copy_fn(text).map(|()| None).map_err(|osc_err| { format!("native clipboard: {native_err}; OSC 52 fallback: {osc_err}") @@ -101,6 +130,16 @@ fn is_ssh_session() -> bool { std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some() } +#[cfg(target_os = "linux")] +fn is_wsl_session() -> bool { + crate::clipboard_paste::is_probably_wsl() +} + +#[cfg(not(target_os = "linux"))] +fn is_wsl_session() -> bool { + false +} + /// Run arboard with stderr suppressed. /// /// On macOS, `arboard::Clipboard::new()` initializes `NSPasteboard` which @@ -144,6 +183,57 @@ fn arboard_copy(_text: &str) -> Result, String> { Err("native clipboard unavailable on Android".to_string()) } +/// Copy text into the Windows clipboard from a WSL process. +#[cfg(target_os = "linux")] +fn wsl_clipboard_copy(text: &str) -> Result<(), String> { + let mut child = std::process::Command::new("powershell.exe") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .args([ + "-NoProfile", + "-Command", + "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; $ErrorActionPreference = 'Stop'; $text = [Console]::In.ReadToEnd(); Set-Clipboard -Value $text", + ]) + .spawn() + .map_err(|e| format!("failed to spawn powershell.exe: {e}"))?; + + let Some(mut stdin) = child.stdin.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err("failed to open powershell.exe stdin".to_string()); + }; + + if let Err(err) = stdin.write_all(text.as_bytes()) { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("failed to write to powershell.exe: {err}")); + } + + drop(stdin); + + let output = child + .wait_with_output() + .map_err(|e| format!("failed to wait for powershell.exe: {e}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + let status = output.status; + Err(format!("powershell.exe exited with status {status}")) + } else { + Err(format!("powershell.exe failed: {stderr}")) + } + } +} + +#[cfg(not(target_os = "linux"))] +fn wsl_clipboard_copy(_text: &str) -> Result<(), String> { + Err("WSL clipboard fallback unavailable on this platform".to_string()) +} + /// RAII guard that redirects stderr (fd 2) to `/dev/null` on creation and /// restores the original fd on drop. #[cfg(target_os = "macos")] @@ -291,9 +381,11 @@ mod tests { fn ssh_uses_osc52_and_skips_native_on_success() { let osc_calls = Cell::new(0_u8); let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - true, + /*ssh_session*/ true, + /*wsl_session*/ true, |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) @@ -302,20 +394,27 @@ mod tests { native_calls.set(native_calls.get() + 1); Ok(None) }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, ); assert!(matches!(result, Ok(None))); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 0); + assert_eq!(wsl_calls.get(), 0); } #[test] fn ssh_returns_osc52_error_and_skips_native() { let osc_calls = Cell::new(0_u8); let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - true, + /*ssh_session*/ true, + /*wsl_session*/ true, |_| { osc_calls.set(osc_calls.get() + 1); Err("blocked".into()) @@ -324,6 +423,10 @@ mod tests { native_calls.set(native_calls.get() + 1); Ok(None) }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, ); let Err(error) = result else { @@ -332,15 +435,18 @@ mod tests { assert_eq!(error, "OSC 52 clipboard copy failed over SSH: blocked"); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 0); + assert_eq!(wsl_calls.get(), 0); } #[test] fn local_uses_native_clipboard_first() { let osc_calls = Cell::new(0_u8); let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - false, + /*ssh_session*/ false, + /*wsl_session*/ true, |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) @@ -349,20 +455,27 @@ mod tests { native_calls.set(native_calls.get() + 1); Ok(Some(super::ClipboardLease::test())) }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, ); assert!(matches!(result, Ok(Some(_)))); assert_eq!(osc_calls.get(), 0); assert_eq!(native_calls.get(), 1); + assert_eq!(wsl_calls.get(), 0); } #[test] - fn local_falls_back_to_osc52_when_native_fails() { + fn local_non_wsl_falls_back_to_osc52_when_native_fails() { let osc_calls = Cell::new(0_u8); let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - false, + /*ssh_session*/ false, + /*wsl_session*/ false, |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) @@ -371,20 +484,85 @@ mod tests { native_calls.set(native_calls.get() + 1); Err("native unavailable".into()) }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, ); assert!(matches!(result, Ok(None))); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 1); + assert_eq!(wsl_calls.get(), 0); + } + + #[test] + fn local_wsl_native_failure_uses_powershell_and_skips_osc52_on_success() { + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + /*ssh_session*/ false, + /*wsl_session*/ true, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Err("native unavailable".into()) + }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, + ); + + assert!(matches!(result, Ok(None))); + assert_eq!(osc_calls.get(), 0); + assert_eq!(native_calls.get(), 1); + assert_eq!(wsl_calls.get(), 1); + } + + #[test] + fn local_wsl_falls_back_to_osc52_when_native_and_powershell_fail() { + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + /*ssh_session*/ false, + /*wsl_session*/ true, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Err("native unavailable".into()) + }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Err("powershell unavailable".into()) + }, + ); + + assert!(matches!(result, Ok(None))); + assert_eq!(osc_calls.get(), 1); + assert_eq!(native_calls.get(), 1); + assert_eq!(wsl_calls.get(), 1); } #[test] fn local_reports_both_errors_when_native_and_osc52_fail() { let osc_calls = Cell::new(0_u8); let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - false, + /*ssh_session*/ false, + /*wsl_session*/ false, |_| { osc_calls.set(osc_calls.get() + 1); Err("osc blocked".into()) @@ -393,6 +571,10 @@ mod tests { native_calls.set(native_calls.get() + 1); Err("native unavailable".into()) }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, ); let Err(error) = result else { @@ -404,5 +586,41 @@ mod tests { ); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 1); + assert_eq!(wsl_calls.get(), 0); + } + + #[test] + fn local_wsl_reports_native_powershell_and_osc52_errors_when_all_fail() { + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + /*ssh_session*/ false, + /*wsl_session*/ true, + |_| { + osc_calls.set(osc_calls.get() + 1); + Err("osc blocked".into()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Err("native unavailable".into()) + }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Err("powershell unavailable".into()) + }, + ); + + let Err(error) = result else { + panic!("expected native, WSL, and OSC 52 errors"); + }; + assert_eq!( + error, + "native clipboard: native unavailable; WSL fallback: powershell unavailable; OSC 52 fallback: osc blocked" + ); + assert_eq!(osc_calls.get(), 1); + assert_eq!(native_calls.get(), 1); + assert_eq!(wsl_calls.get(), 1); } } From 3c4b49f74feee3e69b3515cbecedd34cae8188b5 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Apr 2026 12:06:38 -0300 Subject: [PATCH 21/32] fix(tui): satisfy argument comment lint Add required argument comments to new copy-as-markdown callsites. This keeps the Bazel argument-comment lint aligned with CI. --- codex-rs/tui/src/app_backtrack.rs | 14 +++++++++----- codex-rs/tui/src/chatwidget.rs | 4 ++-- .../tui/src/chatwidget/tests/slash_commands.rs | 8 ++++++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 8e1706899039..11a6d0561668 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -919,14 +919,18 @@ mod tests { #[test] fn agent_group_count_ignores_context_compacted_marker() { let cells: Vec> = vec![ - Arc::new(AgentMessageCell::new(vec![Line::from("first")], true)) - as Arc, + Arc::new(AgentMessageCell::new( + vec![Line::from("first")], + /*is_first_line*/ true, + )) as Arc, Arc::new(crate::history_cell::new_info_event( "Context compacted".to_string(), - None, + /*hint*/ None, + )) as Arc, + Arc::new(AgentMessageCell::new( + vec![Line::from("second")], + /*is_first_line*/ true, )) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("second")], true)) - as Arc, ]; assert_eq!(agent_group_count(&cells), 2); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 56cf5aeb0b92..4983c6cdadd4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2234,7 +2234,7 @@ impl ChatWidget { self.handle_stream_finished(); self.add_to_history(history_cell::new_info_event( "Context compacted".to_owned(), - None, + /*hint*/ None, )); self.request_redraw(); } @@ -5103,7 +5103,7 @@ impl ChatWidget { self.clipboard_lease = lease; self.add_to_history(history_cell::new_info_event( "Copied last message to clipboard".into(), - None, + /*hint*/ None, )); } Err(error) => self.add_to_history(history_cell::new_error_event(format!( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 1e8016b2955b..9e8d7044e03b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -221,7 +221,9 @@ async fn slash_copy_state_clears_on_thread_rollback() { duration_ms: None, }), }); - chat.truncate_agent_turn_markdowns_to_turn_count(/*remaining_turn_count*/ 0, None); + chat.truncate_agent_turn_markdowns_to_turn_count( + /*remaining_turn_count*/ 0, /*transcript_fallback*/ None, + ); assert_eq!(chat.last_agent_markdown_text(), None); } @@ -324,7 +326,9 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { }); let _ = drain_insert_history(&mut rx); - chat.truncate_agent_turn_markdowns_to_turn_count(/*remaining_turn_count*/ 0, None); + chat.truncate_agent_turn_markdowns_to_turn_count( + /*remaining_turn_count*/ 0, /*transcript_fallback*/ None, + ); assert_eq!(chat.last_agent_markdown_text(), None); } From f5424c5e8879b029acc42ce75480466e986c26b3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Apr 2026 12:08:30 -0300 Subject: [PATCH 22/32] docs(tui): document clipboard lease member Explain that the ChatWidget lease keeps copied clipboard text available while the platform requires the lease to be held. --- codex-rs/tui/src/chatwidget.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4983c6cdadd4..8a71b30f7f69 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -774,6 +774,7 @@ pub(crate) struct ChatWidget { stream_controller: Option, // Stream lifecycle controller for proposed plan output. plan_stream_controller: Option, + /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, /// Raw markdown of the most recently completed agent response. last_agent_markdown: Option, From 9bbc97286bdf2382986061128ffe68262564176c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Apr 2026 12:38:06 -0300 Subject: [PATCH 23/32] fix(tui): restore tmux osc52 passthrough Wrap OSC 52 clipboard copies in tmux passthrough sequences when the TUI is running under `TMUX`. This keeps SSH and terminal fallback clipboard copy working for nested tmux sessions. --- codex-rs/tui/src/clipboard_copy.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index 0dfb674a410d..a2c84482ed42 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -293,7 +293,7 @@ impl SuppressStderr { /// Write text to the clipboard via the OSC 52 terminal escape sequence. fn osc52_copy(text: &str) -> Result<(), String> { - let sequence = osc52_sequence(text)?; + let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some())?; #[cfg(unix)] { match std::fs::OpenOptions::new().write(true).open("/dev/tty") { @@ -321,7 +321,7 @@ fn write_osc52_to_writer(mut writer: impl Write, sequence: &str) -> Result<(), S .map_err(|e| format!("failed to flush OSC 52: {e}")) } -fn osc52_sequence(text: &str) -> Result { +fn osc52_sequence(text: &str, tmux: bool) -> Result { let raw_bytes = text.len(); if raw_bytes > OSC52_MAX_RAW_BYTES { return Err(format!( @@ -330,7 +330,11 @@ fn osc52_sequence(text: &str) -> Result { } let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - Ok(format!("\x1b]52;c;{encoded}\x07")) + if tmux { + Ok(format!("\x1bPtmux;\x1b\x1b]52;c;{encoded}\x07\x1b\\")) + } else { + Ok(format!("\x1b]52;c;{encoded}\x07")) + } } #[cfg(test)] @@ -347,7 +351,7 @@ mod tests { fn osc52_encoding_roundtrips() { use base64::Engine; let text = "# Hello\n\n```rust\nfn main() {}\n```\n"; - let sequence = osc52_sequence(text).expect("OSC 52 sequence"); + let sequence = osc52_sequence(text, /*tmux*/ false).expect("OSC 52 sequence"); let encoded = sequence .trim_start_matches("\u{1b}]52;c;") .trim_end_matches('\u{7}'); @@ -361,7 +365,7 @@ mod tests { fn osc52_rejects_payload_larger_than_limit() { let text = "x".repeat(OSC52_MAX_RAW_BYTES + 1); assert_eq!( - osc52_sequence(&text), + osc52_sequence(&text, /*tmux*/ false), Err(format!( "OSC 52 payload too large ({} bytes; max {OSC52_MAX_RAW_BYTES})", OSC52_MAX_RAW_BYTES + 1 @@ -369,6 +373,14 @@ mod tests { ); } + #[test] + fn osc52_wraps_tmux_passthrough() { + assert_eq!( + osc52_sequence("hello", /*tmux*/ true), + Ok("\u{1b}Ptmux;\u{1b}\u{1b}]52;c;aGVsbG8=\u{7}\u{1b}\\".to_string()) + ); + } + #[test] fn write_osc52_to_writer_emits_sequence_verbatim() { let sequence = "\u{1b}]52;c;aGVsbG8=\u{7}"; From a6aca227ae27301096cb964cbe758cea1cb01fa3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Apr 2026 13:31:39 -0300 Subject: [PATCH 24/32] fix(tui): keep copy state aligned after local prompts Advance the copy turn count when locally rendered prompts are added to history, so resumed threads do not overwrite prior assistant markdown when a new response arrives before rollback. Use the recorded item-level assistant markdown as the completion notification fallback when `TurnComplete` omits final text, while avoiding stale notification previews across later empty completions. --- codex-rs/tui/src/chatwidget.rs | 16 +++- .../src/chatwidget/tests/slash_commands.rs | 92 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8a71b30f7f69..8a068c22c022 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2391,6 +2391,18 @@ impl ChatWidget { { self.record_agent_markdown(message); } + let notification_response = last_agent_message + .as_ref() + .filter(|message| !message.is_empty()) + .cloned() + .or_else(|| { + if self.saw_copy_source_this_turn { + self.last_agent_markdown.clone() + } else { + None + } + }) + .unwrap_or_default(); self.saw_copy_source_this_turn = false; // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); @@ -2451,7 +2463,7 @@ impl ChatWidget { self.maybe_send_next_queued_input(); // Emit a notification when the turn completes (suppressed if focused). self.notify(Notification::AgentTurnComplete { - response: last_agent_message.unwrap_or_default(), + response: notification_response, }); self.maybe_show_pending_rate_limit_prompt(); @@ -5969,6 +5981,7 @@ impl ChatWidget { local_image_paths, remote_image_urls, )); + self.completed_turn_count = self.completed_turn_count.saturating_add(1); } else if render_in_history && !remote_image_urls.is_empty() { self.last_rendered_user_message_event = Some(Self::rendered_user_message_event_from_parts( @@ -5983,6 +5996,7 @@ impl ChatWidget { Vec::new(), remote_image_urls, )); + self.completed_turn_count = self.completed_turn_count.saturating_add(1); } self.needs_final_message_separator = false; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 9e8d7044e03b..efeaeded04ee 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -135,6 +135,10 @@ async fn slash_copy_state_tracks_plan_item_completion() { }); assert_eq!(chat.last_agent_markdown_text(), Some(plan_text.as_str())); + assert_matches!( + chat.pending_notification, + Some(Notification::AgentTurnComplete { ref response }) if response == &plan_text + ); } #[tokio::test] @@ -293,6 +297,10 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( chat.last_agent_markdown_text(), Some("Legacy item final message") ); + assert_matches!( + chat.pending_notification, + Some(Notification::AgentTurnComplete { ref response }) if response == "Legacy item final message" + ); } #[tokio::test] @@ -333,6 +341,90 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { assert_eq!(chat.last_agent_markdown_text(), None); } +#[tokio::test] +async fn slash_copy_preserves_surviving_response_after_local_prompt_rollback() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event_replay(Event { + id: "old-user".into(), + msg: EventMsg::UserMessage(UserMessageEvent { + message: "Old prompt".into(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }), + }); + let _ = drain_insert_history(&mut rx); + chat.handle_codex_event_replay(Event { + id: "old-agent".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Old reply".into(), + phase: None, + memory_citation: None, + }), + }); + let _ = drain_insert_history(&mut rx); + assert_eq!(chat.last_agent_markdown_text(), Some("Old reply")); + + chat.thread_id = Some(ThreadId::new()); + chat.submit_user_message(UserMessage::from("New prompt")); + let _ = next_submit_op(&mut op_rx); + let _ = drain_insert_history(&mut rx); + + complete_assistant_message( + &mut chat, + "msg-2", + "New reply that will be rolled back", + /*phase*/ None, + ); + let _ = drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "turn-2".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + }), + }); + let _ = drain_insert_history(&mut rx); + assert_eq!( + chat.last_agent_markdown_text(), + Some("New reply that will be rolled back") + ); + + chat.truncate_agent_turn_markdowns_to_turn_count( + /*remaining_turn_count*/ 1, /*transcript_fallback*/ None, + ); + + assert_eq!(chat.last_agent_markdown_text(), Some("Old reply")); +} + +#[tokio::test] +async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Previous reply".to_string()), + }), + }); + chat.pending_notification = None; + + chat.handle_codex_event(Event { + id: "turn-2".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + }), + }); + + assert_matches!( + chat.pending_notification, + Some(Notification::AgentTurnComplete { ref response }) if response.is_empty() + ); +} + #[tokio::test] async fn slash_exit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From ea672a8e25c8daa99ec2808b7abe2932fa4bbda2 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Apr 2026 13:48:14 -0300 Subject: [PATCH 25/32] fix(tui): make turn completion tests base compatible Construct new slash command test turn-completion events through serde so they compile against both the current branch protocol shape and the newer PR merge-base shape with completion timing fields. --- .../src/chatwidget/tests/slash_commands.rs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index efeaeded04ee..848d3c61d9d2 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,6 +1,14 @@ use super::*; use pretty_assertions::assert_eq; +fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnCompleteEvent { + serde_json::from_value(serde_json::json!({ + "turn_id": turn_id, + "last_agent_message": last_agent_message, + })) + .expect("turn complete event should deserialize") +} + #[tokio::test] async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -380,10 +388,7 @@ async fn slash_copy_preserves_surviving_response_after_local_prompt_rollback() { let _ = drain_insert_history(&mut rx); chat.handle_codex_event(Event { id: "turn-2".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-2".to_string(), - last_agent_message: None, - }), + msg: EventMsg::TurnComplete(turn_complete_event("turn-2", None)), }); let _ = drain_insert_history(&mut rx); assert_eq!( @@ -404,19 +409,13 @@ async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Previous reply".to_string()), - }), + msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Previous reply"))), }); chat.pending_notification = None; chat.handle_codex_event(Event { id: "turn-2".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-2".to_string(), - last_agent_message: None, - }), + msg: EventMsg::TurnComplete(turn_complete_event("turn-2", None)), }); assert_matches!( From af639da698f2a41d027b8e101cf4662ae11d1ae8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Apr 2026 15:00:45 -0300 Subject: [PATCH 26/32] fix(tui): satisfy turn completion argument lint Add the required parameter comments to the new turn-completion test helper calls so the Bazel argument-comment lint accepts the explicit `None` fallback cases. --- codex-rs/tui/src/chatwidget/tests/slash_commands.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 848d3c61d9d2..80ea1a5d4efb 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -388,7 +388,9 @@ async fn slash_copy_preserves_surviving_response_after_local_prompt_rollback() { let _ = drain_insert_history(&mut rx); chat.handle_codex_event(Event { id: "turn-2".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-2", None)), + msg: EventMsg::TurnComplete(turn_complete_event( + "turn-2", /*last_agent_message*/ None, + )), }); let _ = drain_insert_history(&mut rx); assert_eq!( @@ -415,7 +417,9 @@ async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() { chat.handle_codex_event(Event { id: "turn-2".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-2", None)), + msg: EventMsg::TurnComplete(turn_complete_event( + "turn-2", /*last_agent_message*/ None, + )), }); assert_matches!( From f32ad2227800fd5aab245e48ef65be5bdef9e99a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Apr 2026 16:09:30 -0300 Subject: [PATCH 27/32] docs(tui): clarify copy-source ordinal and precedence logic Add inline comments to record_agent_markdown and on_task_complete explaining the turn ordinal computation and saw_copy_source_this_turn guard, plus a doc comment on the copy_last_agent_markdown_with testing seam. Co-Authored-By: Claude Opus 4.6 (1M context) --- codex-rs/tui/src/chatwidget.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8a068c22c022..5e3ebea3ace4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1963,6 +1963,10 @@ impl ChatWidget { if message.is_empty() { return; } + // `completed_turn_count` is bumped when the *user* message that starts the + // turn is submitted, so it reflects the turn we are currently inside. Before + // the first user message it is zero; in that case (e.g. a system-initiated + // agent message during replay) synthesise a monotonically increasing ordinal. let turn_ordinal = if self.completed_turn_count == 0 { self.agent_turn_markdowns .last() @@ -2384,6 +2388,10 @@ impl ChatWidget { fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { self.submit_pending_steers_after_interrupt = false; + // Use `last_agent_message` from the turn-complete notification as the copy + // source only when no earlier item-level event (AgentMessageItem, plan + // commit, review output) already recorded markdown for this turn. This + // prevents the final summary from overwriting a more specific source. if let Some(message) = last_agent_message .as_ref() .filter(|message| !message.is_empty()) @@ -2391,6 +2399,8 @@ impl ChatWidget { { self.record_agent_markdown(message); } + // For desktop notifications: prefer the notification payload, fall back to + // the item-level copy source if present, otherwise send an empty string. let notification_response = last_agent_message .as_ref() .filter(|message| !message.is_empty()) @@ -5106,6 +5116,7 @@ impl ChatWidget { self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); } + /// Inner implementation with an injectable clipboard backend for testing. fn copy_last_agent_markdown_with( &mut self, copy_fn: impl FnOnce(&str) -> Result, String>, From 16f4567631e60fe27f216c14cf0f4ba8926fc855 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Apr 2026 16:12:13 -0300 Subject: [PATCH 28/32] docs(tui): add reviewer-facing comments for copy-history data flow Annotate the three AgentMessage match arms that now feed record_agent_markdown (previously no-ops), the ThreadRolledBack handler pointing to app_backtrack for cleanup, and the completed_turn_count bump that drives ordinal assignment. Co-Authored-By: Claude Opus 4.6 (1M context) --- codex-rs/tui/src/chatwidget.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5e3ebea3ace4..d11d6c653c7b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5992,6 +5992,9 @@ impl ChatWidget { local_image_paths, remote_image_urls, )); + // Bump turn counter here (at user-message submission time) so that + // `record_agent_markdown` can derive the correct ordinal for the + // agent response that follows. self.completed_turn_count = self.completed_turn_count.saturating_add(1); } else if render_in_history && !remote_image_urls.is_empty() { self.last_rendered_user_message_event = @@ -7006,6 +7009,10 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), + // NOTE: All three AgentMessage arms feed `record_agent_markdown` even + // when the message is otherwise not rendered (thread-snapshot replay, + // non-review live messages). This ensures the copy history stays + // populated across replay, resume, and live paths. EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && !self.is_review_mode => @@ -7198,6 +7205,9 @@ impl ChatWidget { EventMsg::CollabCloseEnd(ev) => self.on_collab_event(multi_agents::close_end(ev)), EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), + // Copy-history cleanup on rollback is handled by `app_backtrack`, + // which calls `truncate_agent_turn_markdowns_to_turn_count` after + // trimming transcript cells. EventMsg::ThreadRolledBack(rollback) => { if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { From 628eafbc739fc2bd480eb4f1925327abcb4bfa7c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 8 Apr 2026 16:01:30 -0300 Subject: [PATCH 29/32] fix(tui): use ctrl-o for copy response shortcut Move the copy-last-agent-response hotkey from Alt+C to Ctrl+O and update the clipboard backend docs to match. Add coverage so the new key path still reports when no agent response exists. --- codex-rs/tui/src/chatwidget.rs | 6 +++--- .../tui/src/chatwidget/tests/slash_commands.rs | 15 +++++++++++++++ codex-rs/tui/src/clipboard_copy.rs | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d11d6c653c7b..81e69aa7b355 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4864,10 +4864,10 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { - // Alt+C - copy last agent response from the main view. + // Ctrl+O - copy last agent response from the main view. KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::ALT, + code: KeyCode::Char('o'), + modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, .. } => { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 80ea1a5d4efb..6d45856c9598 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -165,6 +165,21 @@ async fn slash_copy_reports_when_no_agent_response_exists() { ); } +#[tokio::test] +async fn ctrl_o_copy_reports_when_no_agent_response_exists() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("No agent response to copy"), + "expected no-output message, got {rendered:?}" + ); +} + #[tokio::test] async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index a2c84482ed42..038dc0b285f2 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -1,4 +1,4 @@ -//! Clipboard copy backend for the TUI's `/copy` command and `Alt+C` hotkey. +//! Clipboard copy backend for the TUI's `/copy` command and `Ctrl+O` hotkey. //! //! This module decides *how* to get text onto the user's clipboard based on the //! current environment. The selection order is: From bd2a9b6ca341a95efe9652b8d43b256bebb6111e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 8 Apr 2026 16:50:46 -0300 Subject: [PATCH 30/32] feat(tui): tooltip for copy command and shortcut --- codex-rs/tui/tooltips.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 88f9de01cd65..7bb91d5ba673 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -22,3 +22,4 @@ When the composer is empty, press Esc to step back and edit your last message; E Press Tab to queue a message when a task is running; otherwise it sends immediately (except `!`). Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume` +Use /copy or press Ctrl+O to copy the latest agent response as Markdown. From 89ab3f878ae477c9b352a3e746ad7d121b9a19aa Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 9 Apr 2026 11:22:27 -0300 Subject: [PATCH 31/32] fix(tui): lower copy response history cap Retain fewer prior agent responses for `/copy` rollback bookkeeping. This still supports ordinary backtrack flows while reducing the worst-case markdown retained by the chat widget. --- codex-rs/tui/src/chatwidget.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 81e69aa7b355..502beee4b493 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -253,7 +253,7 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; -const MAX_AGENT_COPY_HISTORY: usize = 256; +const MAX_AGENT_COPY_HISTORY: usize = 32; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; /// Choose the keybinding used to edit the most-recently queued message. From 699582b9d7f6dbb9a8025164768b4de464c4469a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 9 Apr 2026 11:46:01 -0300 Subject: [PATCH 32/32] refactor(tui): keep copy response cache rollback agnostic Remove the copy-specific rollback synchronization and document the known tradeoff on the single-response cache. This keeps `/copy` state simple and avoids coupling clipboard behavior to transcript backtracking internals. --- codex-rs/tui/src/app_backtrack.rs | 56 +-------- codex-rs/tui/src/chatwidget.rs | 113 ++--------------- codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 - .../src/chatwidget/tests/slash_commands.rs | 114 ------------------ codex-rs/tui/src/history_cell.rs | 18 --- 5 files changed, 8 insertions(+), 295 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 11a6d0561668..2852fbc3790d 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -30,8 +30,8 @@ use std::sync::Arc; use crate::app::App; use crate::app_command::AppCommand; use crate::app_event::AppEvent; +#[cfg(test)] use crate::history_cell::AgentMessageCell; -use crate::history_cell::HistoryCell; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; @@ -482,10 +482,6 @@ impl App { if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) { return false; } - let remaining_turns = user_count(&self.transcript_cells); - let fallback_markdown = last_agent_markdown_from_transcript(&self.transcript_cells); - self.chat_widget - .truncate_agent_turn_markdowns_to_turn_count(remaining_turns, fallback_markdown); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; true @@ -507,10 +503,6 @@ impl App { &mut self.transcript_cells, pending.selection.nth_user_message, ) { - let remaining_turns = user_count(&self.transcript_cells); - let fallback_markdown = last_agent_markdown_from_transcript(&self.transcript_cells); - self.chat_widget - .truncate_agent_turn_markdowns_to_turn_count(remaining_turns, fallback_markdown); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; } @@ -645,52 +637,6 @@ fn user_positions_iter( .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) } -/// Reconstruct the plain text of the last agent response group from transcript cells. -/// -/// Used as a fallback when the ordinal-indexed markdown history has been fully -/// truncated by a rollback but the transcript still contains visible agent output. -/// Walks backward from the end of the visible portion (after the last session-start -/// marker) to find the final contiguous block of `AgentMessageCell`s, then joins -/// their display text. -/// -/// Because this uses `AgentMessageCell::plain_text()` (which joins rendered spans), -/// the result is display-level text rather than the original raw markdown. For most -/// responses these are identical. -fn last_agent_markdown_from_transcript( - cells: &[Arc], -) -> Option { - let session_start_type = TypeId::of::(); - let type_of = |cell: &Arc| cell.as_any().type_id(); - - let start = cells - .iter() - .rposition(|cell| type_of(cell) == session_start_type) - .map_or(0, |idx| idx + 1); - let visible_cells = &cells[start..]; - - let group_start = visible_cells.iter().rposition(|cell| { - cell.as_any().downcast_ref::().is_some() && !cell.is_stream_continuation() - })?; - - let mut blocks: Vec = Vec::new(); - for (offset, cell) in visible_cells[group_start..].iter().enumerate() { - let Some(agent_cell) = cell.as_any().downcast_ref::() else { - break; - }; - if offset > 0 && !agent_cell.is_stream_continuation() { - break; - } - blocks.push(agent_cell.plain_text()); - } - - let merged = blocks - .into_iter() - .filter(|block| !block.is_empty()) - .collect::>() - .join("\n"); - (!merged.is_empty()).then_some(merged) -} - #[cfg(test)] fn agent_group_count(cells: &[Arc]) -> usize { agent_group_positions_iter(cells).count() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 502beee4b493..3575799b2149 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -253,7 +253,6 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; -const MAX_AGENT_COPY_HISTORY: usize = 32; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; /// Choose the keybinding used to edit the most-recently queued message. @@ -777,11 +776,12 @@ pub(crate) struct ChatWidget { /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, /// Raw markdown of the most recently completed agent response. + /// + /// This cache is intentionally best-effort: if the user rolls back the + /// thread and then copies before a replacement response arrives, `/copy` + /// may still return the response from before the rollback. Keeping this as + /// a single cache avoids coupling copy state to the backtrack transcript. last_agent_markdown: Option, - /// Raw markdown for each completed agent response in this session timeline. - agent_turn_markdowns: Vec, - /// Number of completed turns observed in this session timeline. - completed_turn_count: usize, /// Whether this turn already produced a copyable response. /// /// `TurnComplete.last_agent_message` is a fallback source: use it only when no earlier @@ -1022,20 +1022,6 @@ pub(crate) struct UserMessage { mention_bindings: Vec, } -/// A snapshot of the raw markdown for one completed agent turn. -/// -/// Entries are keyed by `ordinal` — the number of completed user turns at the -/// time the agent response was recorded. This allows rollbacks to truncate the -/// history by comparing ordinals against the remaining turn count without -/// maintaining a parallel index structure. -#[derive(Clone, Debug, Eq, PartialEq)] -struct AgentTurnMarkdown { - /// Monotonically increasing turn number derived from `completed_turn_count`. - ordinal: usize, - /// The full raw markdown of the agent's response for this turn. - markdown: String, -} - #[derive(Debug, Clone, PartialEq, Default)] struct ThreadComposerState { text: String, @@ -1954,56 +1940,17 @@ impl ChatWidget { } /// Record or update the raw markdown for the current agent turn. - /// - /// If the current turn already has an entry (same ordinal), it is overwritten - /// rather than appended — a turn's markdown is the *last* agent message seen, - /// not a concatenation. The history is bounded by `MAX_AGENT_COPY_HISTORY`; - /// overflow drains the oldest entries. fn record_agent_markdown(&mut self, message: &str) { if message.is_empty() { return; } - // `completed_turn_count` is bumped when the *user* message that starts the - // turn is submitted, so it reflects the turn we are currently inside. Before - // the first user message it is zero; in that case (e.g. a system-initiated - // agent message during replay) synthesise a monotonically increasing ordinal. - let turn_ordinal = if self.completed_turn_count == 0 { - self.agent_turn_markdowns - .last() - .map_or(1, |entry| entry.ordinal.saturating_add(1)) - } else { - self.completed_turn_count - }; - if self - .agent_turn_markdowns - .last() - .is_some_and(|entry| entry.ordinal == turn_ordinal) - { - if let Some(last) = self.agent_turn_markdowns.last_mut() { - last.markdown = message.to_string(); - } - } else { - self.agent_turn_markdowns.push(AgentTurnMarkdown { - ordinal: turn_ordinal, - markdown: message.to_string(), - }); - } - if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { - let overflow = self.agent_turn_markdowns.len() - MAX_AGENT_COPY_HISTORY; - self.agent_turn_markdowns.drain(0..overflow); - } - self.last_agent_markdown = self - .agent_turn_markdowns - .last() - .map(|entry| entry.markdown.clone()); + self.last_agent_markdown = Some(message.to_string()); self.saw_copy_source_this_turn = true; } // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) { self.last_agent_markdown = None; - self.agent_turn_markdowns.clear(); - self.completed_turn_count = 0; self.saw_copy_source_this_turn = false; self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); @@ -4754,8 +4701,6 @@ impl ChatWidget { agent_turn_running: false, mcp_startup_status: None, last_agent_markdown: None, - agent_turn_markdowns: Vec::new(), - completed_turn_count: 0, saw_copy_source_this_turn: false, mcp_startup_expected_servers: None, mcp_startup_ignore_updates_until_next_start: false, @@ -5141,41 +5086,6 @@ impl ChatWidget { self.request_redraw(); } - /// Trim the markdown history to match a rollback. - /// - /// Called by `app_backtrack` after transcript cells have been trimmed. Pops - /// entries whose ordinal exceeds `remaining_turn_count`, then uses - /// `transcript_fallback` (reconstructed from surviving `AgentMessageCell`s) if - /// the ordinal history is now empty but the transcript still has agent output. - pub(crate) fn truncate_agent_turn_markdowns_to_turn_count( - &mut self, - remaining_turn_count: usize, - transcript_fallback: Option, - ) { - while self - .agent_turn_markdowns - .last() - .is_some_and(|entry| entry.ordinal > remaining_turn_count) - { - self.agent_turn_markdowns.pop(); - } - if self.agent_turn_markdowns.is_empty() - && let Some(fallback) = transcript_fallback - .map(|fallback| fallback.trim().to_string()) - .filter(|fallback| !fallback.is_empty()) - { - self.agent_turn_markdowns.push(AgentTurnMarkdown { - ordinal: remaining_turn_count, - markdown: fallback, - }); - } - self.completed_turn_count = self.completed_turn_count.min(remaining_turn_count); - self.last_agent_markdown = self - .agent_turn_markdowns - .last() - .map(|entry| entry.markdown.clone()); - } - #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { self.last_agent_markdown.as_deref() @@ -5992,10 +5902,6 @@ impl ChatWidget { local_image_paths, remote_image_urls, )); - // Bump turn counter here (at user-message submission time) so that - // `record_agent_markdown` can derive the correct ordinal for the - // agent response that follows. - self.completed_turn_count = self.completed_turn_count.saturating_add(1); } else if render_in_history && !remote_image_urls.is_empty() { self.last_rendered_user_message_event = Some(Self::rendered_user_message_event_from_parts( @@ -6010,7 +5916,6 @@ impl ChatWidget { Vec::new(), remote_image_urls, )); - self.completed_turn_count = self.completed_turn_count.saturating_add(1); } self.needs_final_message_separator = false; @@ -7011,7 +6916,7 @@ impl ChatWidget { EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), // NOTE: All three AgentMessage arms feed `record_agent_markdown` even // when the message is otherwise not rendered (thread-snapshot replay, - // non-review live messages). This ensures the copy history stays + // non-review live messages). This ensures the copy source stays // populated across replay, resume, and live paths. EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) @@ -7205,9 +7110,6 @@ impl ChatWidget { EventMsg::CollabCloseEnd(ev) => self.on_collab_event(multi_agents::close_end(ev)), EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), - // Copy-history cleanup on rollback is handled by `app_backtrack`, - // which calls `truncate_agent_turn_markdowns_to_turn_count` after - // trimming transcript cells. EventMsg::ThreadRolledBack(rollback) => { if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { @@ -7379,7 +7281,6 @@ impl ChatWidget { event.local_images, remote_image_urls, )); - self.completed_turn_count = self.completed_turn_count.saturating_add(1); } // User messages reset separator state so the next agent response doesn't add a stray break. diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 01167f987411..0a7deee58973 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -208,8 +208,6 @@ pub(super) async fn make_chatwidget_manual( pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, last_agent_markdown: None, - agent_turn_markdowns: Vec::new(), - completed_turn_count: 0, saw_copy_source_this_turn: false, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 6d45856c9598..e751292550df 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -235,26 +235,6 @@ async fn slash_copy_state_is_preserved_during_running_task() { ); } -#[tokio::test] -async fn slash_copy_state_clears_on_thread_rollback() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Reply that will be rolled back".to_string()), - completed_at: None, - duration_ms: None, - }), - }); - chat.truncate_agent_turn_markdowns_to_turn_count( - /*remaining_turn_count*/ 0, /*transcript_fallback*/ None, - ); - - assert_eq!(chat.last_agent_markdown_text(), None); -} - #[tokio::test] async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omits_text() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -326,100 +306,6 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( ); } -#[tokio::test] -async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); - complete_assistant_message( - &mut chat, - "msg-1", - "Reply that will be rolled back", - /*phase*/ None, - ); - let _ = drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - }), - }); - let _ = drain_insert_history(&mut rx); - - chat.truncate_agent_turn_markdowns_to_turn_count( - /*remaining_turn_count*/ 0, /*transcript_fallback*/ None, - ); - - assert_eq!(chat.last_agent_markdown_text(), None); -} - -#[tokio::test] -async fn slash_copy_preserves_surviving_response_after_local_prompt_rollback() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event_replay(Event { - id: "old-user".into(), - msg: EventMsg::UserMessage(UserMessageEvent { - message: "Old prompt".into(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - }); - let _ = drain_insert_history(&mut rx); - chat.handle_codex_event_replay(Event { - id: "old-agent".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Old reply".into(), - phase: None, - memory_citation: None, - }), - }); - let _ = drain_insert_history(&mut rx); - assert_eq!(chat.last_agent_markdown_text(), Some("Old reply")); - - chat.thread_id = Some(ThreadId::new()); - chat.submit_user_message(UserMessage::from("New prompt")); - let _ = next_submit_op(&mut op_rx); - let _ = drain_insert_history(&mut rx); - - complete_assistant_message( - &mut chat, - "msg-2", - "New reply that will be rolled back", - /*phase*/ None, - ); - let _ = drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "turn-2".into(), - msg: EventMsg::TurnComplete(turn_complete_event( - "turn-2", /*last_agent_message*/ None, - )), - }); - let _ = drain_insert_history(&mut rx); - assert_eq!( - chat.last_agent_markdown_text(), - Some("New reply that will be rolled back") - ); - - chat.truncate_agent_turn_markdowns_to_turn_count( - /*remaining_turn_count*/ 1, /*transcript_fallback*/ None, - ); - - assert_eq!(chat.last_agent_markdown_text(), Some("Old reply")); -} - #[tokio::test] async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4c153c139e7d..67c7e9f98b57 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -460,24 +460,6 @@ impl AgentMessageCell { is_first_line, } } - - /// Join all spans into unstyled plain text, one line per entry in `self.lines`. - /// - /// Used by `last_agent_markdown_from_transcript` to reconstruct copy-source text - /// from rendered transcript cells after a rollback. The result strips style - /// information but preserves whitespace and newlines. - pub(crate) fn plain_text(&self) -> String { - self.lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n") - } } impl HistoryCell for AgentMessageCell {