//! 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::legacy_core::config::Config; 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 cells = load_export_transcript( app_server, thread_id, visibility, Some(&self.config), 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, config: Option<&Config>, 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, config, )); } } 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("