mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
optionally show conversation
This commit is contained in:
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user