From 2801d12661bea3c7ff1a6a39c810348222453a27 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 7 Aug 2026 02:40:17 +0000 Subject: [PATCH] Add Markdown conversation export to the TUI (#37358) ## What changed - Add `/export` with clipboard and file destinations, including a default filename prompt and support for an explicit path argument. - Export the complete conversation history as structured Markdown, preserving user and assistant messages, plans, reasoning, activity, image labels, file changes, and MCP tool details while honoring reasoning visibility. - Fall back to legacy history loading when pagination is unavailable, and use the visible transcript for ephemeral sessions. - Resolve relative and home-relative paths without overwriting an existing file, and report export or copy results in the conversation. ## Testing - Cover Markdown rendering, hidden review prompts, activity details, complete paginated and legacy history loading, destination prompts, status messages, path resolution, and overwrite protection. GitOrigin-RevId: c2815fc2430d7a555da75d7038cc1ac08157b82e --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 14 + ...pt_export__tests__markdown_transcript.snap | 31 ++ .../app/tests/session_lifecycle_requests.rs | 62 +++- codex-rs/tui/src/app/transcript_export.rs | 311 ++++++++++++++++++ .../tui/src/app/transcript_export_tests.rs | 198 +++++++++++ codex-rs/tui/src/app_event.rs | 14 + codex-rs/tui/src/app_server_session.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 7 +- .../tui/src/bottom_pane/slash_commands.rs | 1 + ...p__tests__command_popup_default_items.snap | 1 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 14 + ...ests__slash_export_completion_message.snap | 8 + ...ests__slash_export_destination_picker.snap | 11 + ...__tests__slash_export_filename_prompt.snap | 9 + .../src/chatwidget/tests/slash_commands.rs | 34 ++ .../tui/src/chatwidget/transcript_export.rs | 74 +++++ codex-rs/tui/src/slash_command.rs | 5 + 19 files changed, 795 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__markdown_transcript.snap create mode 100644 codex-rs/tui/src/app/transcript_export.rs create mode 100644 codex-rs/tui/src/app/transcript_export_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_completion_message.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_destination_picker.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_export_filename_prompt.snap create mode 100644 codex-rs/tui/src/chatwidget/transcript_export.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1b3e6f2305..8f4de9a945 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -230,6 +230,7 @@ mod thread_goal_actions; mod thread_routing; mod thread_session_state; mod thread_settings; +mod transcript_export; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 2d55735451..02f1ba13b2 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -61,6 +61,20 @@ impl App { tracing::warn!(%thread_id, error = %err, "failed to load older transcript history"); } } + AppEvent::OpenTranscriptExportFilePrompt => { + self.chat_widget.show_transcript_export_file_prompt(); + } + AppEvent::ExportTranscript { destination } => { + if let Err(error) = self.export_transcript(app_server, destination).await { + self.chat_widget + .add_error_message(format!("Export failed: {error}")); + } + if self.chat_widget.no_modal_or_popup_active() { + self.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ false); + self.chat_widget.maybe_send_next_queued_input(); + } + } 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/snapshots/codex_tui__app__transcript_export__tests__markdown_transcript.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__markdown_transcript.snap new file mode 100644 index 0000000000..d06cd9f173 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__markdown_transcript.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/app/transcript_export_tests.rs +expression: "render_markdown_transcript(&cells).expect(\"exported transcript\")" +--- +# Codex conversation + +## User + +Explain **the change** + +## Assistant + +```rust +let answer = 42; +``` + +## Plan + +completed plan + +## Activity + + $ cargo test + +## User + +[Image #1] + +## User + +[Image #1] describe this diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index c560f72f86..2c36f0d809 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app_event::TranscriptExportDestination; use app_test_support::create_fake_paginated_rollout; use app_test_support::create_fake_parented_rollout_with_source; use app_test_support::create_fake_rollout; @@ -579,11 +580,47 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> { app.enqueue_primary_thread_session(started.session, started.turns) .await?; app.transcript_cells = initial_cells; + while app_event_rx.try_recv().is_ok() {} + let initial_turn_requests = recorded_params(&requests, "thread/turns/list").len(); + let initial_item_requests = recorded_params(&requests, "thread/items/list").len(); + let export_path = codex_home.path().join("complete-export.md"); + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + app.chat_widget.insert_str("queued after export"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ExportTranscript { + destination: TranscriptExportDestination::File(export_path.clone()), + }, + ) + .await?; + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + let markdown = std::fs::read_to_string(export_path)?; + assert!( + (0..305) + .map(|index| format!("history output {index}")) + .eq(markdown.lines().filter(|line| line.starts_with("history"))) + ); + assert!( + recorded_params(&requests, "thread/turns/list")[initial_turn_requests..] + .iter() + .all(|params| params["itemsView"] == "notLoaded") + ); + assert!( + recorded_params(&requests, "thread/items/list")[initial_item_requests..] + .iter() + .all(|params| { + params["limit"] == crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT + }) + ); app.scrollback_has_older_history = app_server.has_older_history(thread_id); assert!(app.scrollback_has_older_history); while app_event_rx.try_recv().is_ok() {} let initial_page_requests = recorded_params(&requests, "thread/items/list").len(); - let mut tui = crate::tui::test_support::make_test_tui()?; app.open_transcript_overlay(&mut tui); app.handle_backtrack_overlay_event( @@ -635,6 +672,11 @@ async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Re let (app, _codex_home) = make_history_test_app().await?; let legacy_thread_id = create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "legacy history")?; + let paginated_thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Paginated, + "paginated history", + )?; let (mut app_server, requests, proxy) = start_recording_app_server_with_history( &app.config, HistoryCapabilities::LegacyOnly, @@ -671,6 +713,24 @@ async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Re assert!(recorded_params(&requests, "thread/turns/list").is_empty()); assert!(recorded_params(&requests, "thread/items/list").is_empty()); + let initial_read_count = recorded_params(&requests, "thread/read").len(); + let exported = crate::app::transcript_export::load_export_transcript( + &mut app_server, + paginated_thread_id, + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(app.config.codex_home.as_path()), + vec![Arc::new(PlainHistoryCell::new(vec!["visible".into()]))], + ) + .await + .map_err(|error| color_eyre::eyre::eyre!(error))?; + assert_eq!(exported[0].raw_lines()[0].to_string(), "visible"); + assert!( + recorded_params(&requests, "thread/read")[initial_read_count..] + .iter() + .any(|params| params["includeTurns"] == true) + ); + assert_eq!(recorded_params(&requests, "thread/turns/list").len(), 1); + app_server.shutdown().await?; proxy.await??; Ok(()) diff --git a/codex-rs/tui/src/app/transcript_export.rs b/codex-rs/tui/src/app/transcript_export.rs new file mode 100644 index 0000000000..81cf6e73bd --- /dev/null +++ b/codex-rs/tui/src/app/transcript_export.rs @@ -0,0 +1,311 @@ +//! Complete, Markdown-preserving conversation exports. + +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::Turn; +use codex_protocol::ThreadId; +use codex_protocol::models::local_image_label_text; + +use super::App; +use crate::app_event::TranscriptExportDestination; +use crate::app_server_session::AppServerSession; +use crate::app_server_session::HistoryHydrationScope; +use crate::app_server_session::is_history_pagination_unsupported; +use crate::history_cell::AgentMarkdownCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::ProposedPlanCell; +use crate::history_cell::ReasoningSummaryCell; +use crate::history_cell::SessionInfoCell; +use crate::history_cell::UserHistoryCell; +use crate::history_cell::raw_lines_from_source; +use crate::thread_transcript::RawReasoningVisibility; +use crate::thread_transcript::thread_items_to_transcript_cells; + +impl App { + pub(super) async fn export_transcript( + &mut self, + app_server: &mut AppServerSession, + destination: TranscriptExportDestination, + ) -> Result<(), String> { + let thread_id = self + .chat_widget + .thread_id() + .ok_or_else(|| "No active conversation to export.".to_string())?; + let visibility = if self.config.show_raw_agent_reasoning { + RawReasoningVisibility::Visible + } else { + RawReasoningVisibility::Hidden + }; + let codex_home = self.config.codex_home.to_path_buf(); + let cells = load_export_transcript( + app_server, + thread_id, + visibility, + Some(codex_home.as_path()), + self.transcript_cells.clone(), + ) + .await?; + let markdown = render_markdown_transcript(&cells)?; + match destination { + TranscriptExportDestination::Clipboard => { + self.chat_widget.copy_transcript_to_clipboard(&markdown); + } + TranscriptExportDestination::File(path) => { + let cwd = if self.app_server_target.uses_remote_workspace() { + self.launch_cwd.as_path() + } else { + self.chat_widget.config_ref().cwd.as_path() + }; + let path = write_transcript(cwd, &path, &markdown)?; + self.chat_widget.add_info_message( + format!("Saved conversation to {}", path.display()), + /*hint*/ None, + ); + } + } + Ok(()) + } +} + +pub(super) async fn load_export_transcript( + app_server: &mut AppServerSession, + thread_id: ThreadId, + visibility: RawReasoningVisibility, + codex_home: Option<&Path>, + visible_transcript: Vec>, +) -> Result>, String> { + let mut thread = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + .map_err(|error| format!("could not load conversation: {error}"))?; + if thread.ephemeral { + return Ok(visible_transcript); + } + if let Err(error) = app_server + .hydrate_initial_thread_history( + &mut thread, + /*turn_cursor*/ None, + /*item_cursor*/ None, + /*config*/ None, + HistoryHydrationScope::Complete, + ) + .await + { + if matches!( + error.downcast_ref::(), + Some(TypedRequestError::Server { source, .. }) + if is_history_pagination_unsupported(source) + ) { + match app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + { + Ok(legacy) if !legacy.turns.is_empty() => thread = legacy, + _ => return Ok(visible_transcript), + } + } else { + return Err(format!("could not load conversation history: {error}")); + } + } + let mut cells: Vec> = Vec::new(); + for item in visible_export_items(thread.turns) { + if let Some(cell) = export_activity_cell(&item) { + cells.push(Arc::new(cell)); + } else { + cells.extend(thread_items_to_transcript_cells( + Some(thread_id), + &thread.cwd, + [item], + visibility, + codex_home, + )); + } + } + Ok(cells) +} + +fn export_activity_cell(item: &ThreadItem) -> Option { + let lines = match item { + ThreadItem::FileChange { + changes, status, .. + } => { + let mut lines = + vec![format!("file changes: {status:?} · {} changes", changes.len()).into()]; + for change in changes { + lines.push(format!("{:?}: {}", change.kind, change.path).into()); + lines.extend(change.diff.lines().map(|line| line.to_string().into())); + } + lines + } + ThreadItem::McpToolCall { + server, + tool, + status, + arguments, + result, + error, + .. + } => { + let mut lines = + vec![format!("mcp tool: {server}/{tool}({arguments}) · {status:?}").into()]; + if let Some(result) = result { + for content in &result.content { + match serde_json::from_value::(content.clone()) { + Ok(rmcp::model::ContentBlock::Text(text)) => { + lines.extend(raw_lines_from_source(&text.text)); + } + Ok(rmcp::model::ContentBlock::Image(_)) => { + lines.push("".into()); + } + Ok(rmcp::model::ContentBlock::Audio(_)) => { + lines.push("