diff --git a/codex-rs/tui/src/app/agent_message_consolidation.rs b/codex-rs/tui/src/app/agent_message_consolidation.rs index 4b8120185d..bf1627c998 100644 --- a/codex-rs/tui/src/app/agent_message_consolidation.rs +++ b/codex-rs/tui/src/app/agent_message_consolidation.rs @@ -8,7 +8,6 @@ //! resize re-renders. The retained view preserves its displayed revision before that //! replacement, then repaints without replaying terminal scrollback. -use std::path::PathBuf; use std::sync::Arc; use color_eyre::eyre::Result; @@ -18,7 +17,6 @@ use super::resize_reflow::trailing_run_start; use crate::app_event::ConsolidationScrollbackReflow; use crate::history_cell; use crate::history_cell::HistoryCell; -use crate::inline_visualization::InlineVisualizationContext; use crate::pager_overlay::Overlay; use crate::tui; @@ -26,9 +24,7 @@ impl App { pub(super) fn handle_consolidate_agent_message( &mut self, tui: &mut tui::Tui, - source: String, - cwd: PathBuf, - inline_visualization_context: Option, + source: history_cell::AgentMarkdownCell, scrollback_reflow: ConsolidationScrollbackReflow, deferred_history_cell: Option>, ) -> Result<()> { @@ -51,20 +47,14 @@ impl App { let end = self.transcript_cells.len(); tracing::debug!( "ConsolidateAgentMessage: transcript_cells.len()={end}, source_len={}", - source.len() + source.copy_source().map_or(/*default*/ 0, str::len) ); let start = trailing_run_start::(&self.transcript_cells); if start < end { tracing::debug!( "ConsolidateAgentMessage: replacing cells [{start}..{end}] with AgentMarkdownCell" ); - let consolidated: Arc = Arc::new( - history_cell::AgentMarkdownCell::new_with_inline_visualizations( - source, - &cwd, - inline_visualization_context, - ), - ); + let consolidated: Arc = Arc::new(source); self.native_history .consolidate(&self.transcript_cells[start..end], &consolidated); if tui.is_owned_screen() { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f59717f3de..97d06f9268 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -56,6 +56,8 @@ impl App { | AppEvent::CopyWarning(_) | AppEvent::UpdateWarnings { .. } | AppEvent::CopySelection { .. } + | AppEvent::SelectTranscriptCopy { .. } + | AppEvent::TranscriptCopyClosed | AppEvent::ConfirmDaemonUpdate(_) | AppEvent::RunDaemonUpdate(_) | AppEvent::InsertHistoryCell(_) @@ -391,6 +393,17 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } } + AppEvent::TranscriptCopyClosed => { + self.chat_widget.maybe_send_next_queued_input(); + } + AppEvent::SelectTranscriptCopy { guard } => { + let size = tui.prepare_draw_size()?; + self.render_owned_transcript(tui, size)?; + if !self.transcript_view.begin_copy_mode(&self.transcript_cells, Some(guard)) { + self.chat_widget.add_info_message("Nothing to copy".into(), /*hint*/ None); + } + tui.frame_requester().schedule_frame(); + } AppEvent::CopySelection { text, label, format } => { let result = tui.clipboard.copy(text, format, tui.frame_requester()); self.chat_widget.show_copy_result(&label, result); @@ -879,6 +892,7 @@ impl App { } AppEvent::ConsolidateAgentMessage { source, + copy_source, cwd, inline_visualization_context, scrollback_reflow, @@ -886,9 +900,7 @@ impl App { } => { self.handle_consolidate_agent_message( tui, - source, - cwd, - inline_visualization_context, + history_cell::AgentMarkdownCell::new_with_inline_visualizations(source, &cwd, inline_visualization_context).with_copy_source(copy_source), scrollback_reflow, deferred_history_cell, )?; diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 26a570b4e0..1db2c4b80d 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -568,8 +568,6 @@ impl App { &text, !copy_on_select, ); - self.transcript_view - .show_copy_feedback(&result, text.chars().count()); if resume_following && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_))) { diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 009b47fdc7..38f1954692 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -178,9 +178,10 @@ async fn list_spacing_completion_preserves_the_scrolled_reader() -> Result<()> { assert!(buffer_text(&before).contains("• c")); app.handle_consolidate_agent_message( &mut tui, - "- First item wraps onto a second row\n- b\n- c".into(), - app.config.cwd.to_path_buf(), - /*inline_visualization_context*/ None, + crate::history_cell::AgentMarkdownCell::new( + "- First item wraps onto a second row\n- b\n- c".into(), + &app.config.cwd, + ), ConsolidationScrollbackReflow::Required, /*deferred_history_cell*/ None, )?; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2c900b72fd..4c68189bd9 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1,5 +1,7 @@ //! App-level orchestration tests for the TUI. +#[path = "tests/copy_mode_tests.rs"] +mod copy_mode_tests; #[path = "tests/mcp_login_tests.rs"] mod mcp_login_tests; @@ -6868,6 +6870,8 @@ async fn closing_fullscreen_inline_overlay_restores_history_once() -> Result<()> #[tokio::test] async fn copy_picker_opening_preserves_terminal_scrollback_without_reflow() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget.local_settings.transcript_mode = + crate::transcript_mode::TranscriptMode::Terminal; let response = "Existing response\n\n```rust\nkeep_scrollback();\n```"; app.chat_widget.handle_server_notification( ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { @@ -7066,9 +7070,7 @@ async fn directive_only_completion_removes_streamed_directive() -> Result<()> { let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_consolidate_agent_message( &mut tui, - String::new(), - PathBuf::from("/tmp"), - /*inline_visualization_context*/ None, + AgentMarkdownCell::new(String::new(), Path::new("/tmp")), ConsolidationScrollbackReflow::Required, /*deferred_history_cell*/ None, )?; @@ -7111,10 +7113,11 @@ async fn required_stream_reflow_during_capped_initial_replay_survives_transcript let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_consolidate_agent_message( &mut tui, - "Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |" - .to_string(), - PathBuf::from("/tmp"), - /*inline_visualization_context*/ None, + AgentMarkdownCell::new( + "Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |" + .into(), + Path::new("/tmp"), + ), ConsolidationScrollbackReflow::Required, /*deferred_history_cell*/ None, )?; diff --git a/codex-rs/tui/src/app/tests/copy_mode_tests.rs b/codex-rs/tui/src/app/tests/copy_mode_tests.rs new file mode 100644 index 0000000000..ff360f0043 --- /dev/null +++ b/codex-rs/tui/src/app/tests/copy_mode_tests.rs @@ -0,0 +1,30 @@ +//! Exercise copy entry through the app dispatcher, including an empty transcript. + +use super::*; + +#[tokio::test] +async fn empty_owned_copy_reports_feedback() -> Result<()> { + let (mut app, mut rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let guard = Arc::new(crate::copy_input_guard::CopyInputGuard( + app.app_event_tx.clone(), + )); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::SelectTranscriptCopy { guard }, + ) + .await?; + let lines = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell.display_lines(/*width*/ 80)), + _ => None, + }) + .flatten() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + insta::assert_snapshot!(lines, @"• Nothing to copy"); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs index 6fcd5a22b5..9af0abbc93 100644 --- a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs +++ b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs @@ -268,6 +268,7 @@ async fn inactive_thread_user_verification_preserves_foreground_stream() -> Resu AppEvent::InsertHistoryCell(cell) => app.insert_history_cell(&mut tui, cell), AppEvent::ConsolidateAgentMessage { source, + copy_source, cwd, inline_visualization_context, scrollback_reflow, @@ -276,9 +277,12 @@ async fn inactive_thread_user_verification_preserves_foreground_stream() -> Resu completed_messages.push(source.clone()); app.handle_consolidate_agent_message( &mut tui, - source, - cwd, - inline_visualization_context, + history_cell::AgentMarkdownCell::new_with_inline_visualizations( + source, + &cwd, + inline_visualization_context, + ) + .with_copy_source(copy_source), scrollback_reflow, deferred_history_cell, )?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 88a8a74514..910d203646 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -474,6 +474,14 @@ pub(crate) enum AppEvent { destination: TranscriptExportDestination, }, + /// Select a response or block directly in the owned transcript. + SelectTranscriptCopy { + guard: Arc, + }, + + /// Retry queued input after the transcript copy owner is released. + TranscriptCopyClosed, + /// Copy text through the session clipboard worker. CopySelection { text: Arc, @@ -1166,6 +1174,7 @@ pub(crate) enum AppEvent { /// transcript without first writing its provisional render to scrollback. ConsolidateAgentMessage { source: String, + copy_source: Option, cwd: PathBuf, inline_visualization_context: Option, scrollback_reflow: ConsolidationScrollbackReflow, diff --git a/codex-rs/tui/src/chatwidget/copy_picker.rs b/codex-rs/tui/src/chatwidget/copy_picker.rs index f67f88dcbf..c41d40b26e 100644 --- a/codex-rs/tui/src/chatwidget/copy_picker.rs +++ b/codex-rs/tui/src/chatwidget/copy_picker.rs @@ -8,6 +8,17 @@ use crate::text_formatting::truncate_text; impl ChatWidget { pub(super) fn show_copy_picker(&mut self) { + if self.local_settings.transcript_mode.is_owned() + && self.transcript.last_status_copy_targets.is_none() + { + let guard = Arc::new(crate::copy_input_guard::CopyInputGuard( + self.app_event_tx.clone(), + )); + self.input_queue.transcript_copy = Arc::downgrade(&guard); + self.app_event_tx + .send(AppEvent::SelectTranscriptCopy { guard }); + return; + } let mut choices = Vec::new(); if let Some(status_targets) = &self.transcript.last_status_copy_targets { choices.push(( diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 7b841baf64..6ee018ed4c 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -60,7 +60,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::FollowTranscript); let should_submit_now = self.is_session_configured() && !self.is_plan_streaming_in_tui() - && !self.input_queue.suppress_queue_autosend + && !self.input_queue.submissions_paused() && !self.input_queue.rate_limit_recovery_pending && (!self.input_queue.user_turn_pending_start || self.turn_lifecycle.agent_turn_running); @@ -170,7 +170,7 @@ impl ChatWidget { } let should_run_now = self.is_session_configured() && !self.is_user_turn_pending_or_running() - && !self.input_queue.suppress_queue_autosend + && !self.input_queue.submissions_paused() && !self.input_queue.rate_limit_recovery_pending; if action != QueuedInputAction::ParseSlash { self.empty_state_animation.borrow_mut().dismiss(); @@ -237,7 +237,7 @@ impl ChatWidget { if !self.is_session_configured() || self.fork_in_progress || self.has_misalignment_policy_violation() - || self.input_queue.suppress_queue_autosend + || self.input_queue.submissions_paused() || self.input_queue.rate_limit_recovery_pending || self.input_queue.recovered_queue || self.input_queue.has_unconfirmed_messages() diff --git a/codex-rs/tui/src/chatwidget/input_queue.rs b/codex-rs/tui/src/chatwidget/input_queue.rs index 13600b2d86..bdaedab6ca 100644 --- a/codex-rs/tui/src/chatwidget/input_queue.rs +++ b/codex-rs/tui/src/chatwidget/input_queue.rs @@ -49,6 +49,7 @@ pub(super) struct InputQueueState { /// fresh user turn instead of restoring them into the composer. pub(super) submit_pending_steers_after_interrupt: bool, pub(super) suppress_queue_autosend: bool, + pub(super) transcript_copy: std::sync::Weak, /// Hold submissions while a usage failure or backend-directed model fallback is resolved. pub(super) rate_limit_recovery_pending: bool, /// Pause for user recovery (for example an image or permission failure), independent of delivery. @@ -56,6 +57,10 @@ pub(super) struct InputQueueState { } impl InputQueueState { + pub(super) fn submissions_paused(&self) -> bool { + self.suppress_queue_autosend || self.transcript_copy.strong_count() > 0 + } + pub(super) fn has_unconfirmed_messages(&self) -> bool { self.queued_user_messages .iter() diff --git a/codex-rs/tui/src/chatwidget/questions.rs b/codex-rs/tui/src/chatwidget/questions.rs index 37e32211dd..83884b3228 100644 --- a/codex-rs/tui/src/chatwidget/questions.rs +++ b/codex-rs/tui/src/chatwidget/questions.rs @@ -133,7 +133,7 @@ impl ChatWidget { } let main = self.bottom_pane.composer_draft_snapshot(); let accepted = if queued - || self.input_queue.suppress_queue_autosend + || self.input_queue.submissions_paused() || self.is_plan_streaming_in_tui() || self.input_queue.user_turn_pending_start && !self.turn_lifecycle.agent_turn_running diff --git a/codex-rs/tui/src/chatwidget/startup_submission.rs b/codex-rs/tui/src/chatwidget/startup_submission.rs index 6d9deb7856..7c2f0cab79 100644 --- a/codex-rs/tui/src/chatwidget/startup_submission.rs +++ b/codex-rs/tui/src/chatwidget/startup_submission.rs @@ -51,7 +51,7 @@ impl ChatWidget { || self.startup_submission_has_protected_input() || self.is_plan_streaming_in_tui() || self.is_user_turn_pending_or_running() - || self.input_queue.suppress_queue_autosend + || self.input_queue.submissions_paused() || self.input_queue.rate_limit_recovery_pending || self.input_queue.recovered_queue || self.input_queue.has_unconfirmed_messages() diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 02491ee761..3fb130d563 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -89,7 +89,10 @@ impl ChatWidget { let needs_scrollback_reflow = controller.needs_scrollback_reflow(); self.clear_active_stream_tail(); let (cell, streamed_source) = controller.finalize(); - let completed_message_differs = completed_message.is_some_and(|completed| { + let completed_display = completed_message.map(|source| { + parse_assistant_markdown(source, self.config.cwd.as_path()).visible_markdown + }); + let completed_message_differs = completed_display.as_deref().is_some_and(|completed| { let Some(streamed) = streamed_source.as_deref() else { return true; }; @@ -117,10 +120,9 @@ impl ChatWidget { }; // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell // that can re-render from source on resize. - let source = completed_message.map(str::to_owned).or_else(|| { - streamed_source.map(|source| { - parse_assistant_markdown(&source, self.config.cwd.as_path()).visible_markdown - }) + let copy_source = completed_message.map(str::to_owned).or(streamed_source); + let source = copy_source.as_deref().map(|source| { + parse_assistant_markdown(source, self.config.cwd.as_path()).visible_markdown }); if let Some(source) = source { let inline_visualization_context = self.thread_id.and_then(|thread_id| { @@ -132,6 +134,7 @@ impl ChatWidget { self.note_stream_consolidation_queued(); self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { source, + copy_source, cwd: self.config.cwd.to_path_buf(), inline_visualization_context, scrollback_reflow, @@ -189,7 +192,9 @@ impl ChatWidget { && let Some(message) = message && !message.is_empty() { - self.handle_streaming_delta(message.to_string()); + let displayed = + parse_assistant_markdown(message, self.config.cwd.as_path()).visible_markdown; + self.handle_streaming_delta(displayed); } // Item completion is authoritative. Use it for consolidation so any // deltas dropped by a saturated transport cannot truncate the transcript. @@ -439,12 +444,13 @@ impl ChatWidget { parsed.visible_markdown.clone(), self.config.cwd.as_path(), context, - ), + ) + .with_copy_source(Some(message.clone())), ); self.handle_stream_finished(); self.request_redraw(); } else { - self.finalize_completed_assistant_message(Some(parsed.visible_markdown.as_str())); + self.finalize_completed_assistant_message(Some(&message)); } if !parsed.visible_markdown.is_empty() { self.transcript diff --git a/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs index ad9631c072..4c2cab5b58 100644 --- a/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs @@ -8,6 +8,7 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn whole_response_copy_uses_followup_labels() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; let directive = r#":codex-followup[**Inspect items[0]**]{prompt="private"}"#; let literal = format!("\n\n`{directive}`\n\n```text\n{directive}\n```\n\n:codex-followup[unfinished"); @@ -37,6 +38,7 @@ async fn whole_response_copy_uses_followup_labels() { #[tokio::test] async fn completed_response_copy_preserves_markdown_line_endings() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; let markdown = "Hard break: \nstarts a new line.\n\n```text\ncode with trailing spaces \n```"; replay_agent_message( &mut chat, @@ -59,6 +61,7 @@ async fn completed_response_copy_preserves_markdown_line_endings() { #[tokio::test] async fn copy_export_picker_custom_keys_preserve_payloads_and_composer_draft() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; let mut keymap = crate::keymap::RuntimeKeymap::defaults(); keymap.list.accept = vec![key_hint::plain(KeyCode::F(/*n*/ 3))]; keymap.list.cancel = vec![key_hint::plain(KeyCode::F(/*n*/ 2))]; @@ -127,3 +130,41 @@ async fn copy_export_picker_custom_keys_preserve_payloads_and_composer_draft() { assert!(chat.no_modal_or_popup_active()); assert_eq!(chat.bottom_pane.composer_text(), "Keep this draft"); } + +#[tokio::test] +async fn owned_copy_opens_transcript_selection() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Owned; + let markdown = "Latest **response**\n\n```sh\necho hello \n```"; + chat.transcript.last_agent_markdown = Some(markdown.into()); + chat.show_copy_picker(); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::SelectTranscriptCopy { .. })) + ); + assert!(chat.no_modal_or_popup_active()); +} + +#[tokio::test] +async fn completed_copy_source_survives_replay_and_stream_consolidation() { + let source = "Intro\r\n\r\n```sh\r\necho x \r\n```\r\n"; + for replay in [false, true] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + if replay { + replay_agent_message(&mut chat, "source", source, ReplayKind::ThreadSnapshot); + } else { + complete_assistant_message( + &mut chat, + "source", + source, + Some(MessagePhase::FinalAnswer), + ); + } + let retained = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => cell.copy_source().map(str::to_owned), + AppEvent::ConsolidateAgentMessage { copy_source, .. } => copy_source, + _ => None, + }); + assert_eq!(retained.as_deref(), Some(source)); + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e5e20e2db9..bc1ba4e4fe 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1562,6 +1562,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { #[tokio::test] async fn slash_copy_picker_uses_completed_commentary_during_active_turn() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; complete_turn_with_message(&mut chat, "turn-1", Some("Previous final response")); handle_turn_started(&mut chat, "active"); let commentary = "Here is the command:\n\n```sh\necho current\n```"; @@ -1596,6 +1597,7 @@ async fn slash_copy_picker_uses_completed_commentary_during_active_turn() { #[tokio::test] async fn slash_copy_state_tracks_plan_item_completion() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; complete_turn_with_message(&mut chat, "previous", Some("```rust\nstale();\n```")); let plan_text = "## Plan\n\n1. Build it\n2. Test it\n\n```sh\r\njust test \r\n```".to_string(); @@ -1653,6 +1655,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { #[tokio::test] async fn slash_copy_picker_preserves_completed_source_whitespace_and_hides_directives() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; let source = concat!( "Intro ::git-push{cwd=\"/repo\"}\r\n\r\n", "```powershell\r\nWrite-Output value \r\nWrite-Output done\t\r\n```\r\n\r\n", @@ -1709,6 +1712,7 @@ async fn slash_copy_picker_defers_queued_input_until_selection_or_cancellation_s for close_key in [KeyCode::Enter, KeyCode::Esc] { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.thread_id = Some(ThreadId::new()); complete_turn_with_message(&mut chat, "previous", Some("Previous response")); handle_turn_started(&mut chat, "active"); @@ -1762,6 +1766,7 @@ async fn slash_copy_picker_defers_queued_input_until_selection_or_cancellation_s #[tokio::test] async fn slash_copy_picker_previews_whole_response_code_blocks_and_blockquotes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.transcript.last_agent_markdown = Some( "Overview\n\n```python\nprint('first')\nprint('second')\n```\n\n> Important quote\n\n```sh\necho done\n```" .to_string(), @@ -1881,6 +1886,7 @@ async fn slash_copy_status_yields_to_later_turns_and_commands_even_after_refresh NextTurn::ShellCommand, ] { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.thread_id = Some(ThreadId::new()); complete_turn_with_message(&mut chat, "turn-1", Some("Original reply")); set_chatgpt_auth(&mut chat); @@ -1929,6 +1935,7 @@ async fn slash_copy_status_yields_to_later_turns_and_commands_even_after_refresh async fn slash_copy_status_yields_to_queued_commands_after_queued_status() { for command in ["/rename Queued rename", "!echo hello"] { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/status"); @@ -1955,6 +1962,7 @@ async fn slash_copy_status_yields_to_queued_commands_after_queued_status() { async fn slash_copy_picker_waits_for_submission_after_typing_or_autocomplete() { for select_from_autocomplete in [false, true] { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.transcript.last_agent_markdown = Some("Ready to copy".to_string()); chat.bottom_pane .set_composer_text("/cop".to_string(), Vec::new(), Vec::new()); @@ -1990,6 +1998,7 @@ async fn slash_copy_picker_waits_for_submission_after_typing_or_autocomplete() { #[tokio::test] async fn slash_copy_picker_waits_for_submission_after_paste() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.transcript.last_agent_markdown = Some("Ready to copy".to_string()); chat.handle_paste("/copy".to_string()); @@ -2009,6 +2018,7 @@ async fn slash_copy_picker_waits_for_submission_after_paste() { #[tokio::test] async fn slash_copy_picker_numeric_shortcuts_copy_whole_response_and_exact_code() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; let markdown = "Intro\n\n```rust\nfn main() {\n run();\n}\n```"; chat.transcript.last_agent_markdown = Some(markdown.to_string()); @@ -2035,6 +2045,7 @@ async fn slash_copy_picker_renders_only_whole_responses_as_markdown() { use crate::clipboard_copy::CopyFormat; let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; let markdown = "**Intro**\n\n```text\n# literal *code*\n```\n\n> **Quote**\n"; chat.transcript.last_agent_markdown = Some(markdown.to_string()); for (key, expected) in [ @@ -2064,6 +2075,7 @@ async fn slash_copy_picker_renders_only_whole_responses_as_markdown() { #[tokio::test] async fn slash_copy_picker_preserves_nested_blockquote_markdown_and_code() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.transcript.last_agent_markdown = Some("> **outer**\n> > _nested_\n> ```sh\n> nested()\n> ```\n".to_string()); @@ -2088,6 +2100,7 @@ async fn slash_copy_picker_preserves_nested_blockquote_markdown_and_code() { #[tokio::test] async fn slash_copy_picker_supports_arrow_navigation_enter_and_scrolling() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.transcript.last_agent_markdown = Some( (1..=11) .map(|index| format!("```sh\nexample_{index}()\n```\n")) @@ -2114,6 +2127,7 @@ async fn slash_copy_picker_supports_arrow_navigation_enter_and_scrolling() { #[tokio::test] async fn slash_copy_picker_escape_dismisses_without_copying() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.transcript.last_agent_markdown = Some("Nothing copied".to_string()); chat.dispatch_command(SlashCommand::Copy); @@ -2128,6 +2142,7 @@ async fn slash_copy_picker_escape_dismisses_without_copying() { #[tokio::test] async fn slash_copy_picker_remains_available_from_parent_owned_threads() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.transcript.last_agent_markdown = Some("Safe local copy".to_string()); chat.set_parent_owned_thread(); chat.bottom_pane @@ -2240,6 +2255,7 @@ async fn slash_daybreak_offers_an_application_when_unavailable() { #[tokio::test] 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.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; chat.dispatch_command(SlashCommand::Copy); @@ -3732,3 +3748,35 @@ async fn transcript_copy_feedback_stays_in_the_footer_without_history_or_interru render_bottom_popup(&chat, /*width*/ 80) ); } + +#[tokio::test] +async fn owned_copy_guard_defers_queued_input_until_released() { + for queued in ["/new", "/clear", "follow-up prompt"] { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Owned; + chat.thread_id = Some(ThreadId::new()); + complete_turn_with_message(&mut chat, "previous", Some("Previous response")); + handle_turn_started(&mut chat, "active"); + queue_composer_text_with_tab(&mut chat, queued); + chat.dispatch_command(SlashCommand::Copy); + let guard = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::SelectTranscriptCopy { guard, .. } => Some(guard), + _ => None, + }) + .unwrap(); + complete_turn_with_message(&mut chat, "active", Some("New response")); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + while let Ok(event) = rx.try_recv() { + assert!(!matches!( + event, + AppEvent::NewSession { .. } | AppEvent::ClearUi { .. } + )); + } + drop(guard); + assert_matches!(rx.try_recv(), Ok(AppEvent::TranscriptCopyClosed)); + chat.maybe_send_next_queued_input(); + assert!(chat.input_queue.queued_user_messages.is_empty()); + } +} diff --git a/codex-rs/tui/src/copy_input_guard.rs b/codex-rs/tui/src/copy_input_guard.rs new file mode 100644 index 0000000000..be59888e4d --- /dev/null +++ b/codex-rs/tui/src/copy_input_guard.rs @@ -0,0 +1,13 @@ +//! Keep queued submissions paused until the transcript selector releases its owner. + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +#[derive(Debug)] +pub(crate) struct CopyInputGuard(pub(crate) AppEventSender); + +impl Drop for CopyInputGuard { + fn drop(&mut self) { + self.0.send(AppEvent::TranscriptCopyClosed); + } +} diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index f80f311c75..1cd4a356c5 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -502,6 +502,7 @@ impl HistoryCell for AgentMessageCell { #[derive(Debug)] pub(crate) struct AgentMarkdownCell { markdown_source: String, + copy_source: Option, cwd: PathBuf, inline_visualization_context: Option, rendered_lines: Option, @@ -509,6 +510,11 @@ pub(crate) struct AgentMarkdownCell { } impl AgentMarkdownCell { + pub(crate) fn with_copy_source(mut self, source: Option) -> Self { + self.copy_source = source; + self + } + /// Create a finalized source-backed assistant message cell. /// /// `markdown_source` must be the raw source accumulated by the stream controller, not already @@ -535,6 +541,7 @@ impl AgentMarkdownCell { .then(MarkdownRenderCache::default); Self { markdown_source, + copy_source: None, cwd: cwd.to_path_buf(), inline_visualization_context, rendered_lines, @@ -621,6 +628,10 @@ impl AgentMarkdownCell { } impl HistoryCell for AgentMarkdownCell { + fn copy_source(&self) -> Option<&str> { + Some(self.copy_source.as_deref().unwrap_or(&self.markdown_source)) + } + fn display_lines(&self, width: u16) -> Vec> { visible_lines(self.display_hyperlink_lines(width)) } diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index bbb05987b4..ac39aef420 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -213,6 +213,11 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { Vec::new() } + /// Original Markdown for semantic copy targets, independent of presentation. + fn copy_source(&self) -> Option<&str> { + None + } + /// Returns the logical lines for the main chat viewport. fn display_lines(&self, width: u16) -> Vec>; diff --git a/codex-rs/tui/src/history_cell/plans.rs b/codex-rs/tui/src/history_cell/plans.rs index f790bff8ed..d8d55194fa 100644 --- a/codex-rs/tui/src/history_cell/plans.rs +++ b/codex-rs/tui/src/history_cell/plans.rs @@ -148,6 +148,10 @@ impl ProposedPlanCell { } impl HistoryCell for ProposedPlanCell { + fn copy_source(&self) -> Option<&str> { + Some(&self.plan_markdown) + } + fn display_lines(&self, width: u16) -> Vec> { visible_lines(self.display_hyperlink_lines(width)) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c865affb22..001c3aac68 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -125,6 +125,7 @@ mod clock_format; mod collaboration_modes; mod color; mod config_update; +mod copy_input_guard; pub(crate) mod custom_terminal; mod daybreak; mod experimental_features; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 2d4db67f55..d2e2a9b04e 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -97,17 +97,23 @@ pub(crate) fn render_markdown_agent_with_list_spacing( list_spacing: ListSpacing, ) -> Vec { let rewritten = rewrite_inline_visualizations(markdown_source, inline_visualization_context); - let normalized = normalize_markdown_for_rendering(&rewritten.markdown); + let mut code_sources = Vec::new(); + let normalized = if crate::markdown_render::preferences::current().tables { + unwrap_markdown_fences_with_sources(&rewritten.markdown, &mut code_sources) + } else { + Cow::Borrowed(rewritten.markdown.as_ref()) + }; let is_hidden_link_destination = |destination: &str| { rewritten.trusted_file_links.contains_key(destination) || crate::markdown_render::hide_web_link_destination(destination) }; - let mut lines = crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd( + let mut lines = crate::markdown_render::render_with_copy_sources( &normalized, width, cwd, &is_hidden_link_destination, list_spacing, + code_sources, ) .lines; for hyperlink in lines.iter_mut().flat_map(|line| &mut line.hyperlinks) { @@ -266,7 +272,14 @@ pub(crate) fn normalize_markdown_for_rendering(markdown_source: &str) -> Cow<'_, /// The fence unwrapping is intentionally conservative: it buffers the entire fence body before /// deciding, and an unclosed fence at end-of-input is re-emitted with its opening line so partial /// streams degrade to code display. -pub(crate) fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> { +pub(crate) fn unwrap_markdown_fences(markdown_source: &str) -> Cow<'_, str> { + unwrap_markdown_fences_with_sources(markdown_source, &mut Vec::new()) +} + +fn unwrap_markdown_fences_with_sources<'a>( + markdown_source: &'a str, + sources: &mut Vec, +) -> Cow<'a, str> { // Zero-copy fast path: most messages contain no fences at all. if !markdown_source.contains("```") && !markdown_source.contains("~~~") { return Cow::Borrowed(markdown_source); @@ -396,7 +409,7 @@ pub(crate) fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, st let mut active_fence: Option = None; let mut source_offset = 0usize; - let mut push_source_range = |range: Range| { + let push_source_range = |out: &mut String, range: Range| { if !range.is_empty() { out.push_str(&markdown_source[range]); } @@ -410,7 +423,7 @@ pub(crate) fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, st if let Some(active) = active_fence.take() { match active { ActiveFence::Passthrough(fence) => { - push_source_range(line_range); + push_source_range(&mut out, line_range); if !is_close_fence(line, fence) { active_fence = Some(ActiveFence::Passthrough(fence)); } @@ -421,15 +434,27 @@ pub(crate) fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, st &content_from_ranges(markdown_source, &data.content_ranges), data.fence.is_blockquoted, ) { + let start = out.len(); for range in data.content_ranges { - push_source_range(range); + push_source_range(&mut out, range); + } + let fenced = &markdown_source[data.opening_range.start..line_range.end]; + if let Some(CopyTarget::Code { content, .. }) = + extract_copy_targets(fenced) + .into_iter() + .find(|target| matches!(target, CopyTarget::Code { .. })) + { + sources.push(crate::markdown_copy::SourceBlock { + range: start..out.len(), + content, + }); } } else { - push_source_range(data.opening_range); + push_source_range(&mut out, data.opening_range); for range in data.content_ranges { - push_source_range(range); + push_source_range(&mut out, range); } - push_source_range(line_range); + push_source_range(&mut out, line_range); } } else { data.content_ranges.push(line_range); @@ -450,22 +475,22 @@ pub(crate) fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, st }, ))); } else { - push_source_range(line_range); + push_source_range(&mut out, line_range); active_fence = Some(ActiveFence::Passthrough(fence)); } continue; } - push_source_range(line_range); + push_source_range(&mut out, line_range); } if let Some(active) = active_fence { match active { ActiveFence::Passthrough(_) => {} ActiveFence::MarkdownCandidate(data) => { - push_source_range(data.opening_range); + push_source_range(&mut out, data.opening_range); for range in data.content_ranges { - push_source_range(range); + push_source_range(&mut out, range); } } } diff --git a/codex-rs/tui/src/markdown_copy.rs b/codex-rs/tui/src/markdown_copy.rs index ebc933a76b..7a9b9d2235 100644 --- a/codex-rs/tui/src/markdown_copy.rs +++ b/codex-rs/tui/src/markdown_copy.rs @@ -16,6 +16,24 @@ use crate::terminal_hyperlinks::LogicalLineSource; pub(crate) mod table; +/// A transformed fenced block and its coordinates in the renderer input. +#[derive(Clone)] +pub(crate) struct SourceBlock { + pub(crate) range: Range, + pub(crate) content: Arc, +} + +pub(crate) fn quote_content(source: &str) -> String { + source + .split_inclusive('\n') + .map(|line| { + line.trim_start_matches(' ') + .strip_prefix('>') + .map_or(line, |line| line.strip_prefix(' ').unwrap_or(line)) + }) + .collect() +} + // Bound retained inline stacks and container prefixes independently of parser nesting. pub(crate) const MAX_COPY_DEPTH: usize = 64; @@ -60,6 +78,10 @@ pub(crate) struct CopyLine { /// Restore the containing item when a multiline selection starts in its later paragraph. pub(crate) item_prefix: String, pub(crate) code: bool, + /// Source shared by rows when rendering replaces code with a diagram or table. + pub(crate) code_source: Option>, + /// Outer quote source, retained even when it contains only code. + pub(crate) quote_source: Option>, pub(crate) table: Option, table_cell: bool, pub(crate) rule: bool, diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index e07cb56868..614c091487 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -81,6 +81,7 @@ mod mermaid; pub(crate) mod preferences; mod source_tables; mod streaming; +pub(crate) use streaming::render_with_copy_sources; mod table_key_value; mod task_lists; mod web_links; @@ -447,6 +448,9 @@ struct Writer<'a, 'policy> { inline_styles: Vec