Show file destinations in TUI change approvals (#39285)

## What changed

- Show a description and the destination paths affected by each file-change approval.
- Include both source and target paths for moves, format cross-platform paths for display, and show `unavailable` when change details are missing.
- Recover file changes from buffered events and thread snapshots so live and replayed approvals retain their destination details.

## Testing

- Add coverage for active and replayed approvals, moved and cross-platform paths, and the missing-destination fallback.

GitOrigin-RevId: 24f90b313fd8643f90cbead1504c788b140d7fed
This commit is contained in:
Eric Traut
2026-08-18 21:43:22 +00:00
committed by copyberry
parent ba37d0c45b
commit e7f9fa9cd9
14 changed files with 411 additions and 73 deletions
+1
View File
@@ -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;
@@ -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<Vec<codex_app_server_protocol::FileUpdateChange>> {
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, &params.turn_id, &params.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<codex_app_server_protocol::FileUpdateChange>,
) {
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;
}
}
}
+119
View File
@@ -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;
+33 -29
View File
@@ -196,35 +196,7 @@ impl ThreadEventStore {
turn_id: &str,
item_id: &str,
) -> Option<Vec<codex_app_server_protocol::FileUpdateChange>> {
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, &notification.turn_id) =>
{
file_change_item_changes(&notification.item, item_id)
}
ServerNotification::ItemCompleted(notification)
if turn_id_matches(turn_id, &notification.turn_id) =>
{
file_change_item_changes(&notification.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<Item = &'a ThreadBufferedEvent>,
turns: &'a [Turn],
turn_id: &str,
item_id: &str,
) -> Option<Vec<codex_app_server_protocol::FileUpdateChange>> {
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) => {
(&notification.turn_id, &notification.item)
}
ServerNotification::ItemCompleted(notification) => {
(&notification.turn_id, &notification.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,
+32 -15
View File
@@ -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<Vec<codex_app_server_protocol::FileUpdateChange>> {
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,
&params.turn_id,
&params.item_id,
)
})
.collect::<Vec<_>>();
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
@@ -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<dyn Renderable> {
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 }))
}
@@ -742,30 +742,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
}
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
}
ApprovalRequest::ApplyPatch(request) => {
let mut header: Vec<Box<dyn Renderable>> = 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::<AppEvent>();
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::<AppEvent>();
+1
View File
@@ -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;
@@ -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
@@ -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)
@@ -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(())
}
@@ -44,6 +44,7 @@ pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> 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" {
+1 -1
View File
@@ -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,
@@ -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