mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
Merge 152f5140d0 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -40,6 +40,7 @@ owo-colors = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
@@ -59,4 +60,3 @@ assert_matches = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
predicates = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_CODEX_DMG_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/Codex.dmg";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct AppCommand {
|
||||
/// Workspace path to open in Codex Desktop.
|
||||
#[arg(value_name = "PATH", default_value = ".")]
|
||||
pub path: PathBuf,
|
||||
|
||||
/// Override the macOS DMG download URL (advanced).
|
||||
#[arg(long, default_value = DEFAULT_CODEX_DMG_URL)]
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn run_app(cmd: AppCommand) -> anyhow::Result<()> {
|
||||
let workspace = std::fs::canonicalize(&cmd.path).unwrap_or(cmd.path);
|
||||
crate::desktop_app::run_app_open_or_install(workspace, cmd.download_url).await
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use anyhow::Context as _;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::Builder;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub async fn run_mac_app_open_or_install(
|
||||
workspace: PathBuf,
|
||||
download_url: String,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(app_path) = find_existing_codex_app_path() {
|
||||
eprintln!(
|
||||
"Opening Codex Desktop at {app_path}...",
|
||||
app_path = app_path.display()
|
||||
);
|
||||
open_codex_app(&app_path, &workspace).await?;
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("Codex Desktop not found; downloading installer...");
|
||||
let installed_app = download_and_install_codex_to_user_applications(&download_url)
|
||||
.await
|
||||
.context("failed to download/install Codex Desktop")?;
|
||||
eprintln!(
|
||||
"Launching Codex Desktop from {installed_app}...",
|
||||
installed_app = installed_app.display()
|
||||
);
|
||||
open_codex_app(&installed_app, &workspace).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_existing_codex_app_path() -> Option<PathBuf> {
|
||||
candidate_codex_app_paths()
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.is_dir())
|
||||
}
|
||||
|
||||
fn candidate_codex_app_paths() -> Vec<PathBuf> {
|
||||
let mut paths = vec![PathBuf::from("/Applications/Codex.app")];
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
paths.push(PathBuf::from(home).join("Applications").join("Codex.app"));
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()> {
|
||||
eprintln!(
|
||||
"Opening workspace {workspace}...",
|
||||
workspace = workspace.display()
|
||||
);
|
||||
let status = Command::new("open")
|
||||
.arg("-a")
|
||||
.arg(app_path)
|
||||
.arg(workspace)
|
||||
.status()
|
||||
.await
|
||||
.context("failed to invoke `open`")?;
|
||||
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"`open -a {app_path} {workspace}` exited with {status}",
|
||||
app_path = app_path.display(),
|
||||
workspace = workspace.display()
|
||||
);
|
||||
}
|
||||
|
||||
async fn download_and_install_codex_to_user_applications(dmg_url: &str) -> anyhow::Result<PathBuf> {
|
||||
let temp_dir = Builder::new()
|
||||
.prefix("codex-app-installer-")
|
||||
.tempdir()
|
||||
.context("failed to create temp dir")?;
|
||||
let tmp_root = temp_dir.path().to_path_buf();
|
||||
let _temp_dir = temp_dir;
|
||||
|
||||
let dmg_path = tmp_root.join("Codex.dmg");
|
||||
download_dmg(dmg_url, &dmg_path).await?;
|
||||
|
||||
eprintln!("Mounting Codex Desktop installer...");
|
||||
let mount_point = mount_dmg(&dmg_path).await?;
|
||||
eprintln!(
|
||||
"Installer mounted at {mount_point}.",
|
||||
mount_point = mount_point.display()
|
||||
);
|
||||
let result = async {
|
||||
let app_in_volume = find_codex_app_in_mount(&mount_point)
|
||||
.context("failed to locate Codex.app in mounted dmg")?;
|
||||
install_codex_app_bundle(&app_in_volume).await
|
||||
}
|
||||
.await;
|
||||
|
||||
let detach_result = detach_dmg(&mount_point).await;
|
||||
if let Err(err) = detach_result {
|
||||
eprintln!(
|
||||
"warning: failed to detach dmg at {mount_point}: {err}",
|
||||
mount_point = mount_point.display()
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn install_codex_app_bundle(app_in_volume: &Path) -> anyhow::Result<PathBuf> {
|
||||
for applications_dir in candidate_applications_dirs()? {
|
||||
eprintln!(
|
||||
"Installing Codex Desktop into {applications_dir}...",
|
||||
applications_dir = applications_dir.display()
|
||||
);
|
||||
std::fs::create_dir_all(&applications_dir).with_context(|| {
|
||||
format!(
|
||||
"failed to create applications dir {applications_dir}",
|
||||
applications_dir = applications_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let dest_app = applications_dir.join("Codex.app");
|
||||
if dest_app.is_dir() {
|
||||
return Ok(dest_app);
|
||||
}
|
||||
|
||||
match copy_app_bundle(app_in_volume, &dest_app).await {
|
||||
Ok(()) => return Ok(dest_app),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"warning: failed to install Codex.app to {applications_dir}: {err}",
|
||||
applications_dir = applications_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("failed to install Codex.app to any applications directory");
|
||||
}
|
||||
|
||||
fn candidate_applications_dirs() -> anyhow::Result<Vec<PathBuf>> {
|
||||
let mut dirs = vec![PathBuf::from("/Applications")];
|
||||
dirs.push(user_applications_dir()?);
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
async fn download_dmg(url: &str, dest: &Path) -> anyhow::Result<()> {
|
||||
eprintln!("Downloading installer...");
|
||||
let status = Command::new("curl")
|
||||
.arg("-fL")
|
||||
.arg("--retry")
|
||||
.arg("3")
|
||||
.arg("--retry-delay")
|
||||
.arg("1")
|
||||
.arg("-o")
|
||||
.arg(dest)
|
||||
.arg(url)
|
||||
.status()
|
||||
.await
|
||||
.context("failed to invoke `curl`")?;
|
||||
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!("curl download failed with {status}");
|
||||
}
|
||||
|
||||
async fn mount_dmg(dmg_path: &Path) -> anyhow::Result<PathBuf> {
|
||||
let output = Command::new("hdiutil")
|
||||
.arg("attach")
|
||||
.arg("-nobrowse")
|
||||
.arg("-readonly")
|
||||
.arg(dmg_path)
|
||||
.output()
|
||||
.await
|
||||
.context("failed to invoke `hdiutil attach`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"`hdiutil attach` failed with {status}: {stderr}",
|
||||
status = output.status,
|
||||
stderr = String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_hdiutil_attach_mount_point(&stdout)
|
||||
.map(PathBuf::from)
|
||||
.with_context(|| format!("failed to parse mount point from hdiutil output:\n{stdout}"))
|
||||
}
|
||||
|
||||
async fn detach_dmg(mount_point: &Path) -> anyhow::Result<()> {
|
||||
let status = Command::new("hdiutil")
|
||||
.arg("detach")
|
||||
.arg(mount_point)
|
||||
.status()
|
||||
.await
|
||||
.context("failed to invoke `hdiutil detach`")?;
|
||||
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!("hdiutil detach failed with {status}");
|
||||
}
|
||||
|
||||
fn find_codex_app_in_mount(mount_point: &Path) -> anyhow::Result<PathBuf> {
|
||||
let direct = mount_point.join("Codex.app");
|
||||
if direct.is_dir() {
|
||||
return Ok(direct);
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(mount_point).with_context(|| {
|
||||
format!(
|
||||
"failed to read {mount_point}",
|
||||
mount_point = mount_point.display()
|
||||
)
|
||||
})? {
|
||||
let entry = entry.context("failed to read mount directory entry")?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "app") && path.is_dir() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"no .app bundle found at {mount_point}",
|
||||
mount_point = mount_point.display()
|
||||
);
|
||||
}
|
||||
|
||||
async fn copy_app_bundle(src_app: &Path, dest_app: &Path) -> anyhow::Result<()> {
|
||||
let status = Command::new("ditto")
|
||||
.arg(src_app)
|
||||
.arg(dest_app)
|
||||
.status()
|
||||
.await
|
||||
.context("failed to invoke `ditto`")?;
|
||||
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!("ditto copy failed with {status}");
|
||||
}
|
||||
|
||||
fn user_applications_dir() -> anyhow::Result<PathBuf> {
|
||||
let home = std::env::var_os("HOME").context("HOME is not set")?;
|
||||
Ok(PathBuf::from(home).join("Applications"))
|
||||
}
|
||||
|
||||
fn parse_hdiutil_attach_mount_point(output: &str) -> Option<String> {
|
||||
output.lines().find_map(|line| {
|
||||
if !line.contains("/Volumes/") {
|
||||
return None;
|
||||
}
|
||||
if let Some((_, mount)) = line.rsplit_once('\t') {
|
||||
return Some(mount.trim().to_string());
|
||||
}
|
||||
line.split_whitespace()
|
||||
.find(|field| field.starts_with("/Volumes/"))
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_hdiutil_attach_mount_point;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parses_mount_point_from_tab_separated_hdiutil_output() {
|
||||
let output = "/dev/disk2s1\tApple_HFS\tCodex\t/Volumes/Codex\n";
|
||||
assert_eq!(
|
||||
parse_hdiutil_attach_mount_point(output).as_deref(),
|
||||
Some("/Volumes/Codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_mount_point_with_spaces() {
|
||||
let output = "/dev/disk2s1\tApple_HFS\tCodex Installer\t/Volumes/Codex Installer\n";
|
||||
assert_eq!(
|
||||
parse_hdiutil_attach_mount_point(output).as_deref(),
|
||||
Some("/Volumes/Codex Installer")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
|
||||
/// Run the app install/open logic for the current OS.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn run_app_open_or_install(
|
||||
workspace: std::path::PathBuf,
|
||||
download_url: String,
|
||||
) -> anyhow::Result<()> {
|
||||
mac::run_mac_app_open_or_install(workspace, download_url).await
|
||||
}
|
||||
@@ -31,6 +31,10 @@ use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
use supports_color::Stream;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod app_cmd;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod desktop_app;
|
||||
mod mcp_cmd;
|
||||
#[cfg(not(windows))]
|
||||
mod wsl_paths;
|
||||
@@ -98,6 +102,10 @@ enum Subcommand {
|
||||
/// [experimental] Run the app server or related tooling.
|
||||
AppServer(AppServerCommand),
|
||||
|
||||
/// Launch the Codex desktop app (downloads the macOS installer if missing).
|
||||
#[cfg(target_os = "macos")]
|
||||
App(app_cmd::AppCommand),
|
||||
|
||||
/// Generate shell completion scripts.
|
||||
Completion(CompletionCommand),
|
||||
|
||||
@@ -564,6 +572,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
)?;
|
||||
}
|
||||
},
|
||||
#[cfg(target_os = "macos")]
|
||||
Some(Subcommand::App(app_cli)) => {
|
||||
app_cmd::run_app(app_cli).await?;
|
||||
}
|
||||
Some(Subcommand::Resume(ResumeCommand {
|
||||
session_id,
|
||||
last,
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView;
|
||||
use crate::bottom_pane::scroll_state::ScrollState;
|
||||
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
|
||||
use crate::bottom_pane::selection_popup_common::measure_rows_height;
|
||||
use crate::history_cell;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
use codex_core::protocol::Op;
|
||||
@@ -722,8 +723,17 @@ impl RequestUserInputOverlay {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CodexOp(Op::UserInputAnswer {
|
||||
id: self.request.turn_id.clone(),
|
||||
response: RequestUserInputResponse { answers },
|
||||
response: RequestUserInputResponse {
|
||||
answers: answers.clone(),
|
||||
},
|
||||
}));
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::RequestUserInputResultCell {
|
||||
questions: self.request.questions.clone(),
|
||||
answers,
|
||||
interrupted: false,
|
||||
},
|
||||
)));
|
||||
if let Some(next) = self.queue.pop_front() {
|
||||
self.request = next;
|
||||
self.reset_for_request();
|
||||
@@ -966,6 +976,8 @@ impl BottomPaneView for RequestUserInputOverlay {
|
||||
}
|
||||
|
||||
if matches!(key_event.code, KeyCode::Esc) {
|
||||
// TODO: Emit interrupted request_user_input results (including committed answers)
|
||||
// once core supports persisting them reliably without follow-up turn issues.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
self.done = true;
|
||||
return;
|
||||
@@ -1173,6 +1185,8 @@ impl BottomPaneView for RequestUserInputOverlay {
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if self.confirm_unanswered_active() {
|
||||
self.close_unanswered_confirmation();
|
||||
// TODO: Emit interrupted request_user_input results (including committed answers)
|
||||
// once core supports persisting them reliably without follow-up turn issues.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
self.done = true;
|
||||
return CancellationEvent::Handled;
|
||||
@@ -1182,6 +1196,8 @@ impl BottomPaneView for RequestUserInputOverlay {
|
||||
return CancellationEvent::Handled;
|
||||
}
|
||||
|
||||
// TODO: Emit interrupted request_user_input results (including committed answers)
|
||||
// once core supports persisting them reliably without follow-up turn issues.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
self.done = true;
|
||||
CancellationEvent::Handled
|
||||
@@ -1234,6 +1250,7 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -1245,6 +1262,18 @@ mod tests {
|
||||
(AppEventSender::new(tx_raw), rx)
|
||||
}
|
||||
|
||||
fn expect_interrupt_only(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) {
|
||||
let event = rx.try_recv().expect("expected interrupt AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"unexpected AppEvents before interrupt completion"
|
||||
);
|
||||
}
|
||||
|
||||
fn question_with_options(id: &str, header: &str) -> RequestUserInputQuestion {
|
||||
RequestUserInputQuestion {
|
||||
id: id.to_string(),
|
||||
@@ -1389,6 +1418,33 @@ mod tests {
|
||||
assert_eq!(overlay.request.turn_id, "turn-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_discards_queued_requests_and_emits_interrupt() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
request_event("turn-1", vec![question_with_options("q1", "First")]),
|
||||
tx,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
questions: vec![question_with_options("q2", "Second")],
|
||||
});
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-3".to_string(),
|
||||
turn_id: "turn-3".to_string(),
|
||||
questions: vec![question_with_options("q3", "Third")],
|
||||
});
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert!(overlay.done, "expected overlay to be done");
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_can_submit_empty_when_unanswered() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
@@ -1403,7 +1459,7 @@ mod tests {
|
||||
overlay.submit_answers();
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { id, response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
assert_eq!(id, "turn-1");
|
||||
@@ -1454,15 +1510,30 @@ mod tests {
|
||||
let first_answer = &overlay.answers[0];
|
||||
assert!(first_answer.answer_committed);
|
||||
assert_eq!(first_answer.options_state.selected_idx, Some(0));
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"unexpected AppEvent before full submission"
|
||||
);
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
assert_eq!(answer.answers, vec!["Option 1".to_string()]);
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert(
|
||||
"q1".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
answers: vec!["Option 1".to_string()],
|
||||
},
|
||||
);
|
||||
expected.insert(
|
||||
"q2".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
answers: vec!["Option 1".to_string()],
|
||||
},
|
||||
);
|
||||
assert_eq!(response.answers, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1630,6 +1701,10 @@ mod tests {
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert!(overlay.confirm_unanswered_active());
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"unexpected AppEvent before confirmation submit"
|
||||
);
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('1')));
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
@@ -1657,11 +1732,7 @@ mod tests {
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1678,11 +1749,7 @@ mod tests {
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1697,16 +1764,13 @@ mod tests {
|
||||
);
|
||||
let answer = overlay.current_answer_mut().expect("answer missing");
|
||||
answer.options_state.selected_idx = Some(0);
|
||||
answer.answer_committed = true;
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1721,17 +1785,42 @@ mod tests {
|
||||
);
|
||||
let answer = overlay.current_answer_mut().expect("answer missing");
|
||||
answer.options_state.selected_idx = Some(0);
|
||||
answer.answer_committed = true;
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_drops_committed_answers() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
request_event(
|
||||
"turn-1",
|
||||
vec![
|
||||
question_with_options("q1", "First"),
|
||||
question_without_options("q2", "Second"),
|
||||
],
|
||||
),
|
||||
tx,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"unexpected AppEvent before interruption"
|
||||
);
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1961,6 +2050,7 @@ mod tests {
|
||||
overlay.composer.move_cursor_to_end();
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert_eq!(overlay.answers[0].answer_committed, true);
|
||||
let _ = rx.try_recv();
|
||||
|
||||
overlay.move_question(false);
|
||||
overlay
|
||||
|
||||
@@ -1070,6 +1070,11 @@ impl ChatWidget {
|
||||
if !from_replay && self.queued_user_messages.is_empty() {
|
||||
self.maybe_prompt_plan_implementation();
|
||||
}
|
||||
// Keep this flag for replayed completion events so a subsequent live TurnComplete can
|
||||
// still show the prompt once after thread switch replay.
|
||||
if !from_replay {
|
||||
self.saw_plan_item_this_turn = false;
|
||||
}
|
||||
// If there is a queued user message, send exactly one now to begin the next turn.
|
||||
self.maybe_send_next_queued_input();
|
||||
// Emit a notification when the turn completes (suppressed if focused).
|
||||
|
||||
@@ -1260,6 +1260,61 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_complete() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
let plan_mask =
|
||||
collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
|
||||
chat.on_task_started();
|
||||
chat.on_plan_delta("- Step 1\n- Step 2\n".to_string());
|
||||
chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
last_agent_message: Some("Plan details".to_string()),
|
||||
})]);
|
||||
let replay_popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
!replay_popup.contains(PLAN_IMPLEMENTATION_TITLE),
|
||||
"expected no prompt for replayed turn completion, got {replay_popup:?}"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "live-turn-complete-1".to_string(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
last_agent_message: Some("Plan details".to_string()),
|
||||
}),
|
||||
});
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains(PLAN_IMPLEMENTATION_TITLE),
|
||||
"expected prompt for first live turn completion after replay, got {popup:?}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let dismissed_popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
!dismissed_popup.contains(PLAN_IMPLEMENTATION_TITLE),
|
||||
"expected prompt to dismiss on Esc, got {dismissed_popup:?}"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "live-turn-complete-2".to_string(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
last_agent_message: Some("Plan details".to_string()),
|
||||
}),
|
||||
});
|
||||
let duplicate_popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
!duplicate_popup.contains(PLAN_IMPLEMENTATION_TITLE),
|
||||
"expected no prompt for duplicate live completion, got {duplicate_popup:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_implementation_popup_skips_when_messages_queued() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
|
||||
@@ -54,6 +54,8 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use image::DynamicImage;
|
||||
use image::ImageReader;
|
||||
@@ -1774,6 +1776,151 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
/// Renders a completed (or interrupted) request_user_input exchange in history.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RequestUserInputResultCell {
|
||||
pub(crate) questions: Vec<RequestUserInputQuestion>,
|
||||
pub(crate) answers: HashMap<String, RequestUserInputAnswer>,
|
||||
pub(crate) interrupted: bool,
|
||||
}
|
||||
|
||||
impl HistoryCell for RequestUserInputResultCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let width = width.max(1) as usize;
|
||||
let total = self.questions.len();
|
||||
let answered = self
|
||||
.questions
|
||||
.iter()
|
||||
.filter(|question| {
|
||||
self.answers
|
||||
.get(&question.id)
|
||||
.is_some_and(|answer| !answer.answers.is_empty())
|
||||
})
|
||||
.count();
|
||||
let unanswered = total.saturating_sub(answered);
|
||||
|
||||
let mut header = vec!["•".dim(), " ".into(), "Questions".bold()];
|
||||
header.push(format!(" {answered}/{total} answered").dim());
|
||||
if self.interrupted {
|
||||
header.push(" (interrupted)".cyan());
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![header.into()];
|
||||
|
||||
for question in &self.questions {
|
||||
let answer = self.answers.get(&question.id);
|
||||
let answer_missing = match answer {
|
||||
Some(answer) => answer.answers.is_empty(),
|
||||
None => true,
|
||||
};
|
||||
let mut question_lines = wrap_with_prefix(
|
||||
&question.question,
|
||||
width,
|
||||
" • ".into(),
|
||||
" ".into(),
|
||||
Style::default(),
|
||||
);
|
||||
if answer_missing && let Some(last) = question_lines.last_mut() {
|
||||
last.spans.push(" (unanswered)".dim());
|
||||
}
|
||||
lines.extend(question_lines);
|
||||
|
||||
let Some(answer) = answer.filter(|answer| !answer.answers.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
if question.is_secret {
|
||||
lines.extend(wrap_with_prefix(
|
||||
"••••••",
|
||||
width,
|
||||
" answer: ".dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let (options, note) = split_request_user_input_answer(answer);
|
||||
|
||||
for option in options {
|
||||
lines.extend(wrap_with_prefix(
|
||||
&option,
|
||||
width,
|
||||
" answer: ".dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
}
|
||||
if let Some(note) = note {
|
||||
let (label, continuation, style) = if question.options.is_some() {
|
||||
(
|
||||
" note: ".dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
" answer: ".dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
)
|
||||
};
|
||||
lines.extend(wrap_with_prefix(¬e, width, label, continuation, style));
|
||||
}
|
||||
}
|
||||
|
||||
if self.interrupted && unanswered > 0 {
|
||||
let summary = format!("interrupted with {unanswered} unanswered");
|
||||
lines.extend(wrap_with_prefix(
|
||||
&summary,
|
||||
width,
|
||||
" ↳ ".cyan().dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
|
||||
));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a plain string with textwrap and prefix each line, while applying a style to the content.
|
||||
fn wrap_with_prefix(
|
||||
text: &str,
|
||||
width: usize,
|
||||
initial_prefix: Span<'static>,
|
||||
subsequent_prefix: Span<'static>,
|
||||
style: Style,
|
||||
) -> Vec<Line<'static>> {
|
||||
let prefix_width = initial_prefix
|
||||
.content
|
||||
.width()
|
||||
.max(subsequent_prefix.content.width());
|
||||
let wrap_width = width.saturating_sub(prefix_width).max(1);
|
||||
let wrapped = textwrap::wrap(text, wrap_width);
|
||||
let wrapped_lines = wrapped
|
||||
.into_iter()
|
||||
.map(|segment| Span::from(segment.to_string()).set_style(style).into())
|
||||
.collect::<Vec<Line<'static>>>();
|
||||
prefix_lines(wrapped_lines, initial_prefix, subsequent_prefix)
|
||||
}
|
||||
|
||||
/// Split a request_user_input answer into option labels and an optional freeform note.
|
||||
/// Notes are encoded as "user_note: <text>" entries in the answers list.
|
||||
fn split_request_user_input_answer(
|
||||
answer: &RequestUserInputAnswer,
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
let mut options = Vec::new();
|
||||
let mut note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note_text) = entry.strip_prefix("user_note: ") {
|
||||
note = Some(note_text.to_string());
|
||||
} else {
|
||||
options.push(entry.clone());
|
||||
}
|
||||
}
|
||||
(options, note)
|
||||
}
|
||||
|
||||
/// Render a user‑friendly plan update styled like a checkbox todo list.
|
||||
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
|
||||
let UpdatePlanArgs { explanation, plan } = update;
|
||||
|
||||
@@ -9,6 +9,7 @@ Use /status to see the current model, approvals, and token usage.
|
||||
Use /fork to branch the current chat into a new thread.
|
||||
Use /init to create an AGENTS.md with project-specific guidance.
|
||||
Use /mcp to list configured MCP tools.
|
||||
Run `codex app` to open Codex Desktop (it installs on macOS if needed).
|
||||
Use /personality to customize how Codex communicates.
|
||||
Use /rename to rename your threads for easier thread resuming.
|
||||
Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`.
|
||||
|
||||
Reference in New Issue
Block a user