mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Use [Image #1] format more consistently across Ctrl+V and drag/drop, put image above user message
This commit is contained in:
@@ -191,7 +191,7 @@ mod tests {
|
||||
#[test]
|
||||
fn skips_local_image_label_text() {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
let label = local_image_label_text(std::path::Path::new("/tmp/example.png"));
|
||||
let label = local_image_label_text(1);
|
||||
let user_text = "Please review this image.".to_string();
|
||||
|
||||
let item = ResponseItem::Message {
|
||||
|
||||
@@ -405,7 +405,8 @@ fn create_view_image_tool() -> ToolSpec {
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: VIEW_IMAGE_TOOL_NAME.to_string(),
|
||||
description: "View a local image from the filesystem.".to_string(),
|
||||
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
|
||||
@@ -182,23 +182,17 @@ fn local_image_error_placeholder(
|
||||
|
||||
pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
|
||||
|
||||
const LOCAL_IMAGE_OPEN_TAG_PREFIX: &str = "<image name=\"";
|
||||
const LOCAL_IMAGE_OPEN_TAG_SUFFIX: &str = "\">";
|
||||
const LOCAL_IMAGE_OPEN_TAG_PREFIX: &str = "<image name=";
|
||||
const LOCAL_IMAGE_OPEN_TAG_SUFFIX: &str = ">";
|
||||
const LOCAL_IMAGE_CLOSE_TAG: &str = "</image>";
|
||||
|
||||
fn local_image_label_text_with_number(
|
||||
path: &std::path::Path,
|
||||
label_number: Option<usize>,
|
||||
) -> String {
|
||||
let label = match label_number {
|
||||
Some(label_number) => format!("[Image #{label_number}]"),
|
||||
None => format!("[Image {}]", path.display()),
|
||||
};
|
||||
format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label}{LOCAL_IMAGE_OPEN_TAG_SUFFIX}")
|
||||
pub fn local_image_label_text(label_number: usize) -> String {
|
||||
format!("[Image #{label_number}]")
|
||||
}
|
||||
|
||||
pub fn local_image_label_text(path: &std::path::Path) -> String {
|
||||
local_image_label_text_with_number(path, None)
|
||||
fn local_image_open_tag_text(label_number: usize) -> String {
|
||||
let label = local_image_label_text(label_number);
|
||||
format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label}{LOCAL_IMAGE_OPEN_TAG_SUFFIX}")
|
||||
}
|
||||
|
||||
pub fn is_local_image_open_tag_text(text: &str) -> bool {
|
||||
@@ -234,20 +228,20 @@ fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> Co
|
||||
}
|
||||
|
||||
pub fn local_image_content_items(path: &std::path::Path, include_label: bool) -> Vec<ContentItem> {
|
||||
local_image_content_items_with_label_number(path, include_label, None)
|
||||
local_image_content_items_with_label_number(path, include_label, 1)
|
||||
}
|
||||
|
||||
fn local_image_content_items_with_label_number(
|
||||
path: &std::path::Path,
|
||||
include_label: bool,
|
||||
label_number: Option<usize>,
|
||||
label_number: usize,
|
||||
) -> Vec<ContentItem> {
|
||||
match load_and_resize_to_fit(path) {
|
||||
Ok(image) => {
|
||||
let mut items = Vec::with_capacity(3);
|
||||
if include_label {
|
||||
items.push(ContentItem::InputText {
|
||||
text: local_image_label_text_with_number(path, label_number),
|
||||
text: local_image_open_tag_text(label_number),
|
||||
});
|
||||
}
|
||||
items.push(ContentItem::InputImage {
|
||||
@@ -285,13 +279,6 @@ fn local_image_content_items_with_label_number(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default_clipboard_image_path(path: &std::path::Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.starts_with("codex-clipboard-"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
impl From<ResponseInputItem> for ResponseItem {
|
||||
fn from(item: ResponseInputItem) -> Self {
|
||||
match item {
|
||||
@@ -395,12 +382,7 @@ impl From<Vec<UserInput>> for ResponseInputItem {
|
||||
UserInput::Image { image_url } => vec![ContentItem::InputImage { image_url }],
|
||||
UserInput::LocalImage { path } => {
|
||||
image_index += 1;
|
||||
let label_number = if is_default_clipboard_image_path(&path) {
|
||||
Some(image_index)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
local_image_content_items_with_label_number(&path, true, label_number)
|
||||
local_image_content_items_with_label_number(&path, true, image_index)
|
||||
}
|
||||
UserInput::Skill { .. } => Vec::new(), // Skill bodies are injected later in core
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ use crate::style::user_message_style;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -59,7 +60,6 @@ use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -266,17 +266,15 @@ impl ChatComposer {
|
||||
|
||||
// normalize_pasted_path already handles Windows → WSL path conversion,
|
||||
// so we can directly try to read the image dimensions.
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((w, h)) => {
|
||||
tracing::info!("OK: {pasted}");
|
||||
let format_label = pasted_image_format(&path_buf).label();
|
||||
self.attach_image(path_buf, w, h, format_label);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::trace!("ERR: {err}");
|
||||
false
|
||||
}
|
||||
if image::image_dimensions(&path_buf).is_ok() {
|
||||
tracing::info!("OK: {pasted}");
|
||||
let format = pasted_image_format(&path_buf);
|
||||
tracing::debug!("attached image format={}", format.label());
|
||||
self.attach_image(path_buf);
|
||||
true
|
||||
} else {
|
||||
tracing::trace!("ERR: not an image path");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,19 +399,10 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
|
||||
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, _format_label: &str) {
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let image_number = self.attached_images.len() + 1;
|
||||
let placeholder = if Self::is_default_clipboard_filename(&path) {
|
||||
let base_placeholder = format!("Image #{image_number}");
|
||||
self.next_image_placeholder(&base_placeholder)
|
||||
} else {
|
||||
let file_label = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "image".to_string());
|
||||
let base_placeholder = format!("{file_label} {width}x{height}");
|
||||
self.next_image_placeholder(&base_placeholder)
|
||||
};
|
||||
let base_placeholder = local_image_label_text(image_number);
|
||||
let placeholder = self.next_image_placeholder(&base_placeholder);
|
||||
// Insert as an element to match large paste placeholder behavior:
|
||||
// styled distinctly and treated atomically for cursor/mutations.
|
||||
self.textarea.insert_element(&placeholder);
|
||||
@@ -481,9 +470,11 @@ impl ChatComposer {
|
||||
let mut suffix = 1;
|
||||
loop {
|
||||
let placeholder = if suffix == 1 {
|
||||
format!("[{base}]")
|
||||
base.to_string()
|
||||
} else if let Some(base_trimmed) = base.strip_suffix(']') {
|
||||
format!("{base_trimmed} #{suffix}]")
|
||||
} else {
|
||||
format!("[{base} #{suffix}]")
|
||||
format!("{base} #{suffix}")
|
||||
};
|
||||
if !text.contains(&placeholder) {
|
||||
return placeholder;
|
||||
@@ -492,13 +483,6 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default_clipboard_filename(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.starts_with("codex-clipboard-"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.textarea.insert_str(text);
|
||||
self.sync_popups();
|
||||
@@ -824,7 +808,7 @@ impl ChatComposer {
|
||||
if is_image {
|
||||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||||
let path_buf = PathBuf::from(&sel_path);
|
||||
if let Ok((w, h)) = image::image_dimensions(&path_buf) {
|
||||
if image::image_dimensions(&path_buf).is_ok() {
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
@@ -850,16 +834,7 @@ impl ChatComposer {
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
let format_label = match Path::new(&sel_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
{
|
||||
Some(ext) if ext == "png" => "PNG",
|
||||
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
|
||||
_ => "IMG",
|
||||
};
|
||||
self.attach_image(path_buf, w, h, format_label);
|
||||
self.attach_image(path_buf);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
} else {
|
||||
@@ -3511,12 +3486,12 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
composer.attach_image(path.clone(), 32, 16, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
composer.handle_paste(" hi".into());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[image1.png 32x16] hi"),
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3535,11 +3510,11 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image2.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[image2.png 10x5]"),
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3560,21 +3535,15 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image_dup.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
composer.handle_paste(" ".into());
|
||||
composer.attach_image(path, 10, 5, "PNG");
|
||||
composer.attach_image(path);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
assert!(text.contains("[image_dup.png 10x5]"));
|
||||
assert!(text.contains("[image_dup.png 10x5 #2]"));
|
||||
assert_eq!(
|
||||
composer.attached_images[0].placeholder,
|
||||
"[image_dup.png 10x5]"
|
||||
);
|
||||
assert_eq!(
|
||||
composer.attached_images[1].placeholder,
|
||||
"[image_dup.png 10x5 #2]"
|
||||
);
|
||||
assert!(text.contains("[Image #1]"));
|
||||
assert!(text.contains("[Image #2]"));
|
||||
assert_eq!(composer.attached_images[0].placeholder, "[Image #1]");
|
||||
assert_eq!(composer.attached_images[1].placeholder, "[Image #2]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3589,7 +3558,7 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image3.png");
|
||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 1: backspace at end
|
||||
@@ -3600,7 +3569,7 @@ mod tests {
|
||||
|
||||
// Re-add and test backspace in middle: should break the placeholder string
|
||||
// and drop the image mapping (same as text placeholder behavior).
|
||||
composer.attach_image(path, 20, 10, "PNG");
|
||||
composer.attach_image(path);
|
||||
let placeholder2 = composer.attached_images[0].placeholder.clone();
|
||||
// Move cursor to roughly middle of placeholder
|
||||
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
|
||||
@@ -3632,7 +3601,7 @@ mod tests {
|
||||
|
||||
// Insert an image placeholder at the start
|
||||
let path = PathBuf::from("/tmp/image_multibyte.png");
|
||||
composer.attach_image(path, 10, 5, "PNG");
|
||||
composer.attach_image(path);
|
||||
// Add multibyte text after the placeholder
|
||||
composer.textarea.insert_str("日本語");
|
||||
|
||||
@@ -3641,12 +3610,7 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
assert!(
|
||||
composer
|
||||
.textarea
|
||||
.text()
|
||||
.starts_with("[image_multibyte.png 10x5]")
|
||||
);
|
||||
assert!(composer.textarea.text().starts_with("[Image #1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3664,10 +3628,10 @@ mod tests {
|
||||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||||
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
||||
|
||||
composer.attach_image(path1, 10, 5, "PNG");
|
||||
composer.attach_image(path1);
|
||||
// separate placeholders with a space for clarity
|
||||
composer.handle_paste(" ".into());
|
||||
composer.attach_image(path2.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path2.clone());
|
||||
|
||||
let placeholder1 = composer.attached_images[0].placeholder.clone();
|
||||
let placeholder2 = composer.attached_images[1].placeholder.clone();
|
||||
@@ -3693,7 +3657,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
vec![AttachedImage {
|
||||
path: path2,
|
||||
placeholder: "[image_dup2.png 10x5]".to_string()
|
||||
placeholder: "[Image #2]".to_string()
|
||||
}],
|
||||
composer.attached_images,
|
||||
"one image mapping remains"
|
||||
@@ -3720,12 +3684,7 @@ mod tests {
|
||||
|
||||
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
||||
assert!(needs_redraw);
|
||||
assert!(
|
||||
composer
|
||||
.textarea
|
||||
.text()
|
||||
.starts_with("[codex_tui_test_paste_image.png 3x2] ")
|
||||
);
|
||||
assert!(composer.textarea.text().starts_with("[Image #1] "));
|
||||
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
assert_eq!(imgs, vec![tmp_path]);
|
||||
|
||||
@@ -542,16 +542,9 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
if self.view_stack.is_empty() {
|
||||
self.composer
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.composer.attach_image(path);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
+24
-317
@@ -49,11 +49,11 @@ use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::SkillsListEntry;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
use codex_core::protocol::TurnCompleteEvent;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::protocol::UndoCompletedEvent;
|
||||
use codex_core::protocol::UndoStartedEvent;
|
||||
@@ -85,9 +85,6 @@ use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::BetaFeatureItem;
|
||||
@@ -1599,7 +1596,7 @@ impl ChatWidget {
|
||||
info.height,
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path, info.width, info.height, info.encoded_format.label());
|
||||
self.attach_image(path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
@@ -1652,16 +1649,9 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
tracing::info!("attach_image path={path:?}");
|
||||
self.bottom_pane
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.bottom_pane.attach_image(path);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -1702,12 +1692,6 @@ impl ChatWidget {
|
||||
}
|
||||
match cmd {
|
||||
SlashCommand::Feedback => {
|
||||
if !self.config.feedback_enabled {
|
||||
let params = crate::bottom_pane::feedback_disabled_params();
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
// Step 1: pick a category (UI built in feedback_view)
|
||||
let params =
|
||||
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
|
||||
@@ -1745,45 +1729,6 @@ impl ChatWidget {
|
||||
SlashCommand::Approvals => {
|
||||
self.open_approvals_popup();
|
||||
}
|
||||
SlashCommand::ElevateSandbox => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox()
|
||||
.is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled();
|
||||
if !windows_degraded_sandbox_enabled
|
||||
|| !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
{
|
||||
// This command should not be visible/recognized outside degraded mode,
|
||||
// but guard anyway in case something dispatches it directly.
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(preset) = builtin_approval_presets()
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "auto")
|
||||
else {
|
||||
// Avoid panicking in interactive UI; treat this as a recoverable
|
||||
// internal error.
|
||||
self.add_error_message(
|
||||
"Internal error: missing the 'auto' approval preset.".to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = self.config.approval_policy.can_set(&preset.approval) {
|
||||
self.add_error_message(err.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Not supported; on non-Windows this command should never be reachable.
|
||||
};
|
||||
}
|
||||
SlashCommand::Experimental => {
|
||||
self.open_experimental_popup();
|
||||
}
|
||||
@@ -2111,8 +2056,8 @@ impl ChatWidget {
|
||||
self.on_agent_reasoning_final();
|
||||
}
|
||||
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
|
||||
EventMsg::TurnStarted(_) => self.on_task_started(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => {
|
||||
EventMsg::TaskStarted(_) => self.on_task_started(),
|
||||
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
|
||||
self.on_task_complete(last_agent_message)
|
||||
}
|
||||
EventMsg::TokenCount(ev) => {
|
||||
@@ -2888,25 +2833,10 @@ impl ChatWidget {
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let windows_degraded_sandbox_enabled = false;
|
||||
|
||||
let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& windows_degraded_sandbox_enabled
|
||||
&& presets.iter().any(|preset| preset.id == "auto");
|
||||
|
||||
for preset in presets.into_iter() {
|
||||
let is_current =
|
||||
Self::preset_matches_current(current_approval, current_sandbox, &preset);
|
||||
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
|
||||
"Agent (non-elevated sandbox)".to_string()
|
||||
} else {
|
||||
preset.label.to_string()
|
||||
};
|
||||
let name = preset.label.to_string();
|
||||
let description = Some(preset.description.to_string());
|
||||
let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) {
|
||||
Ok(()) => None,
|
||||
@@ -2930,24 +2860,11 @@ impl ChatWidget {
|
||||
{
|
||||
if codex_core::get_platform_sandbox().is_none() {
|
||||
let preset_clone = preset.clone();
|
||||
if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& codex_core::windows_sandbox::sandbox_setup_is_complete(
|
||||
self.config.codex_home.as_path(),
|
||||
)
|
||||
{
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
});
|
||||
})]
|
||||
} else {
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
}
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
} else if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
self.world_writable_warning_details()
|
||||
{
|
||||
@@ -2982,18 +2899,8 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
let footer_note = show_elevate_sandbox_hint.then(|| {
|
||||
vec![
|
||||
"The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(),
|
||||
"/setup-elevated-sandbox".cyan(),
|
||||
".".dim(),
|
||||
]
|
||||
.into()
|
||||
});
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_note,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(()),
|
||||
@@ -3270,106 +3177,36 @@ impl ChatWidget {
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
|
||||
// Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it
|
||||
// directly (no elevation prompts).
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(
|
||||
Paragraph::new(vec![
|
||||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
let preset_clone = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Enable experimental sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Go back".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: None,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let presets = builtin_approval_presets();
|
||||
let stay_full_access = presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.is_some_and(|preset| {
|
||||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||||
});
|
||||
let stay_actions = if stay_full_access {
|
||||
Vec::new()
|
||||
} else {
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "read-only")
|
||||
.map(|preset| {
|
||||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let stay_label = if stay_full_access {
|
||||
"Stay in Agent Full Access".to_string()
|
||||
} else {
|
||||
"Stay in Read-Only".to_string()
|
||||
};
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(
|
||||
Paragraph::new(vec![
|
||||
line!["Set Up Agent Sandbox".bold()],
|
||||
line![""],
|
||||
line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."],
|
||||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||||
line![
|
||||
"Learn more: https://developers.openai.com/codex/windows"
|
||||
],
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
let preset_clone = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Set up agent sandbox (requires elevation)".to_string(),
|
||||
name: "Enable experimental sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: preset.clone(),
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: stay_label,
|
||||
name: "Go back".to_string(),
|
||||
description: None,
|
||||
actions: stay_actions,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -3387,107 +3224,6 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
preset: ApprovalPreset,
|
||||
reason: WindowsSandboxFallbackReason,
|
||||
) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
let _ = reason;
|
||||
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let presets = builtin_approval_presets();
|
||||
let stay_full_access = presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.is_some_and(|preset| {
|
||||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||||
});
|
||||
let stay_actions = if stay_full_access {
|
||||
Vec::new()
|
||||
} else {
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "read-only")
|
||||
.map(|preset| {
|
||||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let stay_label = if stay_full_access {
|
||||
"Stay in Agent Full Access".to_string()
|
||||
} else {
|
||||
"Stay in Read-Only".to_string()
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(line!["Use Non-Elevated Sandbox?".bold()]);
|
||||
lines.push(line![""]);
|
||||
lines.push(line![
|
||||
"Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected."
|
||||
]);
|
||||
lines.push(line![
|
||||
"Learn more: https://developers.openai.com/codex/windows"
|
||||
]);
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
|
||||
|
||||
let elevated_preset = preset.clone();
|
||||
let legacy_preset = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Try elevated agent sandbox setup again".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: elevated_preset.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Use non-elevated agent sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: legacy_preset.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: stay_label,
|
||||
description: None,
|
||||
actions: stay_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: None,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
_preset: ApprovalPreset,
|
||||
_reason: WindowsSandboxFallbackReason,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {
|
||||
if self.config.forced_auto_mode_downgraded_on_windows
|
||||
@@ -3503,34 +3239,6 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
|
||||
// While elevated sandbox setup runs, prevent typing so the user doesn't
|
||||
// accidentally queue messages that will run under an unexpected mode.
|
||||
self.bottom_pane.set_composer_input_enabled(
|
||||
false,
|
||||
Some("Input disabled until setup completes.".to_string()),
|
||||
);
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane.set_interrupt_hint_visible(false);
|
||||
self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
|
||||
self.bottom_pane.set_composer_input_enabled(true, None);
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {
|
||||
self.config.forced_auto_mode_downgraded_on_windows = false;
|
||||
@@ -3563,7 +3271,6 @@ impl ChatWidget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) {
|
||||
if enabled {
|
||||
self.config.features.enable(feature);
|
||||
|
||||
@@ -39,12 +39,12 @@ use codex_core::protocol::RateLimitWindow;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TaskStartedEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
use codex_core::protocol::TokenCountEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_core::protocol::TurnCompleteEvent;
|
||||
use codex_core::protocol::TurnStartedEvent;
|
||||
use codex_core::protocol::UndoCompletedEvent;
|
||||
use codex_core::protocol::UndoStartedEvent;
|
||||
use codex_core::protocol::ViewImageToolCallEvent;
|
||||
@@ -64,8 +64,6 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "windows")]
|
||||
use serial_test::serial;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -78,11 +76,6 @@ fn set_windows_sandbox_enabled(enabled: bool) {
|
||||
codex_core::set_windows_sandbox_enabled(enabled);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_windows_elevated_sandbox_enabled(enabled: bool) {
|
||||
codex_core::set_windows_elevated_sandbox_enabled(enabled);
|
||||
}
|
||||
|
||||
async fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
let codex_home = std::env::temp_dir();
|
||||
@@ -1114,7 +1107,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
||||
|
||||
chat.bottom_pane.insert_str("draft message ");
|
||||
chat.bottom_pane
|
||||
.attach_image(PathBuf::from("/tmp/preview.png"), 10, 10, "PNG");
|
||||
.attach_image(PathBuf::from("/tmp/preview.png"));
|
||||
let placeholder = "[Image #1]";
|
||||
assert!(
|
||||
chat.bottom_pane.composer_text().ends_with(placeholder),
|
||||
@@ -1320,7 +1313,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-wait-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
@@ -1369,7 +1362,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-wait-3".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
@@ -1763,7 +1756,7 @@ async fn interrupted_turn_error_message_snapshot() {
|
||||
// Simulate an in-progress task so the widget is in a running state.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -2034,35 +2027,6 @@ async fn approvals_selection_popup_snapshot() {
|
||||
assert_snapshot!("approvals_selection_popup", popup);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some();
|
||||
let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled();
|
||||
|
||||
chat.config.notices.hide_full_access_warning = None;
|
||||
chat.config.features.enable(Feature::WindowsSandbox);
|
||||
chat.config
|
||||
.features
|
||||
.disable(Feature::WindowsSandboxElevated);
|
||||
set_windows_sandbox_enabled(true);
|
||||
set_windows_elevated_sandbox_enabled(false);
|
||||
|
||||
chat.open_approvals_popup();
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
insta::with_settings!({ snapshot_suffix => "windows_degraded" }, {
|
||||
assert_snapshot!("approvals_selection_popup", popup);
|
||||
});
|
||||
|
||||
// Avoid leaking sandbox global state into other tests.
|
||||
set_windows_sandbox_enabled(was_sandbox_enabled);
|
||||
set_windows_elevated_sandbox_enabled(was_elevated_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preset_matching_ignores_extra_writable_roots() {
|
||||
let preset = builtin_approval_presets()
|
||||
@@ -2113,8 +2077,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
|
||||
|
||||
let popup = render_bottom_popup(&chat, 120);
|
||||
assert!(
|
||||
popup.contains("requires elevation"),
|
||||
"expected auto mode prompt to mention elevation, popup: {popup}"
|
||||
popup.contains("Agent mode on Windows uses an experimental sandbox"),
|
||||
"expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2130,16 +2094,12 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
|
||||
|
||||
let popup = render_bottom_popup(&chat, 120);
|
||||
assert!(
|
||||
popup.contains("requires elevation"),
|
||||
"expected startup prompt to explain elevation: {popup}"
|
||||
popup.contains("Agent mode on Windows uses an experimental sandbox"),
|
||||
"expected startup prompt to explain sandbox: {popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Set up agent sandbox"),
|
||||
"expected startup prompt to offer agent sandbox setup: {popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Stay in"),
|
||||
"expected startup prompt to offer staying in current mode: {popup}"
|
||||
popup.contains("Enable experimental sandbox"),
|
||||
"expected startup prompt to offer enabling the sandbox: {popup}"
|
||||
);
|
||||
|
||||
set_windows_sandbox_enabled(true);
|
||||
@@ -2756,7 +2716,7 @@ async fn ui_snapshots_small_heights_task_running() {
|
||||
// Activate status line
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -2787,7 +2747,7 @@ async fn status_widget_and_approval_modal_snapshot() {
|
||||
// Begin a running task so the status indicator would be active.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -2839,7 +2799,7 @@ async fn status_widget_active_snapshot() {
|
||||
// Activate the status indicator by simulating a task start.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3418,7 +3378,7 @@ async fn stream_recovery_restores_previous_status_header() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3455,7 +3415,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
// Begin turn
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3479,7 +3439,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
// End turn
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
@@ -3649,7 +3609,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3693,7 +3653,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() {
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3764,7 +3724,7 @@ printf 'fenced within fenced\n'
|
||||
// Finalize the stream without sending a final AgentMessage, to flush any tail.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
@@ -3781,7 +3741,7 @@ async fn chatwidget_tall() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ use crate::style::user_message_style;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -62,7 +63,6 @@ use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -279,17 +279,15 @@ impl ChatComposer {
|
||||
|
||||
// normalize_pasted_path already handles Windows → WSL path conversion,
|
||||
// so we can directly try to read the image dimensions.
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((w, h)) => {
|
||||
tracing::info!("OK: {pasted}");
|
||||
let format_label = pasted_image_format(&path_buf).label();
|
||||
self.attach_image(path_buf, w, h, format_label);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::trace!("ERR: {err}");
|
||||
false
|
||||
}
|
||||
if image::image_dimensions(&path_buf).is_ok() {
|
||||
tracing::info!("OK: {pasted}");
|
||||
let format = pasted_image_format(&path_buf);
|
||||
tracing::debug!("attached image format={}", format.label());
|
||||
self.attach_image(path_buf);
|
||||
true
|
||||
} else {
|
||||
tracing::trace!("ERR: not an image path");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,17 +333,9 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
|
||||
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, _format_label: &str) {
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let image_number = self.attached_images.len() + 1;
|
||||
let placeholder = if Self::is_default_clipboard_filename(&path) {
|
||||
format!("[Image #{image_number}]")
|
||||
} else {
|
||||
let file_label = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "image".to_string());
|
||||
format!("[{file_label} {width}x{height}]")
|
||||
};
|
||||
let placeholder = local_image_label_text(image_number);
|
||||
// Insert as an element to match large paste placeholder behavior:
|
||||
// styled distinctly and treated atomically for cursor/mutations.
|
||||
self.textarea.insert_element(&placeholder);
|
||||
@@ -408,13 +398,6 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default_clipboard_filename(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.starts_with("codex-clipboard-"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.textarea.insert_str(text);
|
||||
self.sync_popups();
|
||||
@@ -740,7 +723,7 @@ impl ChatComposer {
|
||||
if is_image {
|
||||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||||
let path_buf = PathBuf::from(&sel_path);
|
||||
if let Ok((w, h)) = image::image_dimensions(&path_buf) {
|
||||
if image::image_dimensions(&path_buf).is_ok() {
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
@@ -766,16 +749,7 @@ impl ChatComposer {
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
let format_label = match Path::new(&sel_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
{
|
||||
Some(ext) if ext == "png" => "PNG",
|
||||
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
|
||||
_ => "IMG",
|
||||
};
|
||||
self.attach_image(path_buf, w, h, format_label);
|
||||
self.attach_image(path_buf);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
} else {
|
||||
@@ -3431,12 +3405,12 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
composer.attach_image(path.clone(), 32, 16, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
composer.handle_paste(" hi".into());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[image1.png 32x16] hi"),
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3455,11 +3429,11 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image2.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[image2.png 10x5]"),
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3480,7 +3454,7 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
let path = PathBuf::from("/tmp/image3.png");
|
||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 1: backspace at end
|
||||
@@ -3491,7 +3465,7 @@ mod tests {
|
||||
|
||||
// Re-add and test backspace in middle: should break the placeholder string
|
||||
// and drop the image mapping (same as text placeholder behavior).
|
||||
composer.attach_image(path, 20, 10, "PNG");
|
||||
composer.attach_image(path);
|
||||
let placeholder2 = composer.attached_images[0].placeholder.clone();
|
||||
// Move cursor to roughly middle of placeholder
|
||||
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
|
||||
@@ -3523,7 +3497,7 @@ mod tests {
|
||||
|
||||
// Insert an image placeholder at the start
|
||||
let path = PathBuf::from("/tmp/image_multibyte.png");
|
||||
composer.attach_image(path, 10, 5, "PNG");
|
||||
composer.attach_image(path);
|
||||
// Add multibyte text after the placeholder
|
||||
composer.textarea.insert_str("日本語");
|
||||
|
||||
@@ -3532,12 +3506,7 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
assert!(
|
||||
composer
|
||||
.textarea
|
||||
.text()
|
||||
.starts_with("[image_multibyte.png 10x5]")
|
||||
);
|
||||
assert!(composer.textarea.text().starts_with("[Image #1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3555,10 +3524,10 @@ mod tests {
|
||||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||||
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
||||
|
||||
composer.attach_image(path1, 10, 5, "PNG");
|
||||
composer.attach_image(path1);
|
||||
// separate placeholders with a space for clarity
|
||||
composer.handle_paste(" ".into());
|
||||
composer.attach_image(path2.clone(), 10, 5, "PNG");
|
||||
composer.attach_image(path2.clone());
|
||||
|
||||
let placeholder1 = composer.attached_images[0].placeholder.clone();
|
||||
let placeholder2 = composer.attached_images[1].placeholder.clone();
|
||||
@@ -3584,7 +3553,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
vec![AttachedImage {
|
||||
path: path2,
|
||||
placeholder: "[image_dup2.png 10x5]".to_string()
|
||||
placeholder: "[Image #2]".to_string()
|
||||
}],
|
||||
composer.attached_images,
|
||||
"one image mapping remains"
|
||||
@@ -3611,12 +3580,7 @@ mod tests {
|
||||
|
||||
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
||||
assert!(needs_redraw);
|
||||
assert!(
|
||||
composer
|
||||
.textarea
|
||||
.text()
|
||||
.starts_with("[codex_tui_test_paste_image.png 3x2] ")
|
||||
);
|
||||
assert!(composer.textarea.text().starts_with("[Image #1] "));
|
||||
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
assert_eq!(imgs, vec![tmp_path]);
|
||||
|
||||
@@ -529,16 +529,9 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
if self.view_stack.is_empty() {
|
||||
self.composer
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.composer.attach_image(path);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
+24
-326
@@ -10,7 +10,6 @@ use codex_backend_client::Client as BackendClient;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConstraintResult;
|
||||
use codex_core::config::types::Notifications;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::git_info::current_branch_name;
|
||||
use codex_core::git_info::local_git_branches;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
@@ -48,11 +47,11 @@ use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::SkillsListEntry;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
use codex_core::protocol::TurnCompleteEvent;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::protocol::UndoCompletedEvent;
|
||||
use codex_core::protocol::UndoStartedEvent;
|
||||
@@ -84,9 +83,6 @@ use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
@@ -1458,7 +1454,7 @@ impl ChatWidget {
|
||||
info.height,
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path, info.width, info.height, info.encoded_format.label());
|
||||
self.attach_image(path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
@@ -1511,16 +1507,9 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
tracing::info!("attach_image path={path:?}");
|
||||
self.bottom_pane
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.bottom_pane.attach_image(path);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -1536,12 +1525,6 @@ impl ChatWidget {
|
||||
}
|
||||
match cmd {
|
||||
SlashCommand::Feedback => {
|
||||
if !self.config.feedback_enabled {
|
||||
let params = crate::bottom_pane::feedback_disabled_params();
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
// Step 1: pick a category (UI built in feedback_view)
|
||||
let params =
|
||||
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
|
||||
@@ -1579,45 +1562,6 @@ impl ChatWidget {
|
||||
SlashCommand::Approvals => {
|
||||
self.open_approvals_popup();
|
||||
}
|
||||
SlashCommand::ElevateSandbox => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox()
|
||||
.is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled();
|
||||
if !windows_degraded_sandbox_enabled
|
||||
|| !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
{
|
||||
// This command should not be visible/recognized outside degraded mode,
|
||||
// but guard anyway in case something dispatches it directly.
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(preset) = builtin_approval_presets()
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "auto")
|
||||
else {
|
||||
// Avoid panicking in interactive UI; treat this as a recoverable
|
||||
// internal error.
|
||||
self.add_error_message(
|
||||
"Internal error: missing the 'auto' approval preset.".to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = self.config.approval_policy.can_set(&preset.approval) {
|
||||
self.add_error_message(err.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Not supported; on non-Windows this command should never be reachable.
|
||||
};
|
||||
}
|
||||
SlashCommand::Quit | SlashCommand::Exit => {
|
||||
self.request_exit();
|
||||
}
|
||||
@@ -1917,8 +1861,8 @@ impl ChatWidget {
|
||||
self.on_agent_reasoning_final();
|
||||
}
|
||||
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
|
||||
EventMsg::TurnStarted(_) => self.on_task_started(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => {
|
||||
EventMsg::TaskStarted(_) => self.on_task_started(),
|
||||
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
|
||||
self.on_task_complete(last_agent_message)
|
||||
}
|
||||
EventMsg::TokenCount(ev) => {
|
||||
@@ -2642,25 +2586,10 @@ impl ChatWidget {
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let windows_degraded_sandbox_enabled = false;
|
||||
|
||||
let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& windows_degraded_sandbox_enabled
|
||||
&& presets.iter().any(|preset| preset.id == "auto");
|
||||
|
||||
for preset in presets.into_iter() {
|
||||
let is_current =
|
||||
Self::preset_matches_current(current_approval, current_sandbox, &preset);
|
||||
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
|
||||
"Agent (non-elevated sandbox)".to_string()
|
||||
} else {
|
||||
preset.label.to_string()
|
||||
};
|
||||
let name = preset.label.to_string();
|
||||
let description_text = preset.description;
|
||||
let description = Some(description_text.to_string());
|
||||
let requires_confirmation = preset.id == "full-access"
|
||||
@@ -2681,24 +2610,11 @@ impl ChatWidget {
|
||||
{
|
||||
if codex_core::get_platform_sandbox().is_none() {
|
||||
let preset_clone = preset.clone();
|
||||
if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& codex_core::windows_sandbox::sandbox_setup_is_complete(
|
||||
self.config.codex_home.as_path(),
|
||||
)
|
||||
{
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
});
|
||||
})]
|
||||
} else {
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
}
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
} else if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
self.world_writable_warning_details()
|
||||
{
|
||||
@@ -2732,18 +2648,8 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
let footer_note = show_elevate_sandbox_hint.then(|| {
|
||||
vec![
|
||||
"The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(),
|
||||
"/setup-elevated-sandbox".cyan(),
|
||||
".".dim(),
|
||||
]
|
||||
.into()
|
||||
});
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_note,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(()),
|
||||
@@ -3001,106 +2907,36 @@ impl ChatWidget {
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
|
||||
// Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it
|
||||
// directly (no elevation prompts).
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(
|
||||
Paragraph::new(vec![
|
||||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
let preset_clone = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Enable experimental sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Go back".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: None,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let presets = builtin_approval_presets();
|
||||
let stay_full_access = presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.is_some_and(|preset| {
|
||||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||||
});
|
||||
let stay_actions = if stay_full_access {
|
||||
Vec::new()
|
||||
} else {
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "read-only")
|
||||
.map(|preset| {
|
||||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let stay_label = if stay_full_access {
|
||||
"Stay in Agent Full Access".to_string()
|
||||
} else {
|
||||
"Stay in Read-Only".to_string()
|
||||
};
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(
|
||||
Paragraph::new(vec![
|
||||
line!["Set Up Agent Sandbox".bold()],
|
||||
line![""],
|
||||
line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."],
|
||||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||||
line![
|
||||
"Learn more: https://developers.openai.com/codex/windows"
|
||||
],
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
let preset_clone = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Set up agent sandbox (requires elevation)".to_string(),
|
||||
name: "Enable experimental sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: preset.clone(),
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: stay_label,
|
||||
name: "Go back".to_string(),
|
||||
description: None,
|
||||
actions: stay_actions,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -3118,107 +2954,6 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
preset: ApprovalPreset,
|
||||
reason: WindowsSandboxFallbackReason,
|
||||
) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
let _ = reason;
|
||||
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let presets = builtin_approval_presets();
|
||||
let stay_full_access = presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.is_some_and(|preset| {
|
||||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||||
});
|
||||
let stay_actions = if stay_full_access {
|
||||
Vec::new()
|
||||
} else {
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "read-only")
|
||||
.map(|preset| {
|
||||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let stay_label = if stay_full_access {
|
||||
"Stay in Agent Full Access".to_string()
|
||||
} else {
|
||||
"Stay in Read-Only".to_string()
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(line!["Use Non-Elevated Sandbox?".bold()]);
|
||||
lines.push(line![""]);
|
||||
lines.push(line![
|
||||
"Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected."
|
||||
]);
|
||||
lines.push(line![
|
||||
"Learn more: https://developers.openai.com/codex/windows"
|
||||
]);
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
|
||||
|
||||
let elevated_preset = preset.clone();
|
||||
let legacy_preset = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Try elevated agent sandbox setup again".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: elevated_preset.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Use non-elevated agent sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: legacy_preset.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: stay_label,
|
||||
description: None,
|
||||
actions: stay_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: None,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
_preset: ApprovalPreset,
|
||||
_reason: WindowsSandboxFallbackReason,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {
|
||||
if self.config.forced_auto_mode_downgraded_on_windows
|
||||
@@ -3234,34 +2969,6 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
|
||||
// While elevated sandbox setup runs, prevent typing so the user doesn't
|
||||
// accidentally queue messages that will run under an unexpected mode.
|
||||
self.bottom_pane.set_composer_input_enabled(
|
||||
false,
|
||||
Some("Input disabled until setup completes.".to_string()),
|
||||
);
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane.set_interrupt_hint_visible(false);
|
||||
self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
|
||||
self.bottom_pane.set_composer_input_enabled(true, None);
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {
|
||||
self.config.forced_auto_mode_downgraded_on_windows = false;
|
||||
@@ -3294,15 +3001,6 @@ impl ChatWidget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) {
|
||||
if enabled {
|
||||
self.config.features.enable(feature);
|
||||
} else {
|
||||
self.config.features.disable(feature);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) {
|
||||
self.config.notices.hide_full_access_warning = Some(acknowledged);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ use codex_core::CodexAuth;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::Constrained;
|
||||
#[cfg(target_os = "windows")]
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
@@ -38,11 +36,11 @@ use codex_core::protocol::RateLimitWindow;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TaskStartedEvent;
|
||||
use codex_core::protocol::TokenCountEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_core::protocol::TurnCompleteEvent;
|
||||
use codex_core::protocol::TurnStartedEvent;
|
||||
use codex_core::protocol::UndoCompletedEvent;
|
||||
use codex_core::protocol::UndoStartedEvent;
|
||||
use codex_core::protocol::ViewImageToolCallEvent;
|
||||
@@ -62,8 +60,6 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "windows")]
|
||||
use serial_test::serial;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -76,11 +72,6 @@ fn set_windows_sandbox_enabled(enabled: bool) {
|
||||
codex_core::set_windows_sandbox_enabled(enabled);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_windows_elevated_sandbox_enabled(enabled: bool) {
|
||||
codex_core::set_windows_elevated_sandbox_enabled(enabled);
|
||||
}
|
||||
|
||||
async fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
let codex_home = std::env::temp_dir();
|
||||
@@ -1065,7 +1056,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
||||
|
||||
chat.bottom_pane.insert_str("draft message ");
|
||||
chat.bottom_pane
|
||||
.attach_image(PathBuf::from("/tmp/preview.png"), 10, 10, "PNG");
|
||||
.attach_image(PathBuf::from("/tmp/preview.png"));
|
||||
let placeholder = "[Image #1]";
|
||||
assert!(
|
||||
chat.bottom_pane.composer_text().ends_with(placeholder),
|
||||
@@ -1589,7 +1580,7 @@ async fn interrupted_turn_error_message_snapshot() {
|
||||
// Simulate an in-progress task so the widget is in a running state.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -1795,35 +1786,6 @@ async fn approvals_selection_popup_snapshot() {
|
||||
assert_snapshot!("approvals_selection_popup", popup);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some();
|
||||
let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled();
|
||||
|
||||
chat.config.notices.hide_full_access_warning = None;
|
||||
chat.config.features.enable(Feature::WindowsSandbox);
|
||||
chat.config
|
||||
.features
|
||||
.disable(Feature::WindowsSandboxElevated);
|
||||
set_windows_sandbox_enabled(true);
|
||||
set_windows_elevated_sandbox_enabled(false);
|
||||
|
||||
chat.open_approvals_popup();
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
insta::with_settings!({ snapshot_suffix => "windows_degraded" }, {
|
||||
assert_snapshot!("approvals_selection_popup", popup);
|
||||
});
|
||||
|
||||
// Avoid leaking sandbox global state into other tests.
|
||||
set_windows_sandbox_enabled(was_sandbox_enabled);
|
||||
set_windows_elevated_sandbox_enabled(was_elevated_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preset_matching_ignores_extra_writable_roots() {
|
||||
let preset = builtin_approval_presets()
|
||||
@@ -1874,8 +1836,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
|
||||
|
||||
let popup = render_bottom_popup(&chat, 120);
|
||||
assert!(
|
||||
popup.contains("requires elevation"),
|
||||
"expected auto mode prompt to mention elevation, popup: {popup}"
|
||||
popup.contains("Agent mode on Windows uses an experimental sandbox"),
|
||||
"expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1891,16 +1853,12 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
|
||||
|
||||
let popup = render_bottom_popup(&chat, 120);
|
||||
assert!(
|
||||
popup.contains("requires elevation"),
|
||||
"expected startup prompt to explain elevation: {popup}"
|
||||
popup.contains("Agent mode on Windows uses an experimental sandbox"),
|
||||
"expected startup prompt to explain sandbox: {popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Set up agent sandbox"),
|
||||
"expected startup prompt to offer agent sandbox setup: {popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Stay in"),
|
||||
"expected startup prompt to offer staying in current mode: {popup}"
|
||||
popup.contains("Enable experimental sandbox"),
|
||||
"expected startup prompt to offer enabling the sandbox: {popup}"
|
||||
);
|
||||
|
||||
set_windows_sandbox_enabled(true);
|
||||
@@ -2383,7 +2341,7 @@ async fn ui_snapshots_small_heights_task_running() {
|
||||
// Activate status line
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -2414,7 +2372,7 @@ async fn status_widget_and_approval_modal_snapshot() {
|
||||
// Begin a running task so the status indicator would be active.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -2466,7 +2424,7 @@ async fn status_widget_active_snapshot() {
|
||||
// Activate the status indicator by simulating a task start.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3041,7 +2999,7 @@ async fn stream_recovery_restores_previous_status_header() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3078,7 +3036,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
// Begin turn
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3102,7 +3060,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
// End turn
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
@@ -3272,7 +3230,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3316,7 +3274,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() {
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
@@ -3387,7 +3345,7 @@ printf 'fenced within fenced\n'
|
||||
// Finalize the stream without sending a final AgentMessage, to flush any tail.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
@@ -3404,7 +3362,7 @@ async fn chatwidget_tall() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user