diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 25c536b66..76da2ba90 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5701,7 +5701,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 db1149e76..2852fbc37 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -30,6 +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::SessionInfoCell; use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; @@ -635,6 +637,34 @@ 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 + '_ { + 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(); + let is_copy_source_group = is_agent && !cell.is_stream_continuation(); + is_copy_source_group.then_some(idx) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -831,4 +861,24 @@ 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")], + /*is_first_line*/ true, + )) as Arc, + Arc::new(crate::history_cell::new_info_event( + "Context compacted".to_string(), + /*hint*/ None, + )) as Arc, + Arc::new(AgentMessageCell::new( + vec![Line::from("second")], + /*is_first_line*/ 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 c11066054..3575799b2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -317,7 +317,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; @@ -774,11 +773,22 @@ 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, + /// 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, + /// Whether this turn already produced a copyable response. + /// + /// `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, pending_collab_spawn_requests: HashMap, @@ -1929,8 +1939,19 @@ impl ChatWidget { } } + /// Record or update the raw markdown for the current agent turn. + fn record_agent_markdown(&mut self, message: &str) { + if message.is_empty() { + return; + } + 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.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); @@ -1968,8 +1989,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); @@ -2007,6 +2026,7 @@ impl ChatWidget { if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } + self.saw_copy_source_this_turn = false; self.submit_op(AppCommand::list_skills( Vec::new(), /*force_reload*/ true, @@ -2161,6 +2181,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(), + /*hint*/ None, + )); + self.request_redraw(); + } + fn on_agent_message_delta(&mut self, delta: String) { self.handle_streaming_delta(delta); } @@ -2201,7 +2231,7 @@ 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 // completion can restore it once stream queues are idle. @@ -2278,6 +2308,7 @@ impl ChatWidget { self.agent_turn_running = true; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); + 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; @@ -2285,7 +2316,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(); @@ -2305,12 +2335,32 @@ 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 - .filter(|message| !message.trim().is_empty()) - .or_else(|| self.pending_turn_copyable_output.take()); - if let Some(message) = copyable_turn_output.as_ref() { - self.last_copyable_output = Some(message.clone()); + // 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()) + && !self.saw_copy_source_this_turn + { + 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()) + .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(); if let Some(mut controller) = self.plan_stream_controller.take() @@ -2370,7 +2420,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: notification_response, }); self.maybe_show_pending_rate_limit_prompt(); @@ -2716,7 +2766,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(); @@ -4044,8 +4093,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. @@ -4639,7 +4688,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, - last_copyable_output: None, + clipboard_lease: None, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), @@ -4651,7 +4700,8 @@ impl ChatWidget { unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, - pending_turn_copyable_output: None, + last_agent_markdown: None, + 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, @@ -4759,6 +4809,19 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + // Ctrl+O - copy last agent response from the main view. + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::CONTROL, + 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; + } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -4993,6 +5056,41 @@ impl ChatWidget { false } + /// Copy the last agent response (raw markdown) to the system clipboard. + pub(crate) fn copy_last_agent_markdown(&mut self) { + 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>, + ) { + 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(), + /*hint*/ 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(); + } + + #[cfg(test)] + pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { + self.last_agent_markdown.as_deref() + } + fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -5196,6 +5294,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(); @@ -5213,34 +5314,6 @@ 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(), - /*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}")) - } - } - } SlashCommand::Mention => { self.insert_str("@"); } @@ -6528,13 +6601,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(), } } @@ -6542,11 +6615,6 @@ 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; - } - fn on_mcp_server_elicitation_request( &mut self, request_id: codex_protocol::mcp::RequestId, @@ -6846,18 +6914,34 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), - EventMsg::AgentMessage(AgentMessageEvent { .. }) + // 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 source stays + // populated across replay, resume, and live paths. + EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) - && !self.is_review_mode => {} + && !self.is_review_mode => + { + if !message.is_empty() { + self.record_agent_markdown(&message); + } + } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if from_replay || self.is_review_mode => { + if !message.is_empty() { + self.record_agent_markdown(&message); + } // 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.record_agent_markdown(&message); + } + } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) } @@ -6995,7 +7079,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, @@ -7027,11 +7111,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, @@ -7156,6 +7235,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(); 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 3ade0924e..63e525741 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 d5b30bc04..0a7deee58 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -204,10 +204,11 @@ 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_copyable_output: None, - pending_turn_copyable_output: None, + last_agent_markdown: None, + 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 e2fda4168..e75129255 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; @@ -103,8 +111,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 +142,15 @@ 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())); + assert_matches!( + chat.pending_notification, + Some(Notification::AgentTurnComplete { ref response }) if response == &plan_text + ); } #[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,13 +160,60 @@ 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:?}" ); } +#[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; + 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; @@ -171,34 +230,13 @@ 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") ); } #[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.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() { +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 +259,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,70 +296,36 @@ 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") + ); + assert_matches!( + chat.pending_notification, + Some(Notification::AgentTurnComplete { ref response }) if response == "Legacy item final message" ); } #[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; +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::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), + msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Previous reply"))), }); - 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.pending_notification = None; chat.handle_codex_event(Event { - id: "rollback-1".into(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + id: "turn-2".into(), + msg: EventMsg::TurnComplete(turn_complete_event( + "turn-2", /*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 rollback-cleared copy state message, got {rendered:?}" + assert_matches!( + chat.pending_notification, + Some(Notification::AgentTurnComplete { ref response }) if response.is_empty() ); } diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs new file mode 100644 index 000000000..038dc0b28 --- /dev/null +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -0,0 +1,638 @@ +//! 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: +//! +//! 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. 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` +//! 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> = + std::sync::OnceLock::new(); + +/// Copy text to the system clipboard. +/// +/// 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 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(), + is_wsl_session(), + osc52_copy, + arboard_copy, + wsl_clipboard_copy, + ) +} + +/// 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 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, +} + +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, + } + } +} + +/// 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, + 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 + // useless. Use OSC 52, which travels through the SSH tunnel to the + // local terminal emulator. + 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(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}") + }) + } + } +} + +/// 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() +} + +#[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 +/// 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(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(())) + .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}"))?; + clipboard + .set_text(text) + .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> { + 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")] +struct SuppressStderr { + saved_fd: Option, +} + +#[cfg(target_os = "macos")] +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(c"/dev/null".as_ptr(), libc::O_WRONLY); + 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), + } + } + } +} + +#[cfg(target_os = "macos")] +impl Drop for SuppressStderr { + fn drop(&mut self) { + if let Some(saved) = self.saved_fd { + unsafe { + libc::dup2(saved, 2); + libc::close(saved); + } + } + } +} + +#[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 sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some())?; + #[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}"))?; + writer + .flush() + .map_err(|e| format!("failed to flush OSC 52: {e}")) +} + +fn osc52_sequence(text: &str, tmux: bool) -> 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()); + if tmux { + Ok(format!("\x1bPtmux;\x1b\x1b]52;c;{encoded}\x07\x1b\\")) + } else { + 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; + use super::write_osc52_to_writer; + + #[test] + fn osc52_encoding_roundtrips() { + use base64::Engine; + let text = "# Hello\n\n```rust\nfn main() {}\n```\n"; + 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}'); + let decoded = base64::engine::general_purpose::STANDARD + .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, /*tmux*/ false), + Err(format!( + "OSC 52 payload too large ({} bytes; max {OSC52_MAX_RAW_BYTES})", + OSC52_MAX_RAW_BYTES + 1 + )) + ); + } + + #[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}"; + 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); + let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + /*ssh_session*/ true, + /*wsl_session*/ true, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + 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", + /*ssh_session*/ true, + /*wsl_session*/ true, + |_| { + osc_calls.set(osc_calls.get() + 1); + Err("blocked".into()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Ok(None) + }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, + ); + + 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); + 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", + /*ssh_session*/ false, + /*wsl_session*/ true, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + 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_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", + /*ssh_session*/ false, + /*wsl_session*/ false, + |_| { + 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(), 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", + /*ssh_session*/ false, + /*wsl_session*/ false, + |_| { + 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); + Ok(()) + }, + ); + + let Err(error) = result else { + panic!("expected native and OSC 52 errors"); + }; + assert_eq!( + error, + "native clipboard: native unavailable; OSC 52 fallback: osc blocked" + ); + 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); + } +} diff --git a/codex-rs/tui/src/clipboard_text.rs b/codex-rs/tui/src/clipboard_text.rs deleted file mode 100644 index eee94b98f..000000000 --- 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 eb46ba2c9..700d685b0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -101,8 +101,8 @@ mod audio_device { mod bottom_pane; mod chatwidget; mod cli; +mod clipboard_copy; mod clipboard_paste; -mod clipboard_text; mod collaboration_modes; mod color; pub(crate) mod custom_terminal; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index b1a6e97ad..635dc317f 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", diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 88f9de01c..7bb91d5ba 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.