From 36803606a0226a235324d29977a5f78d85f3824c Mon Sep 17 00:00:00 2001 From: easong-openai Date: Fri, 5 Sep 2025 18:11:23 -0700 Subject: [PATCH] optionally show conversation --- codex-rs/backend-client/src/types.rs | 32 ++++++ codex-rs/cloud-tasks-client/src/api.rs | 8 ++ codex-rs/cloud-tasks-client/src/http.rs | 54 +++++++++ codex-rs/cloud-tasks-client/src/lib.rs | 1 + codex-rs/cloud-tasks-client/src/mock.rs | 8 ++ codex-rs/cloud-tasks/src/app.rs | 18 +++ codex-rs/cloud-tasks/src/lib.rs | 115 +++++++++++++++++--- codex-rs/cloud-tasks/src/scrollable_diff.rs | 2 + codex-rs/cloud-tasks/src/ui.rs | 103 ++++++++++-------- 9 files changed, 278 insertions(+), 63 deletions(-) diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 60619994d1..3f2c9445a0 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -10,6 +10,8 @@ pub trait CodeTaskDetailsResponseExt { fn unified_diff(&self) -> Option; /// Extract assistant text output messages (no diff) from current turns. fn assistant_text_messages(&self) -> Vec; + /// Extract the user's prompt text from the current user turn, when present. + fn user_text_prompt(&self) -> Option; /// Extract an assistant error message (if the turn failed and provided one). fn assistant_error_message(&self) -> Option; /// Best-effort: extract a single file old/new path for header synthesis when only hunk bodies are provided. @@ -79,6 +81,36 @@ impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse { out } + fn user_text_prompt(&self) -> Option { + use serde_json::Value; + let map = self.current_user_turn.as_ref()?; + let items = map.get("input_items").and_then(Value::as_array)?; + let mut parts: Vec = Vec::new(); + for item in items { + if item.get("type").and_then(Value::as_str) == Some("message") { + // optional role filter (prefer user) + let is_user = item + .get("role") + .and_then(Value::as_str) + .map(|r| r.eq_ignore_ascii_case("user")) + .unwrap_or(true); + if !is_user { + continue; + } + if let Some(content) = item.get("content").and_then(Value::as_array) { + for c in content { + if c.get("content_type").and_then(Value::as_str) == Some("text") + && let Some(txt) = c.get("text").and_then(Value::as_str) + { + parts.push(txt.to_string()); + } + } + } + } + } + if parts.is_empty() { None } else { Some(parts.join("\n\n")) } + } + fn assistant_error_message(&self) -> Option { let map = self.current_assistant_turn.as_ref()?; let err = map.get("error")?.as_object()?; diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index ddab2ac8f5..c5496bb832 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -77,12 +77,20 @@ pub struct DiffSummary { pub lines_removed: usize, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TaskText { + pub prompt: Option, + pub messages: Vec, +} + #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { async fn list_tasks(&self, env: Option<&str>) -> Result>; async fn get_task_diff(&self, id: TaskId) -> Result; /// Return assistant output messages (no diff) when available. async fn get_task_messages(&self, id: TaskId) -> Result>; + /// Return the creating prompt and assistant messages (when available). + async fn get_task_text(&self, id: TaskId) -> Result; async fn apply_task(&self, id: TaskId) -> Result; async fn create_task( &self, diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 29e49442de..1d0b6e4b0b 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -1,4 +1,5 @@ use crate::ApplyOutcome; +use crate::api::TaskText; use crate::ApplyStatus; use crate::CloudBackend; use crate::DiffSummary; @@ -172,6 +173,59 @@ impl CloudBackend for HttpClient { ))) } + async fn get_task_text(&self, _id: TaskId) -> Result { + let id = _id.0; + let (details, body, _ct) = self + .backend + .get_task_details_with_body(&id) + .await + .map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?; + let prompt = details.user_text_prompt(); + let mut messages = details.assistant_text_messages(); + if messages.is_empty() { + if let Ok(full) = serde_json::from_str::(&body) { + if let Some(arr) = full + .get("current_assistant_turn") + .and_then(|v| v.get("worklog")) + .and_then(|v| v.get("messages")) + .and_then(|v| v.as_array()) + { + for m in arr { + let is_assistant = m + .get("author") + .and_then(|a| a.get("role")) + .and_then(|r| r.as_str()) + == Some("assistant"); + if !is_assistant { + continue; + } + if let Some(parts) = m + .get("content") + .and_then(|c| c.get("parts")) + .and_then(|p| p.as_array()) + { + for p in parts { + if let Some(s) = p.as_str() { + if !s.is_empty() { + messages.push(s.to_string()); + } + continue; + } + if let Some(obj) = p.as_object() + && obj.get("content_type").and_then(|t| t.as_str()) == Some("text") + && let Some(txt) = obj.get("text").and_then(|t| t.as_str()) + { + messages.push(txt.to_string()); + } + } + } + } + } + } + } + Ok(TaskText { prompt, messages }) + } + async fn apply_task(&self, _id: TaskId) -> Result { let id = _id.0; // Fetch diff fresh and apply locally via git (unified diffs). diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs index f19d493183..face547bca 100644 --- a/codex-rs/cloud-tasks-client/src/lib.rs +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -12,6 +12,7 @@ pub use api::Result; pub use api::TaskId; pub use api::TaskStatus; pub use api::TaskSummary; +pub use api::TaskText; #[cfg(feature = "mock")] mod mock; diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs index a7b3fd0f98..940c5325a9 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -1,4 +1,5 @@ use crate::ApplyOutcome; +use crate::api::TaskText; use crate::CloudBackend; use crate::DiffSummary; use crate::Result; @@ -65,6 +66,13 @@ impl CloudBackend for MockClient { ]) } + async fn get_task_text(&self, _id: TaskId) -> Result { + Ok(TaskText { + prompt: Some("Why is there no diff?".to_string()), + messages: vec!["Mock assistant output: this task contains no diff.".to_string()], + }) + } + async fn apply_task(&self, id: TaskId) -> Result { Ok(ApplyOutcome { applied: true, diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index 12e79927ea..147150179b 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -123,6 +123,17 @@ pub struct DiffOverlay { pub task_id: TaskId, pub sd: ScrollableDiff, pub can_apply: bool, + pub diff_lines: Vec, + // Optional alternate view: conversation text (prompt + assistant messages) + pub text_lines: Vec, + pub prompt: Option, + pub current_view: DetailView, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DetailView { + Diff, + Prompt, } /// Internal app events delivered from background tasks. @@ -147,6 +158,7 @@ pub enum AppEvent { id: TaskId, title: String, messages: Vec, + prompt: Option, }, DetailsFailed { id: TaskId, @@ -221,6 +233,12 @@ mod tests { ) -> codex_cloud_tasks_client::Result> { Ok(vec![]) } + async fn get_task_text( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result { + Ok(codex_cloud_tasks_client::TaskText { prompt: Some("Example prompt".to_string()), messages: Vec::new() }) + } async fn apply_task( &self, diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 8fa474b238..6631f0377e 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -552,20 +552,28 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a // Only update if the overlay still corresponds to this id. if let Some(ov) = &app.diff_overlay && ov.task_id != id { continue; } let mut sd = crate::scrollable_diff::ScrollableDiff::new(); - sd.set_content(diff.lines().map(|s| s.to_string()).collect()); - app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: true }); + let diff_lines: Vec = diff.lines().map(|s| s.to_string()).collect(); + sd.set_content(diff_lines.clone()); + app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: true, diff_lines, text_lines: Vec::new(), prompt: None, current_view: app::DetailView::Diff }); app.details_inflight = false; app.status.clear(); needs_redraw = true; } - app::AppEvent::DetailsMessagesLoaded { id, title, messages } => { + app::AppEvent::DetailsMessagesLoaded { id, title, messages, prompt } => { if let Some(ov) = &app.diff_overlay && ov.task_id != id { continue; } - let mut lines = Vec::new(); - for m in messages { lines.extend(m.lines().map(|s| s.to_string())); lines.push(String::new()); } - if lines.is_empty() { lines.push("".to_string()); } - let mut sd = crate::scrollable_diff::ScrollableDiff::new(); - sd.set_content(lines); - app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: false }); + let conv = conversation_lines(prompt.clone(), &messages); + if let Some(ov) = app.diff_overlay.as_mut() { + ov.text_lines = conv.clone(); + ov.prompt = prompt; + if !ov.can_apply { + ov.sd.set_content(conv); + ov.current_view = app::DetailView::Prompt; + } + } else { + let mut sd = crate::scrollable_diff::ScrollableDiff::new(); + sd.set_content(conv.clone()); + app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: false, diff_lines: Vec::new(), text_lines: conv, prompt, current_view: app::DetailView::Prompt }); + } app.details_inflight = false; app.status.clear(); needs_redraw = true; @@ -576,7 +584,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let pretty = pretty_lines_from_error(&error); let mut sd = crate::scrollable_diff::ScrollableDiff::new(); sd.set_content(pretty); - app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: false }); + app.diff_overlay = Some(app::DiffOverlay{ title, task_id: id, sd, can_apply: false, diff_lines: Vec::new(), text_lines: Vec::new(), prompt: None, current_view: app::DetailView::Prompt }); app.details_inflight = false; needs_redraw = true; } @@ -887,6 +895,34 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a }); } } + KeyCode::Left => { + if let Some(ov) = &mut app.diff_overlay { + let has_text = !ov.text_lines.is_empty() || ov.prompt.is_some(); + let has_diff = !ov.diff_lines.is_empty() || ov.can_apply; + if has_text && has_diff { + ov.current_view = app::DetailView::Prompt; + let lines = if ov.text_lines.is_empty() { conversation_lines(ov.prompt.clone(), &[]) } else { ov.text_lines.clone() }; + ov.sd.set_content(lines); + ov.sd.to_top(); + needs_redraw = true; + } + } + } + KeyCode::Right => { + if let Some(ov) = &mut app.diff_overlay { + let has_text = !ov.text_lines.is_empty() || ov.prompt.is_some(); + let has_diff = !ov.diff_lines.is_empty() || ov.can_apply; + if has_text && has_diff { + ov.current_view = app::DetailView::Diff; + let lines = ov.diff_lines.clone(); + if !lines.is_empty() { + ov.sd.set_content(lines); + } + ov.sd.to_top(); + needs_redraw = true; + } + } + } KeyCode::Esc | KeyCode::Char('q') => { app.diff_overlay = None; needs_redraw = true; @@ -1100,30 +1136,46 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a // Open empty overlay immediately; content arrives via events let mut sd = crate::scrollable_diff::ScrollableDiff::new(); sd.set_content(Vec::new()); - app.diff_overlay = Some(app::DiffOverlay{ title: task.title.clone(), task_id: task.id.clone(), sd, can_apply: false }); + app.diff_overlay = Some(app::DiffOverlay{ title: task.title.clone(), task_id: task.id.clone(), sd, can_apply: false, diff_lines: Vec::new(), text_lines: Vec::new(), prompt: None, current_view: app::DetailView::Prompt }); needs_redraw = true; // Spawn background details load (diff first, then messages fallback) let backend2 = backend.clone(); let tx2 = tx.clone(); + let id1 = task.id.clone(); + let title1 = task.title.clone(); + let id2 = id1.clone(); + let title2 = title1.clone(); tokio::spawn(async move { - match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend2, task.id.clone()).await { + match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend2, id1.clone()).await { Ok(diff) => { - let _ = tx2.send(app::AppEvent::DetailsDiffLoaded { id: task.id, title: task.title, diff }); + let _ = tx2.send(app::AppEvent::DetailsDiffLoaded { id: id1, title: title1, diff }); } Err(e) => { // Always log errors while we debug non-success states. - append_error_log(format!("get_task_diff failed for {}: {e}", task.id.0)); - match codex_cloud_tasks_client::CloudBackend::get_task_messages(&*backend2, task.id.clone()).await { - Ok(msgs) => { - let _ = tx2.send(app::AppEvent::DetailsMessagesLoaded { id: task.id, title: task.title, messages: msgs }); + append_error_log(format!("get_task_diff failed for {}: {e}", id1.0)); + match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend2, id1.clone()).await { + Ok(text) => { + let _ = tx2.send(app::AppEvent::DetailsMessagesLoaded { id: id1, title: title1, messages: text.messages, prompt: text.prompt }); } Err(e2) => { - let _ = tx2.send(app::AppEvent::DetailsFailed { id: task.id, title: task.title, error: format!("{e2}") }); + let _ = tx2.send(app::AppEvent::DetailsFailed { id: id1, title: title1, error: format!("{e2}") }); } } } } }); + // Also fetch conversation text even when diff exists + { + let backend3 = backend.clone(); + let tx3 = tx.clone(); + let id3 = id2; + let title3 = title2; + tokio::spawn(async move { + if let Ok(text) = codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend3, id3.clone()).await { + let _ = tx3.send(app::AppEvent::DetailsMessagesLoaded { id: id3, title: title3, messages: text.messages, prompt: text.prompt }); + } + }); + } // Animate spinner while details load. let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); } @@ -1214,6 +1266,33 @@ fn extract_chatgpt_account_id(token: &str) -> Option { .map(|s| s.to_string()) } +/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages. +fn conversation_lines(prompt: Option, messages: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + if let Some(p) = prompt { + out.push("user:".to_string()); + for l in p.lines() { + out.push(l.to_string()); + } + out.push(String::new()); + } + if !messages.is_empty() { + out.push("assistant:".to_string()); + for (i, m) in messages.iter().enumerate() { + for l in m.lines() { + out.push(l.to_string()); + } + if i + 1 < messages.len() { + out.push(String::new()); + } + } + } + if out.is_empty() { + out.push("".to_string()); + } + out +} + /// Convert a verbose HTTP error with embedded JSON body into concise, user-friendly lines /// for the details overlay. Falls back to a short raw message when parsing fails. fn pretty_lines_from_error(raw: &str) -> Vec { diff --git a/codex-rs/cloud-tasks/src/scrollable_diff.rs b/codex-rs/cloud-tasks/src/scrollable_diff.rs index 813675050b..c8a50a0437 100644 --- a/codex-rs/cloud-tasks/src/scrollable_diff.rs +++ b/codex-rs/cloud-tasks/src/scrollable_diff.rs @@ -42,6 +42,8 @@ impl ScrollableDiff { self.wrapped.clear(); self.wrapped_src_idx.clear(); self.state.content_h = 0; + // Force rewrap on next set_width even if width is unchanged + self.wrap_cols = None; } /// Set the wrap width. If changed, rebuild wrapped lines and clamp scroll. diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index 959b96b61a..70612f2407 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -14,6 +14,7 @@ use ratatui::widgets::ListItem; use ratatui::widgets::ListState; use ratatui::widgets::Padding; use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; use std::sync::OnceLock; use crate::app::App; @@ -282,73 +283,87 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) { } fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { - // Centered overlay rect (deduped geometry) and padded content via helpers. let inner = overlay_outer(area); - if app.diff_overlay.is_none() { return; } - let is_error = if let Some(overlay) = app.diff_overlay.as_ref() { - !overlay.can_apply - && overlay - .sd - .wrapped_lines() - .first() - .map(|s| s.trim_start().starts_with("Task failed:")) - .unwrap_or(false) - } else { - false - }; - let can_apply = app + let ov_can_apply = app.diff_overlay.as_ref().map(|o| o.can_apply).unwrap_or(false); + let is_error = app .diff_overlay .as_ref() - .map(|o| o.can_apply) - .unwrap_or(false); + .and_then(|o| o.sd.wrapped_lines().first().cloned()) + .map(|s| s.trim_start().starts_with("Task failed:")) + .unwrap_or(false) + && !ov_can_apply; let title = app .diff_overlay .as_ref() .map(|o| o.title.clone()) .unwrap_or_default(); - // Ensure ScrollableDiff knows geometry using padded content area. - let content_area = overlay_content(inner); - if let Some(ov) = app.diff_overlay.as_mut() { - ov.sd.set_width(content_area.width); - ov.sd.set_viewport(content_area.height); - } - // Optional percent scrolled label - let pct_opt = app - .diff_overlay - .as_ref() - .and_then(|o| o.sd.percent_scrolled()); + // Title block let mut title_spans: Vec = if is_error { - vec![ - "Details ".magenta(), - "[FAILED]".red().bold(), - " ".into(), - title.clone().magenta(), - ] - } else if can_apply { + vec!["Details ".magenta(), "[FAILED]".red().bold(), " ".into(), title.clone().magenta()] + } else if ov_can_apply { vec!["Diff: ".magenta(), title.clone().magenta()] } else { vec!["Details: ".magenta(), title.clone().magenta()] }; - if let Some(p) = pct_opt { + if let Some(p) = app + .diff_overlay + .as_ref() + .and_then(|o| o.sd.percent_scrolled()) + { title_spans.push(" • ".dim()); title_spans.push(format!("{p}%").dim()); } - let block = overlay_block().title(Line::from(title_spans)); frame.render_widget(Clear, inner); - frame.render_widget(block.clone(), inner); + frame.render_widget(overlay_block().title(Line::from(title_spans)).clone(), inner); - let styled_lines: Vec> = if can_apply { + // Content area and optional status bar + let content_full = overlay_content(inner); + let mut content_area = content_full; + if let Some(ov) = app.diff_overlay.as_mut() { + let has_text = !ov.text_lines.is_empty() || ov.prompt.is_some(); + let has_diff = !ov.diff_lines.is_empty() || ov_can_apply; + if has_diff || has_text { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(content_full); + // Status bar label + let mut spans: Vec = Vec::new(); + if has_diff && has_text { + let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) { "[Prompt]".magenta().bold() } else { "Prompt".dim() }; + let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) { "[Diff]".magenta().bold() } else { "Diff".dim() }; + spans.extend(vec![prompt_lbl, " ".into(), diff_lbl, " ".into(), "(← → to switch)".dim()]); + } else if has_text { + spans.push("Conversation".magenta().bold()); + } else { + spans.push("Diff".magenta().bold()); + } + frame.render_widget(Paragraph::new(Line::from(spans)), rows[0]); + ov.sd.set_width(rows[1].width); + ov.sd.set_viewport(rows[1].height); + content_area = rows[1]; + } else { + ov.sd.set_width(content_full.width); + ov.sd.set_viewport(content_full.height); + content_area = content_full; + } + } + + // Styled content render + // Choose styling by the active view, not just presence of a diff + let is_diff_view = app + .diff_overlay + .as_ref() + .map(|o| matches!(o.current_view, crate::app::DetailView::Diff)) + .unwrap_or(false); + let styled_lines: Vec> = if is_diff_view { let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); - raw.unwrap_or(&[]) - .iter() - .map(|l| style_diff_line(l)) - .collect() + raw.unwrap_or(&[]).iter().map(|l| style_diff_line(l)).collect() } else { - // Basic markdown styling for assistant messages let mut in_code = false; let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); raw.unwrap_or(&[]) @@ -379,10 +394,8 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { .map(|o| o.sd.wrapped_lines().is_empty()) .unwrap_or(true); if app.details_inflight && raw_empty { - // Show a centered spinner while loading details draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…"); } else { - // We pre-wrapped lines in ScrollableDiff; do not enable Paragraph-level wrapping here. let scroll = app .diff_overlay .as_ref()