Use [Image #1] format more consistently across Ctrl+V and drag/drop, put image above user message

This commit is contained in:
Charles Cunningham
2026-01-11 22:25:17 -08:00
parent 29c2859e6e
commit 5e4e63c6ab
11 changed files with 171 additions and 956 deletions
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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,
+11 -29
View File
@@ -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
})
+37 -78
View File
@@ -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]);
+2 -9
View File
@@ -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
View File
@@ -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);
+22 -62
View File
@@ -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,
}),
});
+26 -62
View File
@@ -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]);
+2 -9
View File
@@ -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
View File
@@ -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);
}
+20 -62
View File
@@ -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,
}),
});