From ee1520e79e4fc6fd72565c8f854afc16001a00d7 Mon Sep 17 00:00:00 2001 From: Won Park Date: Tue, 24 Feb 2026 14:17:01 -0800 Subject: [PATCH] feat(tui) - /copy (#12613) # /copy! /copy allows you to copy the latest **complete** message from Codex on the TUI. --- codex-rs/tui/src/chatwidget.rs | 47 +++++ ...ts__slash_copy_no_output_info_message.snap | 6 + codex-rs/tui/src/chatwidget/tests.rs | 198 ++++++++++++++++++ codex-rs/tui/src/clipboard_text.rs | 11 + codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 4 + 6 files changed, 267 insertions(+) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap create mode 100644 codex-rs/tui/src/clipboard_text.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8e9f12538..ac8da4d55 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -218,6 +218,7 @@ 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; @@ -556,6 +557,8 @@ 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, running_commands: HashMap, suppressed_exec_calls: HashSet, skills_all: Vec, @@ -1123,6 +1126,7 @@ impl ChatWidget { Constrained::allow_only(event.sandbox_policy.clone()); } let initial_messages = event.initial_messages.clone(); + self.last_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); @@ -1311,6 +1315,9 @@ impl ChatWidget { } else { text }; + if !plan_text.trim().is_empty() { + self.last_copyable_output = Some(plan_text.clone()); + } // Plan commit ticks can hide the status row; remember whether we streamed plan output so // completion can restore it once stream queues are idle. let should_restore_after_stream = self.plan_stream_controller.is_some(); @@ -1404,6 +1411,11 @@ impl ChatWidget { } fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { + if let Some(message) = last_agent_message.as_ref() + && !message.trim().is_empty() + { + self.last_copyable_output = Some(message.clone()); + } // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); if let Some(mut controller) = self.plan_stream_controller.take() @@ -2807,6 +2819,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + last_copyable_output: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -2983,6 +2996,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + last_copyable_output: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -3148,6 +3162,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + last_copyable_output: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -3650,6 +3665,34 @@ impl ChatWidget { tx.send(AppEvent::DiffResult(text)); }); } + 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(), + 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}")) + } + } + } SlashCommand::Mention => { self.insert_str("@"); } @@ -4402,6 +4445,10 @@ 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; 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 new file mode 100644 index 000000000..7e24b570e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_no_output_info_message.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 4430 +expression: rendered.clone() +--- +• `/copy` is unavailable before the first Codex output or right after a rollback. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6b7635508..5fa985413 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -37,6 +37,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::Settings; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; use codex_protocol::models::MessagePhase; use codex_protocol::openai_models::ModelPreset; @@ -1667,6 +1668,7 @@ async fn make_chatwidget_manual( adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + last_copyable_output: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), skills_all: Vec::new(), @@ -4624,6 +4626,202 @@ async fn slash_quit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn slash_copy_state_tracks_turn_complete_final_reply() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(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("Final reply **markdown**".to_string()), + }), + }); + + assert_eq!( + chat.last_copyable_output, + Some("Final reply **markdown**".to_string()) + ); +} + +#[tokio::test] +async fn slash_copy_state_tracks_plan_item_completion() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string(); + + chat.handle_codex_event(Event { + id: "item-plan".into(), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + item: TurnItem::Plan(PlanItem { + id: "plan-1".to_string(), + text: plan_text.clone(), + }), + }), + }); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + assert_eq!(chat.last_copyable_output, Some(plan_text)); +} + +#[tokio::test] +async fn slash_copy_reports_when_no_copyable_output_exists() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + 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_snapshot!("slash_copy_no_output_info_message", rendered); + assert!( + rendered.contains( + "`/copy` is unavailable before the first Codex output or right after a rollback." + ), + "expected no-output message, got {rendered:?}" + ); +} + +#[tokio::test] +async fn slash_copy_state_is_preserved_during_running_task() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(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 completed reply".to_string()), + }), + }); + chat.on_task_started(); + + assert_eq!( + chat.last_copyable_output, + Some("Previous completed reply".to_string()) + ); +} + +#[tokio::test] +async fn slash_copy_state_clears_on_thread_rollback() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(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()), + }), + }); + chat.handle_codex_event(Event { + id: "rollback-1".into(), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + }); + + assert_eq!(chat.last_copyable_output, None); +} + +#[tokio::test] +async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_turn_complete() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Legacy final message".into(), + 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, + }), + }); + 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:?}" + ); +} + +#[tokio::test] +async fn slash_copy_is_unavailable_when_legacy_agent_message_item_is_not_repeated_on_turn_complete() +{ + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + complete_assistant_message(&mut chat, "msg-1", "Legacy item final message", 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, + }), + }); + 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:?}" + ); +} + +#[tokio::test] +async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(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()), + }), + }); + 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.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:?}" + ); +} + #[tokio::test] async fn slash_exit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/clipboard_text.rs b/codex-rs/tui/src/clipboard_text.rs new file mode 100644 index 000000000..ec8a7b112 --- /dev/null +++ b/codex-rs/tui/src/clipboard_text.rs @@ -0,0 +1,11 @@ +#[cfg(not(target_os = "android"))] +pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> { + let mut cb = arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; + cb.set_text(text.to_string()) + .map_err(|e| format!("clipboard unavailable: {e}")) +} + +#[cfg(target_os = "android")] +pub fn copy_text_to_clipboard(_text: &str) -> Result<(), String> { + Err("clipboard text copy is unsupported on Android".into()) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1d8349cf7..52c5cd9e5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -65,6 +65,7 @@ mod bottom_pane; mod chatwidget; mod cli; mod clipboard_paste; +mod clipboard_text; mod collaboration_modes; mod color; pub mod custom_terminal; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 468303ceb..2799c80b2 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -33,6 +33,7 @@ pub enum SlashCommand { Agent, // Undo, Diff, + Copy, Mention, Status, DebugConfig, @@ -74,6 +75,7 @@ impl SlashCommand { // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", 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", @@ -145,6 +147,7 @@ impl SlashCommand { | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, SlashCommand::Diff + | SlashCommand::Copy | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills @@ -170,6 +173,7 @@ impl SlashCommand { fn is_visible(self) -> bool { match self { SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), + SlashCommand::Copy => !cfg!(target_os = "android"), SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), _ => true, }