optionally show conversation

This commit is contained in:
easong-openai
2025-09-05 18:11:23 -07:00
parent 21ef6be571
commit 36803606a0
9 changed files with 278 additions and 63 deletions
+32
View File
@@ -10,6 +10,8 @@ pub trait CodeTaskDetailsResponseExt {
fn unified_diff(&self) -> Option<String>;
/// Extract assistant text output messages (no diff) from current turns.
fn assistant_text_messages(&self) -> Vec<String>;
/// Extract the user's prompt text from the current user turn, when present.
fn user_text_prompt(&self) -> Option<String>;
/// Extract an assistant error message (if the turn failed and provided one).
fn assistant_error_message(&self) -> Option<String>;
/// 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<String> {
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<String> = 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<String> {
let map = self.current_assistant_turn.as_ref()?;
let err = map.get("error")?.as_object()?;
+8
View File
@@ -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<String>,
pub messages: Vec<String>,
}
#[async_trait::async_trait]
pub trait CloudBackend: Send + Sync {
async fn list_tasks(&self, env: Option<&str>) -> Result<Vec<TaskSummary>>;
async fn get_task_diff(&self, id: TaskId) -> Result<String>;
/// Return assistant output messages (no diff) when available.
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>>;
/// Return the creating prompt and assistant messages (when available).
async fn get_task_text(&self, id: TaskId) -> Result<TaskText>;
async fn apply_task(&self, id: TaskId) -> Result<ApplyOutcome>;
async fn create_task(
&self,
+54
View File
@@ -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<TaskText> {
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::<serde_json::Value>(&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<ApplyOutcome> {
let id = _id.0;
// Fetch diff fresh and apply locally via git (unified diffs).
+1
View File
@@ -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;
+8
View File
@@ -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<TaskText> {
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<ApplyOutcome> {
Ok(ApplyOutcome {
applied: true,
+18
View File
@@ -123,6 +123,17 @@ pub struct DiffOverlay {
pub task_id: TaskId,
pub sd: ScrollableDiff,
pub can_apply: bool,
pub diff_lines: Vec<String>,
// Optional alternate view: conversation text (prompt + assistant messages)
pub text_lines: Vec<String>,
pub prompt: Option<String>,
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<String>,
prompt: Option<String>,
},
DetailsFailed {
id: TaskId,
@@ -221,6 +233,12 @@ mod tests {
) -> codex_cloud_tasks_client::Result<Vec<String>> {
Ok(vec![])
}
async fn get_task_text(
&self,
_id: TaskId,
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::TaskText> {
Ok(codex_cloud_tasks_client::TaskText { prompt: Some("Example prompt".to_string()), messages: Vec::new() })
}
async fn apply_task(
&self,
+97 -18
View File
@@ -552,20 +552,28 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> 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<String> = 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("<no output>".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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<String> {
.map(|s| s.to_string())
}
/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages.
fn conversation_lines(prompt: Option<String>, messages: &[String]) -> Vec<String> {
let mut out: Vec<String> = 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("<no output>".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<String> {
@@ -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.
+58 -45
View File
@@ -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<ratatui::text::Span> = 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<Line<'static>> = 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<ratatui::text::Span> = 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<Line<'static>> = 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()