diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d4c9608967..8b17591059 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -217,6 +217,7 @@ mod background_requests; mod config_persistence; mod connector_mentions; mod event_dispatch; +mod file_change_approvals; mod history_pagination; mod history_ui; mod input; diff --git a/codex-rs/tui/src/app/file_change_approvals.rs b/codex-rs/tui/src/app/file_change_approvals.rs new file mode 100644 index 0000000000..7c00955612 --- /dev/null +++ b/codex-rs/tui/src/app/file_change_approvals.rs @@ -0,0 +1,68 @@ +//! Recover file-change details for live and replayed approval requests. + +use super::*; + +impl App { + pub(super) async fn thread_file_change_changes( + &self, + thread_id: ThreadId, + turn_id: &str, + item_id: &str, + ) -> Option> { + let channel = self.thread_event_channels.get(&thread_id)?; + let store = channel.store.lock().await; + store.file_change_changes(turn_id, item_id) + } + + pub(super) async fn handle_thread_event_now_recovering_file_changes( + &mut self, + event: ThreadBufferedEvent, + ) { + let changes = match (&event, self.active_thread_id) { + (ThreadBufferedEvent::Request(request), Some(thread_id)) => match request.as_ref() { + ServerRequest::FileChangeRequestApproval { params, .. } => { + self.thread_file_change_changes(thread_id, ¶ms.turn_id, ¶ms.item_id) + .await + } + _ => None, + }, + _ => None, + }; + match (changes, event) { + (Some(changes), ThreadBufferedEvent::Request(request)) => { + if self + .pending_app_server_requests + .contains_server_request(request.as_ref()) + { + self.handle_file_change_request(*request, changes); + } + } + (_, event) => self.handle_thread_event_now(event), + } + } + + pub(super) fn handle_file_change_request( + &mut self, + request: ServerRequest, + changes: Vec, + ) { + let ServerRequest::FileChangeRequestApproval { params, .. } = request else { + return; + }; + self.chat_widget.on_apply_patch_approval_request( + params.item_id.clone(), + crate::approval_events::ApplyPatchApprovalRequestEvent { + call_id: params.item_id, + turn_id: params.turn_id, + changes: crate::app_server_approval_conversions::file_update_changes_to_display( + changes, + ), + reason: params.reason, + grant_root: params.grant_root, + }, + ); + if self.startup_protected_input_boundary && !self.chat_widget.has_active_view() { + self.startup_pending_protected_request = true; + } + } +} diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 08c005abf8..d831883478 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3435,6 +3435,125 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() { assert!(rendered.contains("1 +hello")); } +#[tokio::test] +async fn active_thread_file_change_approval_recovers_buffered_changes() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.startup_protected_input_boundary = true; + app.enqueue_thread_notification( + thread_id, + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: thread_id.to_string(), + turn_id: "turn-active-approval".to_string(), + started_at_ms: 0, + item: ThreadItem::FileChange { + id: "patch-active-approval".to_string(), + changes: vec![FileUpdateChange { + path: "visible-target.md".to_string(), + kind: PatchChangeKind::Add, + diff: "hello\n".to_string(), + }], + status: codex_app_server_protocol::PatchApplyStatus::InProgress, + }, + }), + ) + .await + .expect("enqueue file change item"); + + let request = ServerRequest::FileChangeRequestApproval { + request_id: AppServerRequestId::Integer(10), + params: FileChangeRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: "turn-active-approval".to_string(), + item_id: "patch-active-approval".to_string(), + started_at_ms: 0, + reason: None, + grant_root: None, + }, + }; + assert_eq!( + app.pending_app_server_requests + .note_server_request(&request), + None + ); + app.chat_widget.handle_server_notification( + agent_message_delta_notification(thread_id, "turn-active-approval", "agent-1", "streaming"), + /*replay_kind*/ None, + ); + let event = ThreadBufferedEvent::Request(Box::new(request)); + app.handle_thread_event_now_recovering_file_changes(event) + .await; + + assert!(!app.chat_widget.has_active_view()); + assert!(app.startup_pending_protected_request); + app.chat_widget.handle_server_notification( + ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: "turn-active-approval".to_string(), + completed_at_ms: 0, + item: ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "streaming".to_string(), + phase: None, + memory_citation: None, + }, + }), + /*replay_kind*/ None, + ); + + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 100); + let destination = app.chat_widget.config_ref().cwd.join("visible-target.md"); + assert!(rendered.contains("Description: Apply proposed file edits")); + assert!(rendered.contains(&format!("Destination: {}", destination.display()))); +} + +#[tokio::test] +async fn replayed_file_change_approval_recovers_snapshot_changes() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let cwd = test_path_buf("/tmp/project").abs(); + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: Some(test_thread_session(thread_id, cwd.clone().into_path_buf())), + turns: vec![test_turn( + "turn-replayed-approval", + TurnStatus::InProgress, + vec![ThreadItem::FileChange { + id: "patch-replayed-approval".to_string(), + changes: vec![FileUpdateChange { + path: "visible-target.md".to_string(), + kind: PatchChangeKind::Add, + diff: "hello\n".to_string(), + }], + status: codex_app_server_protocol::PatchApplyStatus::InProgress, + }], + )], + events: vec![ThreadBufferedEvent::Request(Box::new( + ServerRequest::FileChangeRequestApproval { + request_id: AppServerRequestId::Integer(11), + params: FileChangeRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: "turn-replayed-approval".to_string(), + item_id: "patch-replayed-approval".to_string(), + started_at_ms: 0, + reason: None, + grant_root: None, + }, + }, + ))], + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 100).replace( + &cwd.join("visible-target.md").display().to_string(), + "/tmp/project/visible-target.md", + ); + assert_app_snapshot!("replayed_file_change_approval", rendered); +} + #[tokio::test] async fn inactive_thread_permissions_approval_preserves_file_system_permissions() { let app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index ba6800ea88..05555af5c6 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -196,35 +196,7 @@ impl ThreadEventStore { turn_id: &str, item_id: &str, ) -> Option> { - self.buffer - .iter() - .rev() - .find_map(|event| match event { - ThreadBufferedEvent::Notification(notification) => match notification.as_ref() { - ServerNotification::ItemStarted(notification) - if turn_id_matches(turn_id, ¬ification.turn_id) => - { - file_change_item_changes(¬ification.item, item_id) - } - ServerNotification::ItemCompleted(notification) - if turn_id_matches(turn_id, ¬ification.turn_id) => - { - file_change_item_changes(¬ification.item, item_id) - } - _ => None, - }, - ThreadBufferedEvent::Request(_) - | ThreadBufferedEvent::HistoryEntryResponse(_) - | ThreadBufferedEvent::FeedbackSubmission(_) => None, - }) - .or_else(|| { - self.turns - .iter() - .rev() - .filter(|turn| turn_id_matches(turn_id, &turn.id)) - .flat_map(|turn| turn.items.iter().rev()) - .find_map(|item| file_change_item_changes(item, item_id)) - }) + file_change_changes(self.buffer.iter(), &self.turns, turn_id, item_id) } pub(super) fn snapshot(&self) -> ThreadEventSnapshot { @@ -298,6 +270,38 @@ fn turn_id_matches(request_turn_id: &str, candidate_turn_id: &str) -> bool { request_turn_id.is_empty() || request_turn_id == candidate_turn_id } +pub(super) fn file_change_changes<'a>( + events: impl DoubleEndedIterator, + turns: &'a [Turn], + turn_id: &str, + item_id: &str, +) -> Option> { + let event_items = events.rev().filter_map(|event| { + let ThreadBufferedEvent::Notification(notification) = event else { + return None; + }; + let (candidate_turn_id, item) = match notification.as_ref() { + ServerNotification::ItemStarted(notification) => { + (¬ification.turn_id, ¬ification.item) + } + ServerNotification::ItemCompleted(notification) => { + (¬ification.turn_id, ¬ification.item) + } + _ => return None, + }; + turn_id_matches(turn_id, candidate_turn_id).then_some(item) + }); + event_items + .chain( + turns + .iter() + .rev() + .filter(|turn| turn_id_matches(turn_id, &turn.id)) + .flat_map(|turn| turn.items.iter().rev()), + ) + .find_map(|item| file_change_item_changes(item, item_id)) +} + fn file_change_item_changes( item: &ThreadItem, item_id: &str, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 7ac3ac4e5c..016b25cbdb 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -202,17 +202,6 @@ impl App { store.session.as_ref().map(|session| session.cwd.clone()) } - async fn thread_file_change_changes( - &self, - thread_id: ThreadId, - turn_id: &str, - item_id: &str, - ) -> Option> { - let channel = self.thread_event_channels.get(&thread_id)?; - let store = channel.store.lock().await; - store.file_change_changes(turn_id, item_id) - } - pub(super) async fn interactive_request_for_thread_request( &self, thread_id: ThreadId, @@ -1451,7 +1440,10 @@ impl App { let mut disconnected = false; loop { match rx.try_recv() { - Ok(event) => self.handle_thread_event_now(event), + Ok(event) => { + self.handle_thread_event_now_recovering_file_changes(event) + .await + } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { disconnected = true; @@ -1503,6 +1495,25 @@ impl App { snapshot: ThreadEventSnapshot, resume_restored_queue: bool, ) { + let request_changes = snapshot + .events + .iter() + .map(|event| { + let ThreadBufferedEvent::Request(request) = event else { + return None; + }; + let ServerRequest::FileChangeRequestApproval { params, .. } = request.as_ref() + else { + return None; + }; + file_change_changes( + snapshot.events.iter(), + &snapshot.turns, + ¶ms.turn_id, + ¶ms.item_id, + ) + }) + .collect::>(); self.refresh_mcp_startup_expected_servers_from_config(); let should_buffer_replay = !snapshot.turns.is_empty() || !snapshot.events.is_empty(); if should_buffer_replay { @@ -1536,11 +1547,16 @@ impl App { self.chat_widget .replay_thread_turns(snapshot.turns, ReplayKind::ThreadSnapshot); } - for event in snapshot.events { + for (event, changes) in snapshot.events.into_iter().zip(request_changes) { if suppress_replay_notices && replay_filter::event_is_notice(&event) { continue; } - self.handle_thread_event_replay(event); + match (event, changes) { + (ThreadBufferedEvent::Request(request), Some(changes)) => { + self.handle_file_change_request(*request, changes) + } + (event, _) => self.handle_thread_event_replay(event), + } } if should_buffer_replay { self.app_event_tx @@ -1753,7 +1769,8 @@ impl App { self.pending_shutdown_exit_thread_id = None; } let had_active_view = self.chat_widget.has_active_view(); - self.handle_thread_event_now(event); + self.handle_thread_event_now_recovering_file_changes(event) + .await; if !had_active_view && self.chat_widget.has_active_view() && self.startup_protected_input_boundary diff --git a/codex-rs/tui/src/bottom_pane/apply_patch_header.rs b/codex-rs/tui/src/bottom_pane/apply_patch_header.rs new file mode 100644 index 0000000000..33dc4ffb87 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/apply_patch_header.rs @@ -0,0 +1,74 @@ +//! Render file-change approval descriptions and cross-platform destinations. + +use super::approval_overlay::ApplyPatchApprovalRequest; +use crate::diff_model::FileChange; +use crate::render::renderable::Renderable; +use codex_utils_path_uri::LegacyAppPathString; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +pub(super) fn build_header(request: &ApplyPatchApprovalRequest) -> Box { + let mut header = Vec::new(); + if let Some(thread_label) = &request.thread_label { + header.push(Line::from(vec![ + "Thread: ".into(), + thread_label.clone().bold(), + ])); + } + + if !header.is_empty() { + header.push(Line::from("")); + } + + let description = request + .reason + .as_deref() + .filter(|reason| !reason.is_empty()) + .unwrap_or("Apply proposed file edits"); + header.push(Line::from_iter([ + "Description: ".into(), + description.to_string().italic(), + ])); + + let mut destinations: Vec<_> = request + .changes + .iter() + .flat_map(|(path, change)| { + let move_destination = match change { + FileChange::Update { + move_path: Some(move_path), + .. + } => Some(move_path), + FileChange::Add { .. } + | FileChange::Delete { .. } + | FileChange::Update { + move_path: None, .. + } => None, + }; + std::iter::once(path).chain(move_destination) + }) + .map(|path| { + LegacyAppPathString::from_path(path) + .to_inferred_path_uri() + .map(|path| path.inferred_native_path_string()) + .unwrap_or_else(|| request.cwd.join(path).display().to_string()) + }) + .collect(); + destinations.sort(); + destinations.dedup(); + if destinations.is_empty() { + header.push(Line::from(vec![ + "Destination: ".into(), + "unavailable".bold(), + ])); + } + for destination in destinations { + header.push(Line::from_iter([ + "Destination: ".into(), + destination.bold(), + ])); + } + Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) +} diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 2f97b329f2..d1fa6cc48f 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -742,30 +742,7 @@ fn build_header(request: &ApprovalRequest) -> Box { } Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) } - ApprovalRequest::ApplyPatch(request) => { - let mut header: Vec> = Vec::new(); - if let Some(thread_label) = &request.thread_label { - header.push(Box::new(Line::from(vec![ - "Thread: ".into(), - thread_label.clone().bold(), - ]))); - } - if let Some(reason) = &request.reason - && !reason.is_empty() - { - if !header.is_empty() { - header.push(Box::new(Line::from(""))); - } - header.push(Box::new( - Paragraph::new(Line::from_iter([ - "Reason: ".into(), - reason.clone().italic(), - ])) - .wrap(Wrap { trim: false }), - )); - } - Box::new(ColumnRenderable::with(header)) - } + ApprovalRequest::ApplyPatch(request) => super::apply_patch_header::build_header(request), ApprovalRequest::McpElicitation(request) => { let mut lines = Vec::new(); if let Some(thread_label) = &request.thread_label { @@ -1124,6 +1101,8 @@ mod tests { use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_absolute_path::test_support::PathExt; + use codex_utils_absolute_path::test_support::test_path_buf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; @@ -2128,12 +2107,30 @@ mod tests { content: "one\ntwo\nthree\n".to_string(), }, ); + changes.insert( + PathBuf::from("old.txt"), + FileChange::Update { + unified_diff: String::new(), + move_path: Some(PathBuf::from("new.txt")), + }, + ); + #[cfg(windows)] + let (foreign_path, foreign_move_path) = ("/remote/old.txt", "/remote/new.txt"); + #[cfg(not(windows))] + let (foreign_path, foreign_move_path) = (r"C:\workspace\old.txt", r"C:\workspace\new.txt"); + changes.insert( + PathBuf::from(foreign_path), + FileChange::Update { + unified_diff: String::new(), + move_path: Some(PathBuf::from(foreign_move_path)), + }, + ); let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { thread_id: ThreadId::new(), thread_label: Some("Banach [worker]".to_string()), id: "test".to_string(), reason: None, - cwd: absolute_path("/tmp"), + cwd: test_path_buf("/tmp").abs(), changes, }); let keymap = crate::keymap::RuntimeKeymap::defaults(); @@ -2146,10 +2143,36 @@ mod tests { ); let rendered = render_overlay_lines(&view, /*width*/ 120); assert!(rendered.contains("Thread: Banach [worker]")); + assert!(rendered.contains("Description: Apply proposed file edits")); + for path in ["/tmp/bug1.txt", "/tmp/new.txt", "/tmp/old.txt"] { + assert!(rendered.contains(&format!("Destination: {}", test_path_buf(path).display()))); + } + for path in [foreign_path, foreign_move_path] { + assert!(rendered.contains(&format!("Destination: {path}"))); + } assert!(rendered.contains("o to open thread")); assert!(!rendered.contains("$ apply_patch")); } + #[test] + fn apply_patch_prompt_without_changes_shows_unavailable_destination() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + reason: None, + cwd: absolute_path("/tmp"), + changes: HashMap::new(), + }); + let view = make_overlay(request, tx, Features::with_defaults()); + assert_snapshot!( + "approval_overlay_patch_destination_unavailable", + normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) + ); + } + #[test] fn network_exec_prompt_title_includes_host() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6201e0513b..d22cb900d3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -56,6 +56,7 @@ use std::time::Instant; mod action_required_title; mod app_link_view; +mod apply_patch_header; mod approval_overlay; mod mcp_server_elicitation; mod multi_select_picker; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_patch_destination_unavailable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_patch_destination_unavailable.snap new file mode 100644 index 0000000000..d47bc3d6c7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_patch_destination_unavailable.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" +--- + Would you like to make the following edits? + + Description: Apply proposed file edits + Destination: unavailable + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for these files (a) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 8635b66682..2a49a6ee7f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -4,7 +4,8 @@ expression: contents --- Would you like to make the following edits? - Reason: The model wants to apply changes + Description: The model wants to apply changes + Destination: /tmp/project/README.md › 1. Yes, proceed (y) 2. Yes, and don't ask again for these files (a) diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index f63e826844..d57dbef008 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1691,6 +1691,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() #[tokio::test] async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.cwd = test_path_buf("/tmp/project").abs(); chat.config .permissions .approval_policy @@ -1709,7 +1710,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { turn_id: "turn-approve-patch".into(), changes, reason: Some("The model wants to apply changes".into()), - grant_root: Some(PathBuf::from("/tmp")), + grant_root: Some(test_path_buf("/tmp/project")), }; handle_apply_patch_approval_request(&mut chat, "sub-approve-patch", ev); @@ -1722,7 +1723,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { .expect("draw patch approval modal"); let contents = terminal.backend().vt100().screen().contents(); assert!(!contents.contains("$ apply_patch")); - assert_chatwidget_snapshot!("approval_modal_patch", contents); + assert_chatwidget_snapshot!("approval_modal_patch", normalize_snapshot_paths(contents)); Ok(()) } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index a8dc6917e1..86fb043465 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -44,6 +44,7 @@ pub(super) fn normalize_snapshot_paths(text: impl Into) -> String { text = text.replace(&platform_path, unix_path); } } + text = text.replace("/tmp/project\\", "/tmp/project/"); let platform_test_cwd = test_path_display("/tmp/project"); if platform_test_cwd == "/tmp/project" { diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 5b467fe931..f3783fa251 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -14,7 +14,7 @@ impl ChatWidget { ); } - pub(super) fn on_apply_patch_approval_request( + pub(crate) fn on_apply_patch_approval_request( &mut self, _id: String, ev: ApplyPatchApprovalRequestEvent, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__replayed_file_change_approval.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__replayed_file_change_approval.snap new file mode 100644 index 0000000000..3dbc24baef --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__replayed_file_change_approval.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/tests.rs +expression: rendered +--- + Would you like to make the following edits? + + Description: Apply proposed file edits + Destination: /tmp/project/visible-target.md + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for these files (a) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel