mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
support best of n
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use crate::types::CodeTaskDetailsResponse;
|
||||
use crate::types::PaginatedListTaskListItem;
|
||||
use crate::types::TurnAttemptsSiblingTurnsResponse;
|
||||
use anyhow::Result;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
@@ -184,6 +185,26 @@ impl Client {
|
||||
Ok((parsed, body, ct))
|
||||
}
|
||||
|
||||
pub async fn list_sibling_turns(
|
||||
&self,
|
||||
task_id: &str,
|
||||
turn_id: &str,
|
||||
) -> Result<TurnAttemptsSiblingTurnsResponse> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!(
|
||||
"{}/api/codex/tasks/{}/turns/{}/sibling_turns",
|
||||
self.base_url, task_id, turn_id
|
||||
),
|
||||
PathStyle::ChatGptApi => format!(
|
||||
"{}/wham/tasks/{}/turns/{}/sibling_turns",
|
||||
self.base_url, task_id, turn_id
|
||||
),
|
||||
};
|
||||
let req = self.http.get(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request(req, "GET", &url).await?;
|
||||
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
|
||||
}
|
||||
|
||||
/// Create a new task (user turn) by POSTing to the appropriate backend path
|
||||
/// based on `path_style`. Returns the created task id.
|
||||
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
|
||||
|
||||
@@ -6,3 +6,4 @@ pub use types::CodeTaskDetailsResponse;
|
||||
pub use types::CodeTaskDetailsResponseExt;
|
||||
pub use types::PaginatedListTaskListItem;
|
||||
pub use types::TaskListItem;
|
||||
pub use types::TurnAttemptsSiblingTurnsResponse;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub use codex_backend_openapi_models::models::CodeTaskDetailsResponse;
|
||||
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
|
||||
pub use codex_backend_openapi_models::models::TaskListItem;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Extension helpers on generated types.
|
||||
@@ -132,3 +133,9 @@ impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse {
|
||||
|
||||
// Removed unused helpers `single_file_paths` and `extract_file_paths_list` to reduce
|
||||
// surface area; reintroduce as needed near call sites.
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TurnAttemptsSiblingTurnsResponse {
|
||||
#[serde(default)]
|
||||
pub sibling_turns: Vec<std::collections::HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
@@ -44,6 +44,35 @@ pub struct TaskSummary {
|
||||
/// True when the backend reports this task as a code review.
|
||||
#[serde(default)]
|
||||
pub is_review: bool,
|
||||
/// Number of assistant attempts (best-of-N), when reported by the backend.
|
||||
#[serde(default)]
|
||||
pub attempt_total: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AttemptStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for AttemptStatus {
|
||||
fn default() -> Self {
|
||||
AttemptStatus::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TurnAttempt {
|
||||
pub turn_id: String,
|
||||
pub attempt_placement: Option<i64>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub status: AttemptStatus,
|
||||
pub diff: Option<String>,
|
||||
pub messages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -77,10 +106,27 @@ pub struct DiffSummary {
|
||||
pub lines_removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TaskText {
|
||||
pub prompt: Option<String>,
|
||||
pub messages: Vec<String>,
|
||||
pub turn_id: Option<String>,
|
||||
pub sibling_turn_ids: Vec<String>,
|
||||
pub attempt_placement: Option<i64>,
|
||||
pub attempt_status: AttemptStatus,
|
||||
}
|
||||
|
||||
impl Default for TaskText {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: None,
|
||||
messages: Vec::new(),
|
||||
turn_id: None,
|
||||
sibling_turn_ids: Vec::new(),
|
||||
attempt_placement: None,
|
||||
attempt_status: AttemptStatus::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -91,10 +137,21 @@ pub trait CloudBackend: Send + Sync {
|
||||
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>;
|
||||
/// Return any sibling attempts (best-of-N) for the given assistant turn.
|
||||
async fn list_sibling_attempts(
|
||||
&self,
|
||||
task: TaskId,
|
||||
turn_id: String,
|
||||
) -> Result<Vec<TurnAttempt>>;
|
||||
/// Dry-run apply (preflight) that validates whether the patch would apply cleanly.
|
||||
/// Never modifies the working tree.
|
||||
async fn apply_task_preflight(&self, id: TaskId) -> Result<ApplyOutcome>;
|
||||
async fn apply_task(&self, id: TaskId) -> Result<ApplyOutcome>;
|
||||
/// Never modifies the working tree. When `diff_override` is supplied, the provided diff is
|
||||
/// used instead of re-fetching the task details so callers can apply alternate attempts.
|
||||
async fn apply_task_preflight(
|
||||
&self,
|
||||
id: TaskId,
|
||||
diff_override: Option<String>,
|
||||
) -> Result<ApplyOutcome>;
|
||||
async fn apply_task(&self, id: TaskId, diff_override: Option<String>) -> Result<ApplyOutcome>;
|
||||
async fn create_task(
|
||||
&self,
|
||||
env_id: &str,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::ApplyOutcome;
|
||||
use crate::ApplyStatus;
|
||||
use crate::AttemptStatus;
|
||||
use crate::CloudBackend;
|
||||
use crate::DiffSummary;
|
||||
use crate::Error;
|
||||
@@ -7,11 +8,13 @@ use crate::Result;
|
||||
use crate::TaskId;
|
||||
use crate::TaskStatus;
|
||||
use crate::TaskSummary;
|
||||
use crate::TurnAttempt;
|
||||
use crate::api::TaskText;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
|
||||
use serde_json::Value;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_backend_client as backend;
|
||||
@@ -143,233 +146,71 @@ impl CloudBackend for HttpClient {
|
||||
if messages.is_empty() {
|
||||
messages.extend(extract_assistant_messages_from_body(&body));
|
||||
}
|
||||
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).
|
||||
let details = self
|
||||
.backend
|
||||
.get_task_details(&id)
|
||||
.await
|
||||
.map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?;
|
||||
let diff = details
|
||||
.unified_diff()
|
||||
.ok_or_else(|| Error::Msg(format!("No diff available for task {id}")))?;
|
||||
// Enforce unified diff format only
|
||||
if !is_unified_diff(&diff) {
|
||||
let summary = summarize_patch_for_logging(&diff);
|
||||
append_error_log(&format!(
|
||||
"apply_error: id={id} format=non-unified; {summary}"
|
||||
));
|
||||
return Ok(ApplyOutcome {
|
||||
applied: false,
|
||||
status: ApplyStatus::Error,
|
||||
message: "Expected unified git diff; backend returned an incompatible format."
|
||||
.to_string(),
|
||||
skipped_paths: Vec::new(),
|
||||
conflict_paths: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Run the new Git apply engine
|
||||
let req = codex_git_apply::ApplyGitRequest {
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
|
||||
diff: diff.clone(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r = codex_git_apply::apply_git_patch(&req)
|
||||
.map_err(|e| Error::Io(format!("git apply failed to run: {e}")))?;
|
||||
|
||||
let status = if r.exit_code == 0 {
|
||||
ApplyStatus::Success
|
||||
} else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() {
|
||||
ApplyStatus::Partial
|
||||
} else {
|
||||
ApplyStatus::Error
|
||||
};
|
||||
let is_preflight = r.cmd_for_log.contains("--check");
|
||||
let applied = matches!(status, ApplyStatus::Success) && !is_preflight;
|
||||
let message = if is_preflight {
|
||||
match status {
|
||||
ApplyStatus::Success => format!("Preflight passed for task {id} (applies cleanly)"),
|
||||
ApplyStatus::Partial => format!(
|
||||
"Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
),
|
||||
ApplyStatus::Error => format!(
|
||||
"Preflight failed for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
),
|
||||
}
|
||||
} else {
|
||||
match status {
|
||||
ApplyStatus::Success => {
|
||||
format!(
|
||||
"Applied task {id} locally ({} files)",
|
||||
r.applied_paths.len()
|
||||
)
|
||||
}
|
||||
ApplyStatus::Partial => {
|
||||
format!(
|
||||
"Apply partially succeeded for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
)
|
||||
}
|
||||
ApplyStatus::Error => {
|
||||
format!(
|
||||
"Apply failed for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Log details on partial and error
|
||||
if matches!(status, ApplyStatus::Partial | ApplyStatus::Error)
|
||||
|| (is_preflight && !matches!(status, ApplyStatus::Success))
|
||||
{
|
||||
let mut log = String::new();
|
||||
let summary = summarize_patch_for_logging(&diff);
|
||||
use std::fmt::Write as _;
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"apply_result: id={} status={:?} applied={} skipped={} conflicts={} cmd={}",
|
||||
id,
|
||||
status,
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len(),
|
||||
r.cmd_for_log
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"stdout_tail=\n{}\nstderr_tail=\n{}",
|
||||
tail(&r.stdout, 2000),
|
||||
tail(&r.stderr, 2000)
|
||||
);
|
||||
let _ = writeln!(&mut log, "{summary}");
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"----- PATCH BEGIN -----\n{diff}\n----- PATCH END -----"
|
||||
);
|
||||
append_error_log(&log);
|
||||
}
|
||||
|
||||
Ok(ApplyOutcome {
|
||||
applied,
|
||||
status,
|
||||
message,
|
||||
skipped_paths: r.skipped_paths,
|
||||
conflict_paths: r.conflicted_paths,
|
||||
let turn_map = details.current_assistant_turn.as_ref();
|
||||
let turn_id = turn_map
|
||||
.and_then(|m| m.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
let sibling_turn_ids = turn_map
|
||||
.and_then(|m| m.get("sibling_turn_ids"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let attempt_placement = turn_map
|
||||
.and_then(|m| m.get("attempt_placement"))
|
||||
.and_then(Value::as_i64);
|
||||
let attempt_status = attempt_status_from_str(
|
||||
turn_map
|
||||
.and_then(|m| m.get("turn_status"))
|
||||
.and_then(Value::as_str),
|
||||
);
|
||||
Ok(TaskText {
|
||||
prompt,
|
||||
messages,
|
||||
turn_id,
|
||||
sibling_turn_ids,
|
||||
attempt_placement,
|
||||
attempt_status,
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_task_preflight(&self, _id: TaskId) -> Result<ApplyOutcome> {
|
||||
let id = _id.0;
|
||||
// Fetch diff fresh and apply locally via git (unified diffs).
|
||||
let details = self
|
||||
async fn list_sibling_attempts(
|
||||
&self,
|
||||
task: TaskId,
|
||||
turn_id: String,
|
||||
) -> Result<Vec<TurnAttempt>> {
|
||||
let resp = self
|
||||
.backend
|
||||
.get_task_details(&id)
|
||||
.list_sibling_turns(&task.0, &turn_id)
|
||||
.await
|
||||
.map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?;
|
||||
let diff = details
|
||||
.unified_diff()
|
||||
.ok_or_else(|| Error::Msg(format!("No diff available for task {id}")))?;
|
||||
// Enforce unified diff format only
|
||||
if !is_unified_diff(&diff) {
|
||||
let summary = summarize_patch_for_logging(&diff);
|
||||
append_error_log(&format!(
|
||||
"apply_error: id={id} format=non-unified; {summary}"
|
||||
));
|
||||
return Ok(ApplyOutcome {
|
||||
applied: false,
|
||||
status: ApplyStatus::Error,
|
||||
message: "Expected unified git diff; backend returned an incompatible format."
|
||||
.to_string(),
|
||||
skipped_paths: Vec::new(),
|
||||
conflict_paths: Vec::new(),
|
||||
});
|
||||
}
|
||||
.map_err(|e| Error::Http(format!("list_sibling_turns failed: {e}")))?;
|
||||
|
||||
// Preflight: run with --check (no changes)
|
||||
let req = codex_git_apply::ApplyGitRequest {
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
|
||||
diff: diff.clone(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
let r = codex_git_apply::apply_git_patch(&req)
|
||||
.map_err(|e| Error::Io(format!("git apply failed to run: {e}")))?;
|
||||
let mut attempts: Vec<TurnAttempt> = resp
|
||||
.sibling_turns
|
||||
.iter()
|
||||
.filter_map(turn_attempt_from_map)
|
||||
.collect();
|
||||
attempts.sort_by(compare_attempts);
|
||||
Ok(attempts)
|
||||
}
|
||||
|
||||
let status = if r.exit_code == 0 {
|
||||
ApplyStatus::Success
|
||||
} else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() {
|
||||
ApplyStatus::Partial
|
||||
} else {
|
||||
ApplyStatus::Error
|
||||
};
|
||||
let message = match status {
|
||||
ApplyStatus::Success => format!("Preflight passed for task {id} (applies cleanly)"),
|
||||
ApplyStatus::Partial => format!(
|
||||
"Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
),
|
||||
ApplyStatus::Error => format!(
|
||||
"Preflight failed for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
),
|
||||
};
|
||||
async fn apply_task(&self, _id: TaskId, diff_override: Option<String>) -> Result<ApplyOutcome> {
|
||||
let id = _id.0;
|
||||
self.apply_with_diff(id, diff_override, false).await
|
||||
}
|
||||
|
||||
if !matches!(status, ApplyStatus::Success) {
|
||||
let mut log = String::new();
|
||||
let summary = summarize_patch_for_logging(&diff);
|
||||
use std::fmt::Write as _;
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"apply_preflight_result: id={} status={:?} applied={} skipped={} conflicts={} cmd={}",
|
||||
id,
|
||||
status,
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len(),
|
||||
r.cmd_for_log
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"stdout_tail=\n{}\nstderr_tail=\n{}",
|
||||
tail(&r.stdout, 2000),
|
||||
tail(&r.stderr, 2000)
|
||||
);
|
||||
let _ = writeln!(&mut log, "{summary}");
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"----- PATCH BEGIN -----\n{diff}\n----- PATCH END -----"
|
||||
);
|
||||
append_error_log(&log);
|
||||
}
|
||||
|
||||
Ok(ApplyOutcome {
|
||||
applied: false,
|
||||
status,
|
||||
message,
|
||||
skipped_paths: r.skipped_paths,
|
||||
conflict_paths: r.conflicted_paths,
|
||||
})
|
||||
async fn apply_task_preflight(
|
||||
&self,
|
||||
_id: TaskId,
|
||||
diff_override: Option<String>,
|
||||
) -> Result<ApplyOutcome> {
|
||||
let id = _id.0;
|
||||
self.apply_with_diff(id, diff_override, true).await
|
||||
}
|
||||
|
||||
async fn create_task(
|
||||
@@ -430,6 +271,147 @@ impl CloudBackend for HttpClient {
|
||||
|
||||
/// Best-effort extraction of assistant text messages from a raw `get_task_details` body.
|
||||
/// Falls back to worklog messages when structured turns are not present.
|
||||
|
||||
impl HttpClient {
|
||||
async fn apply_with_diff(
|
||||
&self,
|
||||
id: String,
|
||||
diff_override: Option<String>,
|
||||
preflight: bool,
|
||||
) -> Result<ApplyOutcome> {
|
||||
let diff = match diff_override {
|
||||
Some(diff) => diff,
|
||||
None => {
|
||||
let details = self
|
||||
.backend
|
||||
.get_task_details(&id)
|
||||
.await
|
||||
.map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?;
|
||||
details
|
||||
.unified_diff()
|
||||
.ok_or_else(|| Error::Msg(format!("No diff available for task {id}")))?
|
||||
}
|
||||
};
|
||||
|
||||
if !is_unified_diff(&diff) {
|
||||
let summary = summarize_patch_for_logging(&diff);
|
||||
let mode = if preflight { "preflight" } else { "apply" };
|
||||
append_error_log(&format!(
|
||||
"apply_error: id={id} mode={mode} format=non-unified; {summary}"
|
||||
));
|
||||
return Ok(ApplyOutcome {
|
||||
applied: false,
|
||||
status: ApplyStatus::Error,
|
||||
message: "Expected unified git diff; backend returned an incompatible format."
|
||||
.to_string(),
|
||||
skipped_paths: Vec::new(),
|
||||
conflict_paths: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let req = codex_git_apply::ApplyGitRequest {
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
|
||||
diff: diff.clone(),
|
||||
revert: false,
|
||||
preflight,
|
||||
};
|
||||
let r = codex_git_apply::apply_git_patch(&req)
|
||||
.map_err(|e| Error::Io(format!("git apply failed to run: {e}")))?;
|
||||
|
||||
let status = if r.exit_code == 0 {
|
||||
ApplyStatus::Success
|
||||
} else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() {
|
||||
ApplyStatus::Partial
|
||||
} else {
|
||||
ApplyStatus::Error
|
||||
};
|
||||
let applied = matches!(status, ApplyStatus::Success) && !preflight;
|
||||
|
||||
let message = if preflight {
|
||||
match status {
|
||||
ApplyStatus::Success => format!("Preflight passed for task {id} (applies cleanly)"),
|
||||
ApplyStatus::Partial => format!(
|
||||
"Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
),
|
||||
ApplyStatus::Error => format!(
|
||||
"Preflight failed for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
),
|
||||
}
|
||||
} else {
|
||||
match status {
|
||||
ApplyStatus::Success => format!(
|
||||
"Applied task {id} locally ({} files)",
|
||||
r.applied_paths.len()
|
||||
),
|
||||
ApplyStatus::Partial => format!(
|
||||
"Apply partially succeeded for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
),
|
||||
ApplyStatus::Error => format!(
|
||||
"Apply failed for task {id} (applied={}, skipped={}, conflicts={})",
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len()
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(status, ApplyStatus::Partial | ApplyStatus::Error)
|
||||
|| (preflight && !matches!(status, ApplyStatus::Success))
|
||||
{
|
||||
let mut log = String::new();
|
||||
let summary = summarize_patch_for_logging(&diff);
|
||||
let mode = if preflight { "preflight" } else { "apply" };
|
||||
use std::fmt::Write as _;
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"apply_result: mode={} id={} status={:?} applied={} skipped={} conflicts={} cmd={}",
|
||||
mode,
|
||||
id,
|
||||
status,
|
||||
r.applied_paths.len(),
|
||||
r.skipped_paths.len(),
|
||||
r.conflicted_paths.len(),
|
||||
r.cmd_for_log
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"stdout_tail=
|
||||
{}
|
||||
stderr_tail=
|
||||
{}",
|
||||
tail(&r.stdout, 2000),
|
||||
tail(&r.stderr, 2000)
|
||||
);
|
||||
let _ = writeln!(&mut log, "{summary}");
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"----- PATCH BEGIN -----
|
||||
{}
|
||||
----- PATCH END -----",
|
||||
diff
|
||||
);
|
||||
append_error_log(&log);
|
||||
}
|
||||
|
||||
Ok(ApplyOutcome {
|
||||
applied,
|
||||
status,
|
||||
message,
|
||||
skipped_paths: r.skipped_paths,
|
||||
conflict_paths: r.conflicted_paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_assistant_messages_from_body(body: &str) -> Vec<String> {
|
||||
let mut msgs = Vec::new();
|
||||
if let Ok(full) = serde_json::from_str::<serde_json::Value>(body)
|
||||
@@ -473,6 +455,125 @@ fn extract_assistant_messages_from_body(body: &str) -> Vec<String> {
|
||||
msgs
|
||||
}
|
||||
|
||||
fn turn_attempt_from_map(turn: &HashMap<String, Value>) -> Option<TurnAttempt> {
|
||||
let turn_id = turn.get("id").and_then(Value::as_str)?.to_string();
|
||||
let attempt_placement = turn.get("attempt_placement").and_then(Value::as_i64);
|
||||
let created_at = parse_timestamp_value(turn.get("created_at"));
|
||||
let status = attempt_status_from_str(turn.get("turn_status").and_then(Value::as_str));
|
||||
let diff = extract_diff_from_turn(turn);
|
||||
let messages = extract_assistant_messages_from_turn(turn);
|
||||
Some(TurnAttempt {
|
||||
turn_id,
|
||||
attempt_placement,
|
||||
created_at,
|
||||
status,
|
||||
diff,
|
||||
messages,
|
||||
})
|
||||
}
|
||||
|
||||
fn compare_attempts(a: &TurnAttempt, b: &TurnAttempt) -> Ordering {
|
||||
match (a.attempt_placement, b.attempt_placement) {
|
||||
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => match (a.created_at, b.created_at) {
|
||||
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => a.turn_id.cmp(&b.turn_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_diff_from_turn(turn: &HashMap<String, Value>) -> Option<String> {
|
||||
let items = turn.get("output_items").and_then(Value::as_array)?;
|
||||
for item in items {
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
Some("output_diff") => {
|
||||
if let Some(diff) = item.get("diff").and_then(Value::as_str) {
|
||||
if !diff.is_empty() {
|
||||
return Some(diff.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("pr") => {
|
||||
if let Some(diff) = item
|
||||
.get("output_diff")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|od| od.get("diff"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
if !diff.is_empty() {
|
||||
return Some(diff.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_assistant_messages_from_turn(turn: &HashMap<String, Value>) -> Vec<String> {
|
||||
let mut msgs = Vec::new();
|
||||
if let Some(items) = turn.get("output_items").and_then(Value::as_array) {
|
||||
for item in items {
|
||||
if item.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
if let Some(content) = item.get("content").and_then(Value::as_array) {
|
||||
for part in content {
|
||||
if part.get("content_type").and_then(Value::as_str) == Some("text")
|
||||
&& let Some(txt) = part.get("text").and_then(Value::as_str)
|
||||
{
|
||||
if !txt.is_empty() {
|
||||
msgs.push(txt.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if msgs.is_empty() {
|
||||
if let Some(err) = turn.get("error").and_then(Value::as_object) {
|
||||
let message = err.get("message").and_then(Value::as_str).unwrap_or("");
|
||||
let code = err.get("code").and_then(Value::as_str).unwrap_or("");
|
||||
if !message.is_empty() || !code.is_empty() {
|
||||
let text = if !code.is_empty() && !message.is_empty() {
|
||||
format!("{code}: {message}")
|
||||
} else if !code.is_empty() {
|
||||
code.to_string()
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
msgs.push(format!("Task failed: {text}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
msgs
|
||||
}
|
||||
|
||||
fn parse_timestamp_value(v: Option<&Value>) -> Option<DateTime<Utc>> {
|
||||
let raw = v?.as_f64()?;
|
||||
let secs = raw as i64;
|
||||
let nanos = ((raw - secs as f64) * 1_000_000_000.0) as u32;
|
||||
Some(DateTime::<Utc>::from(
|
||||
std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos),
|
||||
))
|
||||
}
|
||||
|
||||
fn attempt_status_from_str(s: Option<&str>) -> AttemptStatus {
|
||||
match s.unwrap_or("") {
|
||||
"pending" => AttemptStatus::Pending,
|
||||
"in_progress" => AttemptStatus::InProgress,
|
||||
"completed" => AttemptStatus::Completed,
|
||||
"failed" => AttemptStatus::Failed,
|
||||
"cancelled" => AttemptStatus::Cancelled,
|
||||
_ => AttemptStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary {
|
||||
fn env_label_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<String> {
|
||||
let obj = v?;
|
||||
@@ -527,6 +628,15 @@ fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary {
|
||||
out
|
||||
}
|
||||
|
||||
fn attempt_total_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<usize> {
|
||||
let map = v?;
|
||||
let latest = map
|
||||
.get("latest_turn_status_display")
|
||||
.and_then(Value::as_object)?;
|
||||
let siblings = latest.get("sibling_turn_ids").and_then(Value::as_array)?;
|
||||
Some(siblings.len().saturating_add(1))
|
||||
}
|
||||
|
||||
TaskSummary {
|
||||
id: TaskId(src.id),
|
||||
title: src.title,
|
||||
@@ -539,6 +649,7 @@ fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary {
|
||||
.pull_requests
|
||||
.as_ref()
|
||||
.map_or(false, |prs| !prs.is_empty()),
|
||||
attempt_total: attempt_total_from_status_display(src.task_status_display.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod api;
|
||||
|
||||
pub use api::ApplyOutcome;
|
||||
pub use api::ApplyStatus;
|
||||
pub use api::AttemptStatus;
|
||||
pub use api::CloudBackend;
|
||||
pub use api::CreatedTask;
|
||||
pub use api::DiffSummary;
|
||||
@@ -13,6 +14,7 @@ pub use api::TaskId;
|
||||
pub use api::TaskStatus;
|
||||
pub use api::TaskSummary;
|
||||
pub use api::TaskText;
|
||||
pub use api::TurnAttempt;
|
||||
|
||||
#[cfg(feature = "mock")]
|
||||
mod mock;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::ApplyOutcome;
|
||||
use crate::AttemptStatus;
|
||||
use crate::CloudBackend;
|
||||
use crate::DiffSummary;
|
||||
use crate::Result;
|
||||
use crate::TaskId;
|
||||
use crate::TaskStatus;
|
||||
use crate::TaskSummary;
|
||||
use crate::TurnAttempt;
|
||||
use crate::api::TaskText;
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -52,6 +54,7 @@ impl CloudBackend for MockClient {
|
||||
lines_removed: d,
|
||||
},
|
||||
is_review: false,
|
||||
attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -71,10 +74,14 @@ impl CloudBackend for MockClient {
|
||||
Ok(TaskText {
|
||||
prompt: Some("Why is there no diff?".to_string()),
|
||||
messages: vec!["Mock assistant output: this task contains no diff.".to_string()],
|
||||
turn_id: Some("mock-turn".to_string()),
|
||||
sibling_turn_ids: Vec::new(),
|
||||
attempt_placement: Some(0),
|
||||
attempt_status: AttemptStatus::Completed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_task(&self, id: TaskId) -> Result<ApplyOutcome> {
|
||||
async fn apply_task(&self, id: TaskId, _diff_override: Option<String>) -> Result<ApplyOutcome> {
|
||||
Ok(ApplyOutcome {
|
||||
applied: true,
|
||||
status: crate::ApplyStatus::Success,
|
||||
@@ -84,7 +91,11 @@ impl CloudBackend for MockClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_task_preflight(&self, id: TaskId) -> Result<ApplyOutcome> {
|
||||
async fn apply_task_preflight(
|
||||
&self,
|
||||
id: TaskId,
|
||||
_diff_override: Option<String>,
|
||||
) -> Result<ApplyOutcome> {
|
||||
Ok(ApplyOutcome {
|
||||
applied: false,
|
||||
status: crate::ApplyStatus::Success,
|
||||
@@ -94,6 +105,24 @@ impl CloudBackend for MockClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_sibling_attempts(
|
||||
&self,
|
||||
task: TaskId,
|
||||
_turn_id: String,
|
||||
) -> Result<Vec<TurnAttempt>> {
|
||||
if task.0 == "T-1000" {
|
||||
return Ok(vec![TurnAttempt {
|
||||
turn_id: "T-1000-attempt-2".to_string(),
|
||||
attempt_placement: Some(1),
|
||||
created_at: Some(Utc::now()),
|
||||
status: AttemptStatus::Completed,
|
||||
diff: Some(mock_diff_for(&task)),
|
||||
messages: vec!["Mock alternate attempt".to_string()],
|
||||
}]);
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn create_task(
|
||||
&self,
|
||||
env_id: &str,
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct ApplyModalState {
|
||||
pub result_level: Option<ApplyResultLevel>,
|
||||
pub skipped_paths: Vec<String>,
|
||||
pub conflict_paths: Vec<String>,
|
||||
pub diff_override: Option<String>,
|
||||
}
|
||||
|
||||
use crate::scrollable_diff::ScrollableDiff;
|
||||
@@ -124,12 +125,155 @@ pub struct DiffOverlay {
|
||||
pub title: String,
|
||||
pub task_id: TaskId,
|
||||
pub sd: ScrollableDiff,
|
||||
pub can_apply: bool,
|
||||
pub base_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 attempts: Vec<AttemptView>,
|
||||
pub selected_attempt: usize,
|
||||
pub current_view: DetailView,
|
||||
pub base_turn_id: Option<String>,
|
||||
pub sibling_turn_ids: Vec<String>,
|
||||
pub attempt_total_hint: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AttemptView {
|
||||
pub turn_id: Option<String>,
|
||||
pub status: codex_cloud_tasks_client::AttemptStatus,
|
||||
pub attempt_placement: Option<i64>,
|
||||
pub diff_lines: Vec<String>,
|
||||
pub text_lines: Vec<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub diff_raw: Option<String>,
|
||||
}
|
||||
|
||||
impl AttemptView {
|
||||
pub fn has_diff(&self) -> bool {
|
||||
!self.diff_lines.is_empty()
|
||||
}
|
||||
|
||||
pub fn has_text(&self) -> bool {
|
||||
!self.text_lines.is_empty() || self.prompt.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl DiffOverlay {
|
||||
pub fn new(task_id: TaskId, title: String, attempt_total_hint: Option<usize>) -> Self {
|
||||
let mut sd = ScrollableDiff::new();
|
||||
sd.set_content(Vec::new());
|
||||
Self {
|
||||
title,
|
||||
task_id,
|
||||
sd,
|
||||
base_can_apply: false,
|
||||
diff_lines: Vec::new(),
|
||||
text_lines: Vec::new(),
|
||||
prompt: None,
|
||||
attempts: vec![AttemptView::default()],
|
||||
selected_attempt: 0,
|
||||
current_view: DetailView::Prompt,
|
||||
base_turn_id: None,
|
||||
sibling_turn_ids: Vec::new(),
|
||||
attempt_total_hint,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_attempt(&self) -> Option<&AttemptView> {
|
||||
self.attempts.get(self.selected_attempt)
|
||||
}
|
||||
|
||||
pub fn base_attempt_mut(&mut self) -> &mut AttemptView {
|
||||
if self.attempts.is_empty() {
|
||||
self.attempts.push(AttemptView::default());
|
||||
}
|
||||
self.attempts.get_mut(0).expect("base attempt present")
|
||||
}
|
||||
|
||||
pub fn set_view(&mut self, view: DetailView) {
|
||||
self.current_view = view;
|
||||
self.apply_selection_to_fields();
|
||||
}
|
||||
|
||||
pub fn expected_attempts(&self) -> Option<usize> {
|
||||
self.attempt_total_hint.or({
|
||||
if self.attempts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.attempts.len())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn attempt_count(&self) -> usize {
|
||||
self.attempts.len()
|
||||
}
|
||||
|
||||
pub fn attempt_display_total(&self) -> usize {
|
||||
self.expected_attempts()
|
||||
.unwrap_or_else(|| self.attempts.len().max(1))
|
||||
}
|
||||
|
||||
pub fn step_attempt(&mut self, delta: isize) -> bool {
|
||||
let total = self.attempts.len();
|
||||
if total <= 1 {
|
||||
return false;
|
||||
}
|
||||
let total_isize = total as isize;
|
||||
let current = self.selected_attempt as isize;
|
||||
let mut next = current + delta;
|
||||
next = ((next % total_isize) + total_isize) % total_isize;
|
||||
let next = next as usize;
|
||||
self.selected_attempt = next;
|
||||
self.apply_selection_to_fields();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn current_can_apply(&self) -> bool {
|
||||
matches!(self.current_view, DetailView::Diff)
|
||||
&& self
|
||||
.current_attempt()
|
||||
.and_then(|attempt| attempt.diff_raw.as_ref())
|
||||
.map(|diff| !diff.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn apply_selection_to_fields(&mut self) {
|
||||
let (diff_lines, text_lines, prompt) = if let Some(attempt) = self.current_attempt() {
|
||||
(
|
||||
attempt.diff_lines.clone(),
|
||||
attempt.text_lines.clone(),
|
||||
attempt.prompt.clone(),
|
||||
)
|
||||
} else {
|
||||
self.diff_lines.clear();
|
||||
self.text_lines.clear();
|
||||
self.prompt = None;
|
||||
self.sd.set_content(vec!["<loading attempt>".to_string()]);
|
||||
return;
|
||||
};
|
||||
|
||||
self.diff_lines = diff_lines.clone();
|
||||
self.text_lines = text_lines.clone();
|
||||
self.prompt = prompt;
|
||||
|
||||
match self.current_view {
|
||||
DetailView::Diff => {
|
||||
if diff_lines.is_empty() {
|
||||
self.sd.set_content(vec!["<no diff available>".to_string()]);
|
||||
} else {
|
||||
self.sd.set_content(diff_lines);
|
||||
}
|
||||
}
|
||||
DetailView::Prompt => {
|
||||
if text_lines.is_empty() {
|
||||
self.sd.set_content(vec!["<no output>".to_string()]);
|
||||
} else {
|
||||
self.sd.set_content(text_lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -161,12 +305,20 @@ pub enum AppEvent {
|
||||
title: String,
|
||||
messages: Vec<String>,
|
||||
prompt: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
sibling_turn_ids: Vec<String>,
|
||||
attempt_placement: Option<i64>,
|
||||
attempt_status: codex_cloud_tasks_client::AttemptStatus,
|
||||
},
|
||||
DetailsFailed {
|
||||
id: TaskId,
|
||||
title: String,
|
||||
error: String,
|
||||
},
|
||||
AttemptsLoaded {
|
||||
id: TaskId,
|
||||
attempts: Vec<codex_cloud_tasks_client::TurnAttempt>,
|
||||
},
|
||||
/// Background completion of new task submission
|
||||
NewTaskSubmitted(Result<codex_cloud_tasks_client::CreatedTask, String>),
|
||||
/// Background completion of apply preflight when opening modal or on demand
|
||||
@@ -219,6 +371,7 @@ mod tests {
|
||||
environment_label: None,
|
||||
summary: codex_cloud_tasks_client::DiffSummary::default(),
|
||||
is_review: false,
|
||||
attempt_total: Some(1),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -246,12 +399,25 @@ mod tests {
|
||||
Ok(codex_cloud_tasks_client::TaskText {
|
||||
prompt: Some("Example prompt".to_string()),
|
||||
messages: Vec::new(),
|
||||
turn_id: Some("fake-turn".to_string()),
|
||||
sibling_turn_ids: Vec::new(),
|
||||
attempt_placement: Some(0),
|
||||
attempt_status: codex_cloud_tasks_client::AttemptStatus::Completed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_sibling_attempts(
|
||||
&self,
|
||||
_task: TaskId,
|
||||
_turn_id: String,
|
||||
) -> codex_cloud_tasks_client::Result<Vec<codex_cloud_tasks_client::TurnAttempt>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn apply_task(
|
||||
&self,
|
||||
_id: TaskId,
|
||||
_diff_override: Option<String>,
|
||||
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::ApplyOutcome> {
|
||||
Err(codex_cloud_tasks_client::Error::Unimplemented(
|
||||
"not used in test",
|
||||
@@ -261,6 +427,7 @@ mod tests {
|
||||
async fn apply_task_preflight(
|
||||
&self,
|
||||
_id: TaskId,
|
||||
_diff_override: Option<String>,
|
||||
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::ApplyOutcome> {
|
||||
Err(codex_cloud_tasks_client::Error::Unimplemented(
|
||||
"not used in test",
|
||||
|
||||
@@ -434,42 +434,189 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
// on Err, silently continue with All
|
||||
}
|
||||
app::AppEvent::DetailsDiffLoaded { id, title, diff } => {
|
||||
// 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();
|
||||
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, prompt } => {
|
||||
if let Some(ov) = &app.diff_overlay && ov.task_id != id { continue; }
|
||||
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;
|
||||
if let Some(ov) = &app.diff_overlay
|
||||
&& ov.task_id != id {
|
||||
continue;
|
||||
}
|
||||
let diff_lines: Vec<String> = diff.lines().map(|s| s.to_string()).collect();
|
||||
if let Some(ov) = app.diff_overlay.as_mut() {
|
||||
ov.title = title;
|
||||
{
|
||||
let base = ov.base_attempt_mut();
|
||||
base.diff_lines = diff_lines.clone();
|
||||
base.diff_raw = Some(diff.clone());
|
||||
}
|
||||
ov.base_can_apply = true;
|
||||
ov.apply_selection_to_fields();
|
||||
} 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 });
|
||||
let mut overlay = app::DiffOverlay::new(id.clone(), title, None);
|
||||
{
|
||||
let base = overlay.base_attempt_mut();
|
||||
base.diff_lines = diff_lines.clone();
|
||||
base.diff_raw = Some(diff.clone());
|
||||
}
|
||||
overlay.base_can_apply = true;
|
||||
overlay.current_view = app::DetailView::Diff;
|
||||
overlay.apply_selection_to_fields();
|
||||
app.diff_overlay = Some(overlay);
|
||||
}
|
||||
app.details_inflight = false;
|
||||
app.status.clear();
|
||||
needs_redraw = true;
|
||||
}
|
||||
app::AppEvent::DetailsMessagesLoaded {
|
||||
id,
|
||||
title,
|
||||
messages,
|
||||
prompt,
|
||||
turn_id,
|
||||
sibling_turn_ids,
|
||||
attempt_placement,
|
||||
attempt_status,
|
||||
} => {
|
||||
if let Some(ov) = &app.diff_overlay
|
||||
&& ov.task_id != id {
|
||||
continue;
|
||||
}
|
||||
let conv = conversation_lines(prompt.clone(), &messages);
|
||||
if let Some(ov) = app.diff_overlay.as_mut() {
|
||||
ov.title = title.clone();
|
||||
{
|
||||
let base = ov.base_attempt_mut();
|
||||
base.text_lines = conv.clone();
|
||||
base.prompt = prompt.clone();
|
||||
base.turn_id = turn_id.clone();
|
||||
base.status = attempt_status;
|
||||
base.attempt_placement = attempt_placement;
|
||||
}
|
||||
ov.base_turn_id = turn_id.clone();
|
||||
ov.sibling_turn_ids = sibling_turn_ids.clone();
|
||||
ov.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1));
|
||||
if !ov.base_can_apply {
|
||||
ov.current_view = app::DetailView::Prompt;
|
||||
}
|
||||
ov.apply_selection_to_fields();
|
||||
if let (Some(turn_id), true) = (turn_id.clone(), !sibling_turn_ids.is_empty())
|
||||
&& ov.attempts.len() == 1 {
|
||||
let backend2 = backend.clone();
|
||||
let tx2 = tx.clone();
|
||||
let task_id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
match codex_cloud_tasks_client::CloudBackend::list_sibling_attempts(
|
||||
&*backend2,
|
||||
task_id.clone(),
|
||||
turn_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(attempts) => {
|
||||
let _ = tx2.send(app::AppEvent::AttemptsLoaded { id: task_id, attempts });
|
||||
}
|
||||
Err(e) => {
|
||||
crate::util::append_error_log(format!(
|
||||
"attempts.load failed for {}: {e}",
|
||||
task_id.0
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let mut overlay = app::DiffOverlay::new(id.clone(), title, None);
|
||||
{
|
||||
let base = overlay.base_attempt_mut();
|
||||
base.text_lines = conv.clone();
|
||||
base.prompt = prompt.clone();
|
||||
base.turn_id = turn_id.clone();
|
||||
base.status = attempt_status;
|
||||
base.attempt_placement = attempt_placement;
|
||||
}
|
||||
overlay.base_turn_id = turn_id.clone();
|
||||
overlay.sibling_turn_ids = sibling_turn_ids.clone();
|
||||
overlay.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1));
|
||||
overlay.current_view = app::DetailView::Prompt;
|
||||
overlay.apply_selection_to_fields();
|
||||
app.diff_overlay = Some(overlay);
|
||||
}
|
||||
app.details_inflight = false;
|
||||
app.status.clear();
|
||||
needs_redraw = true;
|
||||
}
|
||||
app::AppEvent::AttemptsLoaded { id, attempts } => {
|
||||
if let Some(ov) = app.diff_overlay.as_mut() {
|
||||
if ov.task_id != id {
|
||||
continue;
|
||||
}
|
||||
for attempt in attempts {
|
||||
if ov
|
||||
.attempts
|
||||
.iter()
|
||||
.any(|existing| existing.turn_id.as_deref() == Some(attempt.turn_id.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let diff_lines = attempt
|
||||
.diff
|
||||
.as_ref()
|
||||
.map(|d| d.lines().map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
let text_lines = conversation_lines(None, &attempt.messages);
|
||||
ov.attempts.push(app::AttemptView {
|
||||
turn_id: Some(attempt.turn_id.clone()),
|
||||
status: attempt.status,
|
||||
attempt_placement: attempt.attempt_placement,
|
||||
diff_lines,
|
||||
text_lines,
|
||||
prompt: None,
|
||||
diff_raw: attempt.diff.clone(),
|
||||
});
|
||||
}
|
||||
if ov.attempts.len() > 1 {
|
||||
let (_, rest) = ov.attempts.split_at_mut(1);
|
||||
rest.sort_by(|a, b| match (a.attempt_placement, b.attempt_placement) {
|
||||
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => a.turn_id.cmp(&b.turn_id),
|
||||
});
|
||||
}
|
||||
if ov.selected_attempt >= ov.attempts.len() {
|
||||
ov.selected_attempt = ov.attempts.len().saturating_sub(1);
|
||||
}
|
||||
ov.attempt_total_hint = Some(ov.attempts.len());
|
||||
ov.apply_selection_to_fields();
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
app::AppEvent::DetailsFailed { id, title, error } => {
|
||||
if let Some(ov) = &app.diff_overlay && ov.task_id != id { continue; }
|
||||
if let Some(ov) = &app.diff_overlay
|
||||
&& ov.task_id != id {
|
||||
continue;
|
||||
}
|
||||
append_error_log(format!("details failed for {}: {error}", id.0));
|
||||
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, diff_lines: Vec::new(), text_lines: Vec::new(), prompt: None, current_view: app::DetailView::Prompt });
|
||||
if let Some(ov) = app.diff_overlay.as_mut() {
|
||||
ov.title = title.clone();
|
||||
{
|
||||
let base = ov.base_attempt_mut();
|
||||
base.diff_lines.clear();
|
||||
base.text_lines = pretty.clone();
|
||||
base.prompt = None;
|
||||
}
|
||||
ov.base_can_apply = false;
|
||||
ov.current_view = app::DetailView::Prompt;
|
||||
ov.apply_selection_to_fields();
|
||||
} else {
|
||||
let mut overlay = app::DiffOverlay::new(id.clone(), title, None);
|
||||
{
|
||||
let base = overlay.base_attempt_mut();
|
||||
base.text_lines = pretty;
|
||||
}
|
||||
overlay.base_can_apply = false;
|
||||
overlay.current_view = app::DetailView::Prompt;
|
||||
overlay.apply_selection_to_fields();
|
||||
app.diff_overlay = Some(overlay);
|
||||
}
|
||||
app.details_inflight = false;
|
||||
needs_redraw = true;
|
||||
}
|
||||
@@ -636,8 +783,14 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
let backend2 = backend.clone();
|
||||
let tx2 = tx.clone();
|
||||
let id2 = m.task_id.clone();
|
||||
let diff_override = m.diff_override.clone();
|
||||
tokio::spawn(async move {
|
||||
let res = codex_cloud_tasks_client::CloudBackend::apply_task(&*backend2, id2.clone()).await;
|
||||
let res = codex_cloud_tasks_client::CloudBackend::apply_task(
|
||||
&*backend2,
|
||||
id2.clone(),
|
||||
diff_override,
|
||||
)
|
||||
.await;
|
||||
let evt = match res {
|
||||
Ok(outcome) => app::AppEvent::ApplyFinished { id: id2, result: Ok(outcome) },
|
||||
Err(e) => app::AppEvent::ApplyFinished { id: id2, result: Err(format!("{e}")) },
|
||||
@@ -650,15 +803,29 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
if let Some(m) = app.apply_modal.take() {
|
||||
// Kick off async preflight; show spinner in modal body
|
||||
app.apply_preflight_inflight = true;
|
||||
app.apply_modal = Some(app::ApplyModalState { task_id: m.task_id.clone(), title: m.title.clone(), result_message: None, result_level: None, skipped_paths: Vec::new(), conflict_paths: Vec::new() });
|
||||
app.apply_modal = Some(app::ApplyModalState {
|
||||
task_id: m.task_id.clone(),
|
||||
title: m.title.clone(),
|
||||
result_message: None,
|
||||
result_level: None,
|
||||
skipped_paths: Vec::new(),
|
||||
conflict_paths: Vec::new(),
|
||||
diff_override: m.diff_override.clone(),
|
||||
});
|
||||
needs_redraw = true;
|
||||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||||
let backend2 = backend.clone();
|
||||
let tx2 = tx.clone();
|
||||
let id2 = m.task_id.clone();
|
||||
let title2 = m.title.clone();
|
||||
let diff_override = m.diff_override.clone();
|
||||
tokio::spawn(async move {
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await;
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(
|
||||
&*backend2,
|
||||
id2.clone(),
|
||||
diff_override,
|
||||
)
|
||||
.await;
|
||||
let evt = match out {
|
||||
Ok(outcome) => {
|
||||
let level = match outcome.status {
|
||||
@@ -681,19 +848,50 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
_ => {}
|
||||
}
|
||||
} else if app.diff_overlay.is_some() {
|
||||
let mut cycle_attempt = |delta: isize| {
|
||||
if let Some(ov) = app.diff_overlay.as_mut()
|
||||
&& ov.attempt_count() > 1 {
|
||||
ov.step_attempt(delta);
|
||||
let total = ov.attempt_display_total();
|
||||
let current = ov.selected_attempt + 1;
|
||||
app.status = format!("Viewing attempt {current} of {total}");
|
||||
ov.sd.to_top();
|
||||
needs_redraw = true;
|
||||
}
|
||||
};
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('a') => {
|
||||
if let Some(ov) = &app.diff_overlay {
|
||||
if ov.can_apply {
|
||||
app.apply_modal = Some(app::ApplyModalState { task_id: ov.task_id.clone(), title: ov.title.clone(), result_message: None, result_level: None, skipped_paths: Vec::new(), conflict_paths: Vec::new() });
|
||||
let snapshot = app.diff_overlay.as_ref().map(|ov| {
|
||||
(
|
||||
ov.task_id.clone(),
|
||||
ov.title.clone(),
|
||||
ov.current_can_apply(),
|
||||
ov.current_attempt().and_then(|attempt| attempt.diff_raw.clone()),
|
||||
)
|
||||
});
|
||||
if let Some((task_id, title, can_apply, diff_override)) = snapshot {
|
||||
if can_apply {
|
||||
app.apply_modal = Some(app::ApplyModalState {
|
||||
task_id: task_id.clone(),
|
||||
title: title.clone(),
|
||||
result_message: None,
|
||||
result_level: None,
|
||||
skipped_paths: Vec::new(),
|
||||
conflict_paths: Vec::new(),
|
||||
diff_override: diff_override.clone(),
|
||||
});
|
||||
app.apply_preflight_inflight = true;
|
||||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||||
let backend2 = backend.clone();
|
||||
let tx2 = tx.clone();
|
||||
let id2 = ov.task_id.clone();
|
||||
let title2 = ov.title.clone();
|
||||
tokio::spawn(async move {
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await;
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(
|
||||
&*backend2,
|
||||
task_id.clone(),
|
||||
diff_override.clone(),
|
||||
)
|
||||
.await;
|
||||
let evt = match out {
|
||||
Ok(outcome) => {
|
||||
let level = match outcome.status {
|
||||
@@ -701,18 +899,38 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
codex_cloud_tasks_client::ApplyStatus::Partial => app::ApplyResultLevel::Partial,
|
||||
codex_cloud_tasks_client::ApplyStatus::Error => app::ApplyResultLevel::Error,
|
||||
};
|
||||
app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: outcome.message, level, skipped: outcome.skipped_paths, conflicts: outcome.conflict_paths }
|
||||
app::AppEvent::ApplyPreflightFinished {
|
||||
id: task_id,
|
||||
title,
|
||||
message: outcome.message,
|
||||
level,
|
||||
skipped: outcome.skipped_paths,
|
||||
conflicts: outcome.conflict_paths,
|
||||
}
|
||||
}
|
||||
Err(e) => app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: format!("Preflight failed: {e}"), level: app::ApplyResultLevel::Error, skipped: Vec::new(), conflicts: Vec::new() },
|
||||
Err(e) => app::AppEvent::ApplyPreflightFinished {
|
||||
id: task_id,
|
||||
title,
|
||||
message: format!("Preflight failed: {e}"),
|
||||
level: app::ApplyResultLevel::Error,
|
||||
skipped: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
},
|
||||
};
|
||||
let _ = tx2.send(evt);
|
||||
});
|
||||
} else {
|
||||
app.status = "No diff available to apply".to_string();
|
||||
app.status = "No diff available to apply.".to_string();
|
||||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
cycle_attempt(1);
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
cycle_attempt(-1);
|
||||
}
|
||||
// From task modal, 'o' should close it and open the env selector
|
||||
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||
app.diff_overlay = None;
|
||||
@@ -735,12 +953,10 @@ 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;
|
||||
let has_text = ov.current_attempt().is_some_and(app::AttemptView::has_text);
|
||||
let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_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.set_view(app::DetailView::Prompt);
|
||||
ov.sd.to_top();
|
||||
needs_redraw = true;
|
||||
}
|
||||
@@ -748,19 +964,21 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
}
|
||||
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;
|
||||
let has_text = ov.current_attempt().is_some_and(app::AttemptView::has_text);
|
||||
let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_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.set_view(app::DetailView::Diff);
|
||||
ov.sd.to_top();
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(']') | KeyCode::Char('}') => {
|
||||
cycle_attempt(1);
|
||||
}
|
||||
KeyCode::Char('[') | KeyCode::Char('{') => {
|
||||
cycle_attempt(-1);
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
app.diff_overlay = None;
|
||||
needs_redraw = true;
|
||||
@@ -932,9 +1150,12 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
app.status = format!("Loading details for {title}…", title = task.title);
|
||||
app.details_inflight = true;
|
||||
// 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, diff_lines: Vec::new(), text_lines: Vec::new(), prompt: None, current_view: app::DetailView::Prompt });
|
||||
let overlay = app::DiffOverlay::new(
|
||||
task.id.clone(),
|
||||
task.title.clone(),
|
||||
task.attempt_total,
|
||||
);
|
||||
app.diff_overlay = Some(overlay);
|
||||
needs_redraw = true;
|
||||
// Spawn background details load (diff first, then messages fallback)
|
||||
let backend2 = backend.clone();
|
||||
@@ -951,7 +1172,17 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
Ok(None) => {
|
||||
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 });
|
||||
let evt = app::AppEvent::DetailsMessagesLoaded {
|
||||
id: id1,
|
||||
title: title1,
|
||||
messages: text.messages,
|
||||
prompt: text.prompt,
|
||||
turn_id: text.turn_id,
|
||||
sibling_turn_ids: text.sibling_turn_ids,
|
||||
attempt_placement: text.attempt_placement,
|
||||
attempt_status: text.attempt_status,
|
||||
};
|
||||
let _ = tx2.send(evt);
|
||||
}
|
||||
Err(e2) => {
|
||||
let _ = tx2.send(app::AppEvent::DetailsFailed { id: id1, title: title1, error: format!("{e2}") });
|
||||
@@ -962,7 +1193,17 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
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 });
|
||||
let evt = app::AppEvent::DetailsMessagesLoaded {
|
||||
id: id1,
|
||||
title: title1,
|
||||
messages: text.messages,
|
||||
prompt: text.prompt,
|
||||
turn_id: text.turn_id,
|
||||
sibling_turn_ids: text.sibling_turn_ids,
|
||||
attempt_placement: text.attempt_placement,
|
||||
attempt_status: text.attempt_status,
|
||||
};
|
||||
let _ = tx2.send(evt);
|
||||
}
|
||||
Err(e2) => {
|
||||
let _ = tx2.send(app::AppEvent::DetailsFailed { id: id1, title: title1, error: format!("{e2}") });
|
||||
@@ -978,8 +1219,18 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
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 });
|
||||
if let Ok(text) = codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend3, id3.clone()).await {
|
||||
let evt = app::AppEvent::DetailsMessagesLoaded {
|
||||
id: id3,
|
||||
title: title3,
|
||||
messages: text.messages,
|
||||
prompt: text.prompt,
|
||||
turn_id: text.turn_id,
|
||||
sibling_turn_ids: text.sibling_turn_ids,
|
||||
attempt_placement: text.attempt_placement,
|
||||
attempt_status: text.attempt_status,
|
||||
};
|
||||
let _ = tx3.send(evt);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -988,10 +1239,19 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
}
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
if let Some(task) = app.tasks.get(app.selected) {
|
||||
if let Some(task) = app.tasks.get(app.selected).cloned() {
|
||||
match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task.id.clone()).await {
|
||||
Ok(Some(_)) => {
|
||||
app.apply_modal = Some(app::ApplyModalState { task_id: task.id.clone(), title: task.title.clone(), result_message: None, result_level: None, skipped_paths: Vec::new(), conflict_paths: Vec::new() });
|
||||
Ok(Some(diff)) => {
|
||||
let diff_override = Some(diff.clone());
|
||||
app.apply_modal = Some(app::ApplyModalState {
|
||||
task_id: task.id.clone(),
|
||||
title: task.title.clone(),
|
||||
result_message: None,
|
||||
result_level: None,
|
||||
skipped_paths: Vec::new(),
|
||||
conflict_paths: Vec::new(),
|
||||
diff_override: diff_override.clone(),
|
||||
});
|
||||
app.apply_preflight_inflight = true;
|
||||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||||
let backend2 = backend.clone();
|
||||
@@ -999,7 +1259,12 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
let id2 = task.id.clone();
|
||||
let title2 = task.title.clone();
|
||||
tokio::spawn(async move {
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await;
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(
|
||||
&*backend2,
|
||||
id2.clone(),
|
||||
diff_override,
|
||||
)
|
||||
.await;
|
||||
let evt = match out {
|
||||
Ok(outcome) => {
|
||||
let level = match outcome.status {
|
||||
@@ -1007,9 +1272,23 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
codex_cloud_tasks_client::ApplyStatus::Partial => app::ApplyResultLevel::Partial,
|
||||
codex_cloud_tasks_client::ApplyStatus::Error => app::ApplyResultLevel::Error,
|
||||
};
|
||||
app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: outcome.message, level, skipped: outcome.skipped_paths, conflicts: outcome.conflict_paths }
|
||||
app::AppEvent::ApplyPreflightFinished {
|
||||
id: id2,
|
||||
title: title2,
|
||||
message: outcome.message,
|
||||
level,
|
||||
skipped: outcome.skipped_paths,
|
||||
conflicts: outcome.conflict_paths,
|
||||
}
|
||||
}
|
||||
Err(e) => app::AppEvent::ApplyPreflightFinished { id: id2, title: title2, message: format!("Preflight failed: {e}"), level: app::ApplyResultLevel::Error, skipped: Vec::new(), conflicts: Vec::new() },
|
||||
Err(e) => app::AppEvent::ApplyPreflightFinished {
|
||||
id: id2,
|
||||
title: title2,
|
||||
message: format!("Preflight failed: {e}"),
|
||||
level: app::ApplyResultLevel::Error,
|
||||
skipped: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
},
|
||||
};
|
||||
let _ = tx2.send(evt);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ use ratatui::widgets::Paragraph;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::AttemptView;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_cloud_tasks_client::TaskStatus;
|
||||
@@ -224,13 +225,19 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
];
|
||||
// Apply hint; show disabled note when overlay is open without a diff.
|
||||
if let Some(ov) = app.diff_overlay.as_ref() {
|
||||
if !ov.can_apply {
|
||||
if !ov.current_can_apply() {
|
||||
help.push("a".dim());
|
||||
help.push(": Apply (disabled) ".dim());
|
||||
} else {
|
||||
help.push("a".dim());
|
||||
help.push(": Apply ".dim());
|
||||
}
|
||||
if ov.attempt_count() > 1 {
|
||||
help.push("Tab".dim());
|
||||
help.push(": Next attempt ".dim());
|
||||
help.push("[ ]".dim());
|
||||
help.push(": Cycle attempts ".dim());
|
||||
}
|
||||
} else {
|
||||
help.push("a".dim());
|
||||
help.push(": Apply ".dim());
|
||||
@@ -289,7 +296,7 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
let ov_can_apply = app
|
||||
.diff_overlay
|
||||
.as_ref()
|
||||
.map(|o| o.can_apply)
|
||||
.map(|o| o.current_can_apply())
|
||||
.unwrap_or(false);
|
||||
let is_error = app
|
||||
.diff_overlay
|
||||
@@ -335,8 +342,9 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
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;
|
||||
let has_text = ov.current_attempt().is_some_and(AttemptView::has_text);
|
||||
let has_diff =
|
||||
ov.current_attempt().is_some_and(AttemptView::has_diff) || ov.base_can_apply;
|
||||
if has_diff || has_text {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
@@ -360,13 +368,28 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
" ".into(),
|
||||
diff_lbl,
|
||||
" ".into(),
|
||||
"(← → to switch)".dim(),
|
||||
"(← → to switch view)".dim(),
|
||||
]);
|
||||
} else if has_text {
|
||||
spans.push("Conversation".magenta().bold());
|
||||
} else {
|
||||
spans.push("Diff".magenta().bold());
|
||||
}
|
||||
if let Some(total) = ov.expected_attempts().or({
|
||||
if ov.attempts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ov.attempts.len())
|
||||
}
|
||||
})
|
||||
&& total > 1 {
|
||||
spans.extend(vec![
|
||||
" ".into(),
|
||||
format!("Attempt {}/{}", ov.selected_attempt + 1, total).dim(),
|
||||
" ".into(),
|
||||
"(Tab/Shift-Tab or [ ] to cycle attempts)".dim(),
|
||||
]);
|
||||
}
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), rows[0]);
|
||||
ov.sd.set_width(rows[1].width);
|
||||
ov.sd.set_viewport(rows[1].height);
|
||||
|
||||
Reference in New Issue
Block a user