mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
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:
1 parent
ba37d0c45b
commit
e7f9fa9cd9
14 files changed
+411
-73
No files matched your search
@@ -217,6 +217,7 @@ mod background_requests;
|
|||||||
mod config_persistence;
|
mod config_persistence;
|
||||||
mod connector_mentions;
|
mod connector_mentions;
|
||||||
mod event_dispatch;
|
mod event_dispatch;
|
||||||
|
mod file_change_approvals;
|
||||||
mod history_pagination;
|
mod history_pagination;
|
||||||
mod history_ui;
|
mod history_ui;
|
||||||
mod input;
|
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, ¶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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3435,6 +3435,125 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() {
|
|||||||
assert!(rendered.contains("1 +hello"));
|
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]
|
#[tokio::test]
|
||||||
async fn inactive_thread_permissions_approval_preserves_file_system_permissions() {
|
async fn inactive_thread_permissions_approval_preserves_file_system_permissions() {
|
||||||
let app = make_test_app().await;
|
let app = make_test_app().await;
|
||||||
|
|||||||
@@ -196,35 +196,7 @@ impl ThreadEventStore {
|
|||||||
turn_id: &str,
|
turn_id: &str,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
) -> Option<Vec<codex_app_server_protocol::FileUpdateChange>> {
|
) -> Option<Vec<codex_app_server_protocol::FileUpdateChange>> {
|
||||||
self.buffer
|
file_change_changes(self.buffer.iter(), &self.turns, turn_id, item_id)
|
||||||
.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))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn snapshot(&self) -> ThreadEventSnapshot {
|
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
|
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) => {
|
||||||
|
(¬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(
|
fn file_change_item_changes(
|
||||||
item: &ThreadItem,
|
item: &ThreadItem,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
|
|||||||
@@ -202,17 +202,6 @@ impl App {
|
|||||||
store.session.as_ref().map(|session| session.cwd.clone())
|
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(
|
pub(super) async fn interactive_request_for_thread_request(
|
||||||
&self,
|
&self,
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
@@ -1451,7 +1440,10 @@ impl App {
|
|||||||
let mut disconnected = false;
|
let mut disconnected = false;
|
||||||
loop {
|
loop {
|
||||||
match rx.try_recv() {
|
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::Empty) => break,
|
||||||
Err(TryRecvError::Disconnected) => {
|
Err(TryRecvError::Disconnected) => {
|
||||||
disconnected = true;
|
disconnected = true;
|
||||||
@@ -1503,6 +1495,25 @@ impl App {
|
|||||||
snapshot: ThreadEventSnapshot,
|
snapshot: ThreadEventSnapshot,
|
||||||
resume_restored_queue: bool,
|
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::<Vec<_>>();
|
||||||
self.refresh_mcp_startup_expected_servers_from_config();
|
self.refresh_mcp_startup_expected_servers_from_config();
|
||||||
let should_buffer_replay = !snapshot.turns.is_empty() || !snapshot.events.is_empty();
|
let should_buffer_replay = !snapshot.turns.is_empty() || !snapshot.events.is_empty();
|
||||||
if should_buffer_replay {
|
if should_buffer_replay {
|
||||||
@@ -1536,11 +1547,16 @@ impl App {
|
|||||||
self.chat_widget
|
self.chat_widget
|
||||||
.replay_thread_turns(snapshot.turns, ReplayKind::ThreadSnapshot);
|
.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) {
|
if suppress_replay_notices && replay_filter::event_is_notice(&event) {
|
||||||
continue;
|
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 {
|
if should_buffer_replay {
|
||||||
self.app_event_tx
|
self.app_event_tx
|
||||||
@@ -1753,7 +1769,8 @@ impl App {
|
|||||||
self.pending_shutdown_exit_thread_id = None;
|
self.pending_shutdown_exit_thread_id = None;
|
||||||
}
|
}
|
||||||
let had_active_view = self.chat_widget.has_active_view();
|
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
|
if !had_active_view
|
||||||
&& self.chat_widget.has_active_view()
|
&& self.chat_widget.has_active_view()
|
||||||
&& self.startup_protected_input_boundary
|
&& 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 }))
|
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
|
||||||
}
|
}
|
||||||
ApprovalRequest::ApplyPatch(request) => {
|
ApprovalRequest::ApplyPatch(request) => super::apply_patch_header::build_header(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::McpElicitation(request) => {
|
ApprovalRequest::McpElicitation(request) => {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
if let Some(thread_label) = &request.thread_label {
|
if let Some(thread_label) = &request.thread_label {
|
||||||
@@ -1124,6 +1101,8 @@ mod tests {
|
|||||||
use codex_protocol::models::FileSystemPermissions;
|
use codex_protocol::models::FileSystemPermissions;
|
||||||
use codex_protocol::models::NetworkPermissions;
|
use codex_protocol::models::NetworkPermissions;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
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 crossterm::event::KeyModifiers;
|
||||||
use insta::assert_snapshot;
|
use insta::assert_snapshot;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
@@ -2128,12 +2107,30 @@ mod tests {
|
|||||||
content: "one\ntwo\nthree\n".to_string(),
|
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 {
|
let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
|
||||||
thread_id: ThreadId::new(),
|
thread_id: ThreadId::new(),
|
||||||
thread_label: Some("Banach [worker]".to_string()),
|
thread_label: Some("Banach [worker]".to_string()),
|
||||||
id: "test".to_string(),
|
id: "test".to_string(),
|
||||||
reason: None,
|
reason: None,
|
||||||
cwd: absolute_path("/tmp"),
|
cwd: test_path_buf("/tmp").abs(),
|
||||||
changes,
|
changes,
|
||||||
});
|
});
|
||||||
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||||
@@ -2146,10 +2143,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let rendered = render_overlay_lines(&view, /*width*/ 120);
|
let rendered = render_overlay_lines(&view, /*width*/ 120);
|
||||||
assert!(rendered.contains("Thread: Banach [worker]"));
|
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("o to open thread"));
|
||||||
assert!(!rendered.contains("$ apply_patch"));
|
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]
|
#[test]
|
||||||
fn network_exec_prompt_title_includes_host() {
|
fn network_exec_prompt_title_includes_host() {
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ use std::time::Instant;
|
|||||||
|
|
||||||
mod action_required_title;
|
mod action_required_title;
|
||||||
mod app_link_view;
|
mod app_link_view;
|
||||||
|
mod apply_patch_header;
|
||||||
mod approval_overlay;
|
mod approval_overlay;
|
||||||
mod mcp_server_elicitation;
|
mod mcp_server_elicitation;
|
||||||
mod multi_select_picker;
|
mod multi_select_picker;
|
||||||
|
|||||||
+14
@@ -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
|
||||||
+2
-1
@@ -4,7 +4,8 @@ expression: contents
|
|||||||
---
|
---
|
||||||
Would you like to make the following edits?
|
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)
|
› 1. Yes, proceed (y)
|
||||||
2. Yes, and don't ask again for these files (a)
|
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]
|
#[tokio::test]
|
||||||
async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
|
async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.config.cwd = test_path_buf("/tmp/project").abs();
|
||||||
chat.config
|
chat.config
|
||||||
.permissions
|
.permissions
|
||||||
.approval_policy
|
.approval_policy
|
||||||
@@ -1709,7 +1710,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
|
|||||||
turn_id: "turn-approve-patch".into(),
|
turn_id: "turn-approve-patch".into(),
|
||||||
changes,
|
changes,
|
||||||
reason: Some("The model wants to apply changes".into()),
|
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);
|
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");
|
.expect("draw patch approval modal");
|
||||||
let contents = terminal.backend().vt100().screen().contents();
|
let contents = terminal.backend().vt100().screen().contents();
|
||||||
assert!(!contents.contains("$ apply_patch"));
|
assert!(!contents.contains("$ apply_patch"));
|
||||||
assert_chatwidget_snapshot!("approval_modal_patch", contents);
|
assert_chatwidget_snapshot!("approval_modal_patch", normalize_snapshot_paths(contents));
|
||||||
|
|
||||||
Ok(())
|
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(&platform_path, unix_path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
text = text.replace("/tmp/project\\", "/tmp/project/");
|
||||||
|
|
||||||
let platform_test_cwd = test_path_display("/tmp/project");
|
let platform_test_cwd = test_path_display("/tmp/project");
|
||||||
if platform_test_cwd == "/tmp/project" {
|
if platform_test_cwd == "/tmp/project" {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ impl ChatWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_apply_patch_approval_request(
|
pub(crate) fn on_apply_patch_approval_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
_id: String,
|
_id: String,
|
||||||
ev: ApplyPatchApprovalRequestEvent,
|
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
|
||||||
Reference in new issue
Block a user