support best of n

This commit is contained in:
easong-openai
2025-09-26 04:29:33 -07:00
parent e20e4edbab
commit 5fa64b7ae1
10 changed files with 992 additions and 295 deletions

View File

@@ -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",

View File

@@ -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);
});

View File

@@ -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);