diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 7aaea2109f..cfd0bba5c3 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -141,6 +141,9 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } } + AppEvent::CopySelection { text, label } => { + self.chat_widget.copy_selection(text, label); + } AppEvent::ClearUi { name } => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ca3cf6cb35..6bd59875ef 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5825,6 +5825,77 @@ async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() { ); } +#[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; + let response = "Existing response\n\n```rust\nkeep_scrollback();\n```"; + app.chat_widget.handle_server_notification( + ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { + thread_id: String::new(), + turn_id: "turn-1".to_string(), + completed_at_ms: 0, + item: serde_json::from_value(serde_json::json!({ + "type": "agentMessage", + "id": "message-1", + "text": response, + })) + .expect("valid completed agent message"), + }), + /*replay_kind*/ None, + ); + while app_event_rx.try_recv().is_ok() {} + + app.transcript_cells = vec![ + plain_line_cell("Older terminal scrollback"), + Arc::new(AgentMarkdownCell::new( + response.to_string(), + Path::new("/tmp"), + )), + ]; + app.deferred_history_lines = vec![Line::from("Buffered scrollback").into()]; + app.has_emitted_history_lines = true; + app.scrollback_has_older_history = true; + app.transcript_reflow.note_width(/*width*/ 80); + let before = app + .render_transcript_lines_for_reflow(/*width*/ 80) + .lines + .iter() + .map(rendered_line_text) + .collect::>(); + + app.chat_widget.apply_external_edit("/copy".to_string()); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response")); + assert_eq!( + app.render_transcript_lines_for_reflow(/*width*/ 80) + .lines + .iter() + .map(rendered_line_text) + .collect::>(), + before + ); + assert_eq!(app.transcript_cells.len(), 2); + assert_eq!(app.deferred_history_lines.len(), 1); + assert!(app.has_emitted_history_lines); + assert!(app.scrollback_has_older_history); + assert!(!app.transcript_reflow.has_pending_reflow()); + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!( + event, + AppEvent::ClearUi { .. } + | AppEvent::ClearUiAndSubmitUserMessage { .. } + | AppEvent::InsertHistoryCell(_) + | AppEvent::ConsolidateAgentMessage { .. } + ), + "opening a picker must not modify terminal scrollback: {event:?}" + ); + } +} + #[tokio::test] async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 46b18ba8f3..1b50c571a3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -287,6 +287,12 @@ pub(crate) enum AppEvent { destination: TranscriptExportDestination, }, + /// Copy a picker selection while retaining its clipboard lease in the chat widget. + CopySelection { + text: Arc, + label: String, + }, + /// Persist a submitted prompt in the cross-session message history. AppendMessageHistoryEntry { thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap index fa06c625ab..7dcde80358 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap @@ -25,7 +25,7 @@ expression: commands /compact - summarize conversation to prevent hitting the context limit /agents - view and switch between all active agent sessions /side - start a side conversation in an ephemeral fork -/copy - copy last response as markdown +/copy - copy the last response, code block, or quote /export - export the conversation as markdown /raw - toggle raw scrollback mode for copy-friendly terminal selection /diff - show git diff (including untracked files) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b9b5e0f0ef..9dda0936c8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -991,13 +991,6 @@ 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() { - self.transcript.record_agent_markdown(message.to_string()); - } - } - pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 255d519455..72680be29d 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -326,6 +326,99 @@ impl ChatWidget { self.request_redraw(); } + pub(super) fn show_copy_picker(&mut self) { + let Some(markdown) = self + .transcript + .last_agent_markdown + .clone() + .filter(|markdown| !markdown.is_empty()) + else { + self.copy_last_agent_markdown(); + return; + }; + + let mut choices = vec![( + "Whole response".to_string(), + Arc::::from(markdown.as_str()), + )]; + let source = self + .transcript + .last_agent_source + .as_deref() + .unwrap_or(&markdown); + choices.extend( + crate::markdown::extract_copy_targets(source) + .into_iter() + .filter_map(|target| match target { + crate::markdown::CopyTarget::Code { language, content } => Some(( + language.map_or_else( + || "Code block".to_string(), + |language| format!("{language} code"), + ), + content, + )), + crate::markdown::CopyTarget::Quote(content) => { + let content: String = content + .split_inclusive('\n') + .map(|line| crate::git_action_directives::strip_line_directives(line).0) + .collect(); + (!content.trim().is_empty()) + .then(|| ("Blockquote".to_string(), Arc::from(content))) + } + }), + ); + + let items = choices + .into_iter() + .map(|(label, text)| { + let description = text + .lines() + .find(|line| !line.trim().is_empty()) + .map(|line| line.trim().chars().take(72).collect()); + SelectionItem { + name: label.clone(), + description, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::CopySelection { + text: Arc::clone(&text), + label: label.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.show_selection_view(SelectionViewParams { + title: Some("Copy from response".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + self.defer_input_until_settings_applied(); + } + + pub(crate) fn copy_selection(&mut self, text: Arc, label: String) { + self.copy_selection_with(&text, &label, crate::clipboard_copy::copy_to_clipboard); + } + + pub(super) fn copy_selection_with( + &mut self, + text: &str, + label: &str, + copy_fn: impl FnOnce(&str) -> Result, String>, + ) { + match copy_fn(text) { + Ok(lease) => { + self.clipboard_lease = lease; + self.add_info_message(format!("Copied {label} to clipboard"), /*hint*/ None); + } + Err(error) => self.add_error_message(format!("Copy failed: {error}")), + } + self.request_redraw(); + } + #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { self.transcript.last_agent_markdown.as_deref() diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 1b434ec247..fb1b75fd8f 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -402,7 +402,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::Logout); } SlashCommand::Copy => { - self.copy_last_agent_markdown(); + self.show_copy_picker(); } SlashCommand::Export => { self.show_transcript_export_popup(); diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 9c19a93d22..b596ac3352 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -174,14 +174,17 @@ impl ChatWidget { } pub(super) fn on_plan_item_completed(&mut self, text: String) { - let streamed_plan = self.transcript.plan_delta_buffer.trim().to_string(); - let plan_text = if text.trim().is_empty() { - streamed_plan + let (plan_text, source) = if text.trim().is_empty() { + ( + self.transcript.plan_delta_buffer.trim().to_string(), + self.transcript.plan_delta_buffer.clone(), + ) } else { - text + (text.clone(), text) }; if !plan_text.trim().is_empty() { - self.record_agent_markdown(&plan_text); + self.transcript + .record_agent_markdown(plan_text.clone(), source); self.transcript.latest_proposed_plan_markdown = Some(plan_text.clone()); } // Plan commit ticks can hide the status row; remember whether we streamed plan output so @@ -331,7 +334,8 @@ impl ChatWidget { if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) && !parsed.visible_markdown.is_empty() { - self.record_agent_markdown(&parsed.visible_markdown); + self.transcript + .record_agent_markdown(parsed.visible_markdown.clone(), message); } if !from_replay && let Some(cwd) = parsed.last_created_branch_cwd() diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2997c0a24e..d2dc6e8b75 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -109,6 +109,17 @@ fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> (String, String) { + let selection = match rx.try_recv() { + Ok(AppEvent::CopySelection { text, label }) => (text.to_string(), label), + other => panic!("expected selected clipboard content, got {other:?}"), + }; + assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed)); + selection +} + #[tokio::test] async fn service_tier_commands_lowercase_catalog_names() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; @@ -1717,8 +1728,9 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { #[tokio::test] async fn slash_copy_state_tracks_plan_item_completion() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + 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(); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { @@ -1739,6 +1751,334 @@ async fn slash_copy_state_tracks_plan_item_completion() { chat.pending_notification, Some(Notification::AgentTurnComplete { ref response }) if response == &plan_text ); + + let _ = drain_insert_history(&mut rx); + chat.dispatch_command(SlashCommand::Copy); + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(popup.contains("Whole response"), "picker: {popup}"); + assert!(popup.contains("sh code"), "picker: {popup}"); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!( + next_copy_selection(&mut rx), + (plan_text, "Whole response".to_string()) + ); + chat.dispatch_command(SlashCommand::Copy); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!( + next_copy_selection(&mut rx), + ("just test \r\n".to_string(), "sh code".to_string()) + ); + chat.transcript.plan_delta_buffer = " ```sh\n echo hi \n ```\n".to_string(); + chat.on_plan_item_completed(String::new()); + assert_eq!( + chat.last_agent_markdown_text(), + Some("```sh\n echo hi \n ```") + ); + let _ = drain_insert_history(&mut rx); + chat.dispatch_command(SlashCommand::Copy); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!( + next_copy_selection(&mut rx), + ("echo hi \n".to_string(), "sh code".to_string()) + ); +} + +#[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; + 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", + "> Keep **formatting** \r\n> > Nested quote\r\n> hidden ::git-stage{cwd=\"/repo\"}\r\n\r\n", + "> ::git-stage{cwd=\"/repo\"}\r\n" + ); + complete_turn_with_message(&mut chat, "turn-1", Some(source)); + while rx.try_recv().is_ok() {} + + for (key, expected, label) in [ + ( + '1', + "Intro\n\n```powershell\nWrite-Output value\nWrite-Output done\n```\n\n> Keep **formatting**\n> > Nested quote\n> hidden\n\n>", + "Whole response", + ), + ( + '2', + "Write-Output value \r\nWrite-Output done\t\r\n", + "powershell code", + ), + ( + '3', + "Keep **formatting** \r\n> Nested quote\r\nhidden \r\n", + "Blockquote", + ), + ] { + chat.dispatch_command(SlashCommand::Copy); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 100) + .matches("Blockquote") + .count(), + 1 + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); + assert_eq!( + next_copy_selection(&mut rx), + (expected.to_string(), label.to_string()) + ); + } + + chat.transcript.reset_copy_history(); + assert_eq!( + ( + chat.transcript.last_agent_markdown.as_deref(), + chat.transcript.last_agent_source.as_deref() + ), + (None, None) + ); +} + +#[tokio::test] +async fn slash_copy_picker_defers_queued_input_until_selection_or_cancellation_settles() { + for queued in ["/new", "/clear", "follow-up prompt"] { + for close_key in [KeyCode::Enter, KeyCode::Esc] { + let (mut chat, mut rx, mut op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + 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); + + complete_turn_with_message(&mut chat, "active", Some("New response")); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Previous response")); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + while let Ok(event) = rx.try_recv() { + assert!(!matches!( + event, + AppEvent::NewSession { .. } | AppEvent::ClearUi { .. } + )); + } + + chat.handle_key_event(KeyEvent::new(close_key, KeyModifiers::NONE)); + if close_key == KeyCode::Enter { + assert_eq!( + next_copy_selection(&mut rx), + ( + "Previous response".to_string(), + "Whole response".to_string() + ) + ); + } else { + assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed)); + } + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); + chat.set_queue_autosend_suppressed(/*suppressed*/ false); + chat.maybe_send_next_queued_input(); + assert!(chat.input_queue.queued_user_messages.is_empty()); + match queued { + "/new" => assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::NewSession { .. })) + ), + "/clear" => assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::ClearUi { .. })) + ), + _ => { + assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text { text: queued.to_string(), text_elements: Vec::new() }]) + } + } + } + } +} + +#[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.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(), + ); + + chat.dispatch_command(SlashCommand::Copy); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + for expected in [ + "1. Whole response", + "Overview", + "2. python code", + "print('first')", + "3. Blockquote", + "Important quote", + "4. sh code", + "echo done", + ] { + assert!(popup.contains(expected), "expected {expected:?} in {popup}"); + } + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +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.transcript.last_agent_markdown = Some("Ready to copy".to_string()); + chat.bottom_pane + .set_composer_text("/cop".to_string(), Vec::new(), Vec::new()); + + let key = if select_from_autocomplete { + KeyCode::Tab + } else { + KeyCode::Char('y') + }; + chat.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE)); + if !select_from_autocomplete { + std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()); + chat.handle_paste_burst_tick(FrameRequester::test_dummy()); + } + + assert_eq!(chat.bottom_pane.composer_text().trim(), "/copy"); + assert!( + !render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"), + "preparing /copy must not open the picker" + ); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(popup.contains("Whole response"), "picker: {popup}"); + assert!(chat.bottom_pane.composer_text().is_empty()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + } +} + +#[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.transcript.last_agent_markdown = Some("Ready to copy".to_string()); + + chat.handle_paste("/copy".to_string()); + + assert_eq!(chat.bottom_pane.composer_text(), "/copy"); + assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response")); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Whole response")); + assert!(chat.bottom_pane.composer_text().is_empty()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[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; + let markdown = "Intro\n\n```rust\nfn main() {\n run();\n}\n```"; + chat.transcript.last_agent_markdown = Some(markdown.to_string()); + + chat.dispatch_command(SlashCommand::Copy); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!( + next_copy_selection(&mut rx), + (markdown.to_string(), "Whole response".to_string()) + ); + + chat.dispatch_command(SlashCommand::Copy); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!( + next_copy_selection(&mut rx), + ( + "fn main() {\n run();\n}\n".to_string(), + "rust code".to_string() + ) + ); +} + +#[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.transcript.last_agent_markdown = + Some("> **outer**\n> > _nested_\n> ```sh\n> nested()\n> ```\n".to_string()); + + chat.dispatch_command(SlashCommand::Copy); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!( + next_copy_selection(&mut rx), + ( + "**outer**\n> _nested_\n```sh\nnested()\n```\n".to_string(), + "Blockquote".to_string() + ) + ); + + chat.dispatch_command(SlashCommand::Copy); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)); + assert_eq!( + next_copy_selection(&mut rx), + ("nested()\n".to_string(), "sh code".to_string()) + ); +} + +#[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.transcript.last_agent_markdown = Some( + (1..=11) + .map(|index| format!("```sh\nexample_{index}()\n```\n")) + .collect(), + ); + + chat.dispatch_command(SlashCommand::Copy); + for _ in 0..11 { + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + assert!( + render_bottom_popup(&chat, /*width*/ 80).contains("example_11()"), + "later rows must remain reachable through scrolling" + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + next_copy_selection(&mut rx), + ("example_10()\n".to_string(), "sh code".to_string()) + ); +} + +#[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.transcript.last_agent_markdown = Some("Nothing copied".to_string()); + + chat.dispatch_command(SlashCommand::Copy); + assert!(!chat.bottom_pane.no_modal_or_popup_active()); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(chat.bottom_pane.no_modal_or_popup_active()); + assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed)); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[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.transcript.last_agent_markdown = Some("Safe local copy".to_string()); + chat.set_parent_owned_thread(); + chat.bottom_pane + .set_composer_text("/cop".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response")); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Whole response")); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + + assert_eq!( + next_copy_selection(&mut rx), + ("Safe local copy".to_string(), "Whole response".to_string()) + ); + assert!( + op_rx.try_recv().is_err(), + "copy must not submit an agent turn" + ); } #[tokio::test] @@ -1996,6 +2336,22 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { rendered.contains("Copy failed: blocked"), "expected failure message, got {rendered:?}" ); + + chat.copy_selection_with("print('ok')\n", "python code", |content| { + assert_eq!(content, "print('ok')\n"); + Ok(Some(crate::clipboard_copy::ClipboardLease::test())) + }); + assert!(chat.clipboard_lease.is_some()); + let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); + assert!(rendered.contains("Copied python code to clipboard")); + + chat.copy_selection_with("print('blocked')\n", "python code", |content| { + assert_eq!(content, "print('blocked')\n"); + Err("blocked selection".to_string()) + }); + assert!(chat.clipboard_lease.is_some()); + let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); + assert!(rendered.contains("Copy failed: blocked selection")); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/transcript.rs b/codex-rs/tui/src/chatwidget/transcript.rs index 02d53b0c40..2268800255 100644 --- a/codex-rs/tui/src/chatwidget/transcript.rs +++ b/codex-rs/tui/src/chatwidget/transcript.rs @@ -32,8 +32,10 @@ pub(super) struct TranscriptState { pub(super) active_cell_revision: u64, /// One bounded entry shared by layout and paint across unchanged active-cell frames. pub(super) active_cell_layout: Cell>, - /// Raw markdown of the most recently completed agent response. + /// Markdown of the most recently completed agent response for whole-response copying. pub(super) last_agent_markdown: Option, + /// Original source of that response, before display sanitization, for exact block copying. + pub(super) last_agent_source: Option, pub(super) last_completed_agent_message: Option<(String, String)>, /// Raw markdown of the most recently completed proposed plan. pub(super) latest_proposed_plan_markdown: Option, @@ -80,13 +82,15 @@ impl TranscriptState { active_cell } - pub(super) fn record_agent_markdown(&mut self, markdown: String) { + pub(super) fn record_agent_markdown(&mut self, markdown: String, source: String) { self.last_agent_markdown = Some(markdown); + self.last_agent_source = Some(source); self.saw_copy_source_this_turn = true; } pub(super) fn reset_copy_history(&mut self) { self.last_agent_markdown = None; + self.last_agent_source = None; self.saw_copy_source_this_turn = false; } diff --git a/codex-rs/tui/src/git_action_directives.rs b/codex-rs/tui/src/git_action_directives.rs index c54744c845..98747ca9b1 100644 --- a/codex-rs/tui/src/git_action_directives.rs +++ b/codex-rs/tui/src/git_action_directives.rs @@ -131,7 +131,7 @@ fn rewrite_code_comment_line(line: &str, cwd: &Path) -> Option { )) } -fn strip_line_directives(line: &str) -> (String, Vec) { +pub(crate) fn strip_line_directives(line: &str) -> (String, Vec) { let mut visible = String::new(); let mut actions = Vec::new(); let mut remaining = line; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index eb687457ca..6375a789b0 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -19,10 +19,15 @@ //! buffers the entire fence body before deciding, only unwraps fences whose //! info string is `md` or `markdown` AND whose body contains a //! header+delimiter pair, and degrades gracefully on unclosed fences. +use pulldown_cmark::CodeBlockKind; +use pulldown_cmark::Event; +use pulldown_cmark::Tag; +use pulldown_cmark::TagEnd; use ratatui::text::Line; use std::borrow::Cow; use std::ops::Range; use std::path::Path; +use std::sync::Arc; use crate::inline_visualization::InlineVisualizationContext; use crate::inline_visualization::rewrite_inline_visualizations; @@ -134,6 +139,93 @@ pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd( rendered } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CopyTarget { + Code { + language: Option, + content: Arc, + }, + Quote(Arc), +} + +pub(crate) fn extract_copy_targets(markdown_source: &str) -> Vec { + let mut targets = Vec::new(); + let mut quote: Option<(Range, usize, bool)> = None; + let mut quote_depth = 0usize; + let mut in_code_block = false; + let mut code = None; + + for (event, range) in pulldown_cmark::Parser::new(markdown_source).into_offset_iter() { + match event { + Event::Start(Tag::BlockQuote) => { + if quote_depth == 0 { + quote = Some((range, targets.len(), false)); + } + quote_depth += 1; + } + Event::End(TagEnd::BlockQuote) => { + quote_depth = quote_depth.saturating_sub(1); + if quote_depth == 0 { + quote = None; + } + } + Event::Start(Tag::CodeBlock(kind)) => { + in_code_block = true; + if let CodeBlockKind::Fenced(info) = kind { + let language = info + .split([',', ' ', '\t']) + .next() + .filter(|language| !language.is_empty()) + .map(str::to_owned); + code = Some((language, String::new())); + } + } + Event::Text(text) | Event::Code(text) => { + if in_code_block { + if let Some((_, content)) = &mut code { + if text.starts_with('\n') + && range.start > 0 + && markdown_source.as_bytes()[range.start - 1] == b'\r' + { + content.push('\r'); + } + content.push_str(&text); + } + } else if let Some((span, position, emitted)) = &mut quote + && !*emitted + && !crate::git_action_directives::strip_line_directives(&text) + .0 + .trim() + .is_empty() + { + let content = markdown_source[span.clone()] + .split_inclusive('\n') + .map(|line| { + line.trim_start_matches(' ') + .strip_prefix('>') + .map_or(line, |line| line.strip_prefix(' ').unwrap_or(line)) + }) + .collect::(); + targets.insert(*position, CopyTarget::Quote(content.into())); + *emitted = true; + } + } + Event::End(TagEnd::CodeBlock) => { + in_code_block = false; + if let Some((language, content)) = code.take() { + targets.push(CopyTarget::Code { + language, + content: content.into(), + }); + } + } + _ => {} + } + } + + targets +} + /// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare /// markdown so `pulldown-cmark` parses the tables natively. /// @@ -359,6 +451,68 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::text::Line; + #[test] + fn copy_targets_preserve_fenced_code_language_and_exact_content() { + let markdown = + "```python title=example\nprint('hi')\n```\n\n ignored()\n\n~~~\nplain()\n~~~"; + assert_eq!( + extract_copy_targets(markdown), + vec![ + CopyTarget::Code { + language: Some("python".to_string()), + content: Arc::from("print('hi')\n"), + }, + CopyTarget::Code { + language: None, + content: Arc::from("plain()\n"), + }, + ] + ); + assert_eq!( + extract_copy_targets("```powershell\r\nGet-Item .\r\nWrite-Output ok\r\n```\r\n"), + vec![CopyTarget::Code { + language: Some("powershell".to_string()), + content: Arc::from("Get-Item .\r\nWrite-Output ok\r\n"), + }] + ); + } + + #[test] + fn copy_targets_preserve_nested_quote_markdown_and_source_order() { + let markdown = "> outer **bold**\n> > inner *quote*\n> ```sh\n> nested()\n> ```\n"; + assert_eq!( + extract_copy_targets(markdown), + vec![ + CopyTarget::Quote(Arc::from( + "outer **bold**\n> inner *quote*\n```sh\nnested()\n```\n" + )), + CopyTarget::Code { + language: Some("sh".to_string()), + content: Arc::from("nested()\n"), + }, + ] + ); + } + + #[test] + fn copy_targets_exclude_blockquotes_without_prose() { + let markdown = concat!( + "> > ::git-stage{cwd=\"/repo\"}\n\n", + "> ::git-stage{cwd=\"/repo\"}\n> ```sh\n> code()\n> ```\n\n", + "> quoted prose\n" + ); + assert_eq!( + extract_copy_targets(markdown), + vec![ + CopyTarget::Code { + language: Some("sh".to_string()), + content: Arc::from("code()\n"), + }, + CopyTarget::Quote(Arc::from("quoted prose\n")), + ] + ); + } + fn lines_to_strings(lines: &[Line<'static>]) -> Vec { lines .iter() diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 1602aae797..8889cbddc2 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -120,8 +120,12 @@ pub(crate) fn maybe_init(config: &Config) { } pub(crate) fn log_inbound_app_event(event: &AppEvent) { + log_inbound_app_event_with(&LOGGER, event); +} + +fn log_inbound_app_event_with(logger: &SessionLogger, event: &AppEvent) { // Log only if enabled - if !LOGGER.is_enabled() { + if !logger.is_enabled() { return; } @@ -132,7 +136,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "dir": "to_tui", "kind": "new_session", }); - LOGGER.write_json_line(value); + logger.write_json_line(value); } AppEvent::ClearUi { .. } => { let value = json!({ @@ -140,7 +144,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "dir": "to_tui", "kind": "clear_ui", }); - LOGGER.write_json_line(value); + logger.write_json_line(value); } AppEvent::InsertHistoryCell(cell) => { let value = json!({ @@ -149,7 +153,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "kind": "insert_history_cell", "lines": cell.transcript_lines(u16::MAX).len(), }); - LOGGER.write_json_line(value); + logger.write_json_line(value); } AppEvent::StartFileSearch(query) => { let value = json!({ @@ -158,7 +162,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "kind": "file_search_start", "query": query, }); - LOGGER.write_json_line(value); + logger.write_json_line(value); } AppEvent::FileSearchResult { query, matches } => { let value = json!({ @@ -168,7 +172,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "query": query, "matches": matches.len(), }); - LOGGER.write_json_line(value); + logger.write_json_line(value); } AppEvent::PetPreviewLoaded { request_id, result } => { let value = json!({ @@ -179,7 +183,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "request_id": request_id, "ok": result.is_ok(), }); - LOGGER.write_json_line(value); + logger.write_json_line(value); } AppEvent::PetSelectionLoaded { request_id, @@ -195,7 +199,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "pet_id": pet_id, "ok": result.is_ok(), }); - LOGGER.write_json_line(value); + logger.write_json_line(value); } // Noise or control flow – record variant only other => { @@ -206,7 +210,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "kind": "app_event", "variant": variant, }); - LOGGER.write_json_line(value); + logger.write_json_line(value); } } } @@ -242,3 +246,7 @@ where }); LOGGER.write_json_line(value); } + +#[cfg(test)] +#[path = "session_log_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/session_log_tests.rs b/codex-rs/tui/src/session_log_tests.rs new file mode 100644 index 0000000000..fce6f9a1da --- /dev/null +++ b/codex-rs/tui/src/session_log_tests.rs @@ -0,0 +1,44 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::sync::Arc; + +#[test] +fn inbound_fallback_records_only_variant_names() { + let directory = tempfile::tempdir().expect("temporary log directory"); + let path = directory.path().join("session.jsonl"); + let logger = SessionLogger::new(); + logger.open(path.clone()).expect("open session log"); + for event in [ + AppEvent::CopySelection { + text: Arc::from("private plan without parentheses"), + label: "private label".to_string(), + }, + AppEvent::CopySelection { + text: Arc::from("private code(with parentheses)"), + label: "private label".to_string(), + }, + AppEvent::ConsolidateProposedPlan("private tuple payload".to_string()), + AppEvent::SettingsSelectionClosed, + ] { + log_inbound_app_event_with(&logger, &event); + } + let records = std::fs::read_to_string(path) + .expect("read session log") + .lines() + .map(|line| { + let mut record: serde_json::Value = serde_json::from_str(line).expect("valid log JSON"); + record.as_object_mut().expect("log object").remove("ts"); + record + }) + .collect::>(); + assert_eq!( + records, + [ + "CopySelection", + "CopySelection", + "ConsolidateProposedPlan", + "SettingsSelectionClosed" + ] + .map(|variant| json!({"dir": "to_tui", "kind": "app_event", "variant": variant})) + ); +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index cbfd820859..3c12b3e284 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -99,7 +99,7 @@ impl SlashCommand { SlashCommand::Fork => "fork the current chat", SlashCommand::App => "continue this session in the Desktop app", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", - SlashCommand::Copy => "copy last response as markdown", + SlashCommand::Copy => "copy the last response, code block, or quote", SlashCommand::Export => "export the conversation as markdown", SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection", SlashCommand::Diff => "show git diff (including untracked files)",