From ef330eff6d52a037e77a7bde042694b2a54492df Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 9 Apr 2026 18:10:38 -0300 Subject: [PATCH] feat(tui): Ctrl+O copy hotkey and harden copy-as-markdown behavior (#16966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR - New `Ctrl+O` shortcut on top of the existing `/copy` command, allowing users to copy the latest agent response without having to cancel a plan or type `/copy` - Copy server clipboard to the client over SSH (OSC 52) - Fixes linux copy behavior: a clipboard handle has to be kept alive while the paste happens for the contents to be preserved - Uses arboard as primary mechanism on Windows, falling back to PowerShell copy clipboard function - Works with resumes, rolling back during a session, etc. Tested on macOS, Linux/X11, Windows WSL2, Windows cmd.exe, Windows PowerShell, Windows VSCode PowerShell, Windows VSCode WSL2, SSH (macOS -> macOS). ## Problem The TUI's `/copy` command was fragile. It relied on a single `last_copyable_output` field that was bluntly cleared on every rollback and thread reconfiguration, making copied content unavailable after common operations like backtracking. It also had no keyboard shortcut, requiring users to type `/copy` each time. The previous clipboard backend mixed platform selection policy with low-level I/O in a way that was hard to test, and it did not keep the Linux clipboard owner alive — meaning pasted content could vanish once the process that wrote it dropped its `arboard::Clipboard`. This addresses the text-copy failure modes reported in #12836, #15452, and #15663: native Linux clipboard access failing in remote or unreachable-display environments, copy state going blank even after visible assistant output, and local Linux X11 reporting success while leaving the clipboard empty. ## Shortcut rationale The copy hotkey is `Ctrl+O` rather than `Alt+C` because Alt/Option combinations are not delivered consistently by macOS terminal emulators. Terminal.app and iTerm2 can treat Option as text input or as a configurable Meta/Esc prefix, and Option+C may be consumed or transformed before the TUI sees an `Alt+C` key event. `Ctrl+O` is a stable control-key chord in Terminal.app, iTerm2, SSH, and the existing cross-platform terminal stack. ## Mental model Agent responses are now tracked as a bounded, ordinal-indexed history (`agent_turn_markdowns: Vec`) rather than a single nullable string. Each completed agent turn appends an entry keyed by its ordinal (the number of user turns seen so far). Rollbacks pop entries whose ordinal exceeds the remaining turn count, then use the visible transcript cells as a best-effort fallback if the ordinal history no longer has a surviving entry. This means `/copy` and `Ctrl+O` reflect the most recent surviving agent response after a backtrack, instead of going blank. The clipboard backend was rewritten as `clipboard_copy.rs` with a strategy-injection design: `copy_to_clipboard_with` accepts closures for the OSC 52, arboard, and WSL PowerShell paths, making the selection logic fully unit-testable without touching real clipboards. On Linux, the `Clipboard` handle is returned as a `ClipboardLease` stored on `ChatWidget`, keeping X11/Wayland clipboard ownership alive for the lifetime of the TUI. When native copy fails under WSL, the backend now tries the Windows clipboard through PowerShell before falling back to OSC 52. ## Non-goals - This change does not introduce rich-text (HTML) clipboard support; the copied content is raw markdown. - It does not add a paste-from-history picker or multi-entry clipboard ring. - WSL support remains a best-effort fallback, not a new configuration surface or guarantee for every terminal/host combination. ## Tradeoffs - **Bounded history (256 entries)**: `MAX_AGENT_COPY_HISTORY` caps memory. For sessions with thousands of turns this silently drops the oldest entries. The cap is generous enough for realistic sessions. - **`saw_copy_source_this_turn` flag**: Prevents double-recording when both `AgentMessage` and `TurnComplete.last_agent_message` fire for the same turn. The flag is reset on turn start and on turn complete, creating a narrow window where a race between the two events could theoretically skip recording. In practice the protocol delivers them sequentially. - **Transcript fallback on rollback**: `last_agent_markdown_from_transcript` walks the visible transcript cells to reconstruct plain text when the ordinal history has been fully truncated. This path uses `AgentMessageCell::plain_text()` which joins rendered spans, so it reconstructs display text rather than the original raw markdown. It keeps visible text copyable after rollback, but responses with markdown-specific syntax can diverge from the original source. - **Clipboard fallback ordering**: SSH still uses OSC 52 exclusively because native/PowerShell clipboard access would target the wrong machine. Local sessions try native clipboard first, then WSL PowerShell when running under WSL, then OSC 52. This adds one process-spawn fallback for WSL users but keeps the normal desktop and SSH paths simple. ## Architecture ``` chatwidget.rs ├── agent_turn_markdowns: Vec // ordinal-indexed history ├── last_agent_markdown: Option // always == last entry's markdown ├── completed_turn_count: usize // incremented when user turns enter history ├── saw_copy_source_this_turn: bool // dedup guard ├── clipboard_lease: Option // keeps Linux clipboard owner alive │ ├── record_agent_markdown(&str) // append/update history entry ├── truncate_agent_turn_markdowns_to_turn_count() // rollback support ├── copy_last_agent_markdown() // public entry point (slash + hotkey) └── copy_last_agent_markdown_with(fn) // testable core clipboard_copy.rs ├── copy_to_clipboard(text) -> Result> ├── copy_to_clipboard_with(text, ssh, wsl, osc52_fn, arboard_fn, wsl_fn) ├── ClipboardLease { _clipboard on linux } ├── arboard_copy(text) // platform-conditional native clipboard path ├── wsl_clipboard_copy(text) // WSL PowerShell fallback ├── osc52_copy(text) // /dev/tty -> stdout fallback ├── SuppressStderr // macOS stderr redirect guard ├── is_ssh_session() └── is_wsl_session() app_backtrack.rs ├── last_agent_markdown_from_transcript() // reconstruct from visible cells └── truncate call sites in trim/apply_confirmed_rollback ``` ## Observability - `tracing::warn!` on native clipboard failure before OSC 52 fallback. - `tracing::debug!` on `/dev/tty` open/write failure before stdout fallback. - History cell messages: "Copied last message to clipboard", "Copy failed: {error}", "No agent response to copy" appear in the TUI transcript. ## Tests - `clipboard_copy.rs`: Unit tests cover OSC 52 encoding roundtrip, payload size rejection, writer output, SSH-only OSC52 routing, non-WSL native-to-OSC52 fallback, WSL native-to-PowerShell fallback, WSL PowerShell-to-OSC52 fallback, and all-error reporting via strategy injection. - `chatwidget/tests/slash_commands.rs`: Updated existing `/copy` tests to use `last_agent_markdown_text()` accessor. Added coverage for the Linux clipboard lease lifecycle, missing `TurnComplete.last_agent_message` fallback through completed assistant items, replayed legacy agent messages, stale-output prevention after rollback, and the `Ctrl+O` no-output hotkey path. - `app_backtrack.rs`: Added `agent_group_count_ignores_context_compacted_marker` verifying that info-event cells don't inflate the agent group count. --------- Co-authored-by: Felipe Coury Co-authored-by: Claude Opus 4.6 (1M context) --- codex-rs/tui/src/app.rs | 1 - codex-rs/tui/src/app_backtrack.rs | 50 ++ codex-rs/tui/src/chatwidget.rs | 209 ++++-- ...ts__slash_copy_no_output_info_message.snap | 5 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 5 +- .../src/chatwidget/tests/slash_commands.rs | 181 +++-- codex-rs/tui/src/clipboard_copy.rs | 638 ++++++++++++++++++ codex-rs/tui/src/clipboard_text.rs | 218 ------ codex-rs/tui/src/lib.rs | 2 +- codex-rs/tui/src/slash_command.rs | 4 +- codex-rs/tui/tooltips.txt | 1 + 11 files changed, 932 insertions(+), 382 deletions(-) create mode 100644 codex-rs/tui/src/clipboard_copy.rs delete mode 100644 codex-rs/tui/src/clipboard_text.rs 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.