From 36e074a2fb365aa788886495c82e6ea612226cd9 Mon Sep 17 00:00:00 2001 From: smurching-oai Date: Tue, 10 Nov 2026 06:54:44 +0000 Subject: [PATCH] Add local diagnostic ZIP saves to the TUI (#60542) ## What changed - Let users press `Ctrl+S` in the report note editor to save a diagnostic ZIP locally without sending it to OpenAI, including when uploads are disabled. Keep `Enter` for sending reports when uploads are enabled. - Apply the attachment consent choice to both destinations, clarify the disclosures, and show the saved archive path on completion. - Require a local app server for ZIP saves, report unsupported servers with an upgrade hint, and stop before collecting a note when no report destination is available. ## Testing Add coverage for ZIP creation with and without log consent, no upload requests on save success or failure, destination availability, keyboard actions, and saved-path receipts. Update report UI snapshots. GitOrigin-RevId: cbc0142988840e5e3f6475f31678915bf25d56a2 --- codex-rs/tui/src/app/background_requests.rs | 62 +++++---- codex-rs/tui/src/app/event_dispatch.rs | 13 +- codex-rs/tui/src/app/feedback_upload.rs | 91 ++++++++++--- ...i__app__tests__feedback_saved_receipt.snap | 5 + codex-rs/tui/src/app/tests.rs | 122 +++++++++++++++++- codex-rs/tui/src/app/thread_events.rs | 2 +- codex-rs/tui/src/app_event.rs | 22 +++- codex-rs/tui/src/app_server_session.rs | 9 +- .../tui/src/bottom_pane/feedback_note_view.rs | 88 +++++++++---- .../bottom_pane/feedback_note_view_tests.rs | 97 +++++++++++--- codex-rs/tui/src/bottom_pane/feedback_view.rs | 48 +++---- codex-rs/tui/src/bottom_pane/mod.rs | 3 +- ..._tests__feedback_disclosure_four_rows.snap | 8 +- ...ests__feedback_disclosure_short_panes.snap | 30 ++--- ...ote_view__tests__feedback_disclosures.snap | 35 +++-- ...view__tests__feedback_multiline_paste.snap | 7 +- ...tests__feedback_note_uploads_disabled.snap | 13 ++ ...k_note_view__tests__feedback_view_bug.snap | 7 +- ...ew__tests__feedback_view_safety_check.snap | 7 +- ...ck_upload_consent_lists_doctor_report.snap | 6 +- ...sts_windows_sandbox_log_when_included.snap | 6 +- codex-rs/tui/src/chatwidget.rs | 29 ++++- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 12 +- ...t__tests__feedback_command_suggestion.snap | 9 ++ ...s__feedback_good_result_consent_popup.snap | 12 +- ..._tests__feedback_upload_consent_popup.snap | 12 +- .../tui/src/chatwidget/tests/app_server.rs | 2 + .../chatwidget/tests/popups_and_settings.rs | 40 +++++- codex-rs/tui/src/slash_command.rs | 2 +- 29 files changed, 595 insertions(+), 204 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__tests__feedback_saved_receipt.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_note_uploads_disabled.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_command_suggestion.snap diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 44577744c1..c9d4585375 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -4,10 +4,12 @@ //! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so //! the main event loop remains single-threaded. -use super::feedback_upload::fetch_feedback_upload; +use super::feedback_upload::deliver_feedback; use super::plugin_mentions::fetch_plugin_mentions; use super::*; use crate::app_event::ConnectorsSnapshot; +use crate::app_event::FeedbackDestination; +use crate::app_event::FeedbackReceipt; use crate::app_info::app_info_from_api; use crate::chatwidget::ThreadUsageOutcome; use crate::config_update::format_config_error; @@ -633,30 +635,34 @@ impl App { reason: Option, turn_id: Option, include_logs: bool, + destination: FeedbackDestination, ) { + if destination == FeedbackDestination::LocalZip && app_server.uses_remote_workspace() { + self.chat_widget.add_info_message( + "Saving a feedback ZIP requires a local app server.".to_string(), + /*hint*/ None, + ); + return; + } let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); let origin_thread_id = self.chat_widget.thread_id(); - let rollout_path = if include_logs { - self.chat_widget.rollout_path() - } else { - None - }; - let params = build_feedback_upload_params( + let params = build_feedback_params( origin_thread_id, - rollout_path, + self.chat_widget.rollout_path(), category, reason, turn_id, include_logs, + destination, ); let codex_home = app_server.codex_home_path(&self.config.codex_home); let feedback = self.feedback.clone(); tokio::spawn(async move { - let result = fetch_feedback_upload(request_handle, codex_home, params, feedback) - .await - .map(|response| response.thread_id) - .map_err(|err| err.to_string()); + let result = + deliver_feedback(request_handle, codex_home, params, feedback, destination) + .await + .map_err(|err| format!("{err:#}")); app_event_tx.send(AppEvent::FeedbackSubmitted { origin_thread_id, category, @@ -668,7 +674,7 @@ impl App { pub(super) fn handle_feedback_thread_event(&mut self, event: FeedbackThreadEvent) { match event.result { - Ok(thread_id) => { + Ok(FeedbackReceipt::Uploaded { thread_id }) => { self.chat_widget .add_to_history(crate::bottom_pane::feedback_success_cell( event.category, @@ -677,11 +683,16 @@ impl App { event.feedback_audience, )) } + Ok(FeedbackReceipt::Saved { path }) => { + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!("Feedback ZIP saved to {path}"), + /*hint*/ None, + )) + } Err(err) => self .chat_widget - .add_to_history(history_cell::new_error_event(format!( - "Failed to upload feedback: {err}" - ))), + .add_to_history(history_cell::new_error_event(err)), } } @@ -724,7 +735,7 @@ impl App { origin_thread_id: Option, category: FeedbackCategory, include_logs: bool, - result: Result, + result: Result, ) { let event = FeedbackThreadEvent { category, @@ -1296,15 +1307,18 @@ pub(super) async fn write_hook_enabled( .wrap_err("config/batchWrite failed while updating hook enablement in TUI") } -pub(super) fn build_feedback_upload_params( +pub(super) fn build_feedback_params( origin_thread_id: Option, rollout_path: Option, category: FeedbackCategory, reason: Option, turn_id: Option, include_logs: bool, + destination: FeedbackDestination, ) -> FeedbackUploadParams { - let extra_log_files = if include_logs { + // The app-server `feedback/save` RPC handler selects persisted transcripts by thread ID. + // The TUI can already know a future rollout path before the first turn writes it. + let extra_log_files = if include_logs && destination == FeedbackDestination::OpenAi { rollout_path.map(|rollout_path| vec![rollout_path]) } else { None @@ -1696,17 +1710,18 @@ mod tests { } #[test] - fn build_feedback_upload_params_includes_thread_id_and_rollout_path() { + fn build_feedback_params_includes_thread_id_and_rollout_path() { let thread_id = ThreadId::new(); let rollout_path = PathBuf::from("/tmp/rollout.jsonl"); - let params = build_feedback_upload_params( + let params = build_feedback_params( Some(thread_id), Some(rollout_path.clone()), FeedbackCategory::SafetyCheck, Some("needs follow-up".to_string()), Some("turn-123".to_string()), /*include_logs*/ true, + FeedbackDestination::OpenAi, ); assert_eq!(params.classification, "safety_check"); @@ -1725,14 +1740,15 @@ mod tests { } #[test] - fn build_feedback_upload_params_omits_rollout_path_without_logs() { - let params = build_feedback_upload_params( + fn build_feedback_params_omits_rollout_path_without_logs() { + let params = build_feedback_params( /*origin_thread_id*/ None, Some(PathBuf::from("/tmp/rollout.jsonl")), FeedbackCategory::GoodResult, /*reason*/ None, /*turn_id*/ None, /*include_logs*/ false, + FeedbackDestination::OpenAi, ); assert_eq!(params.classification, "good_result"); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 13c4ed103a..70700f6fa9 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2703,22 +2703,29 @@ impl App { AppEvent::ApplyPermissionShortcut { thread_id, selection } => { self.apply_permission_shortcut(app_server, thread_id, selection).await; } + AppEvent::OpenFeedback => self.chat_widget.open_feedback(app_server.thread_params_mode()), AppEvent::OpenFeedbackNote { category, include_logs, } => { - self.chat_widget.open_feedback_note(category, include_logs, self.feedback_audience); + self.chat_widget.open_feedback_note( + category, + include_logs, + self.feedback_audience, + !app_server.uses_remote_workspace(), + ); } AppEvent::OpenFeedbackConsent { category } => { - self.chat_widget.open_feedback_consent(category); + self.chat_widget.open_feedback_consent(category, !app_server.uses_remote_workspace()); } AppEvent::SubmitFeedback { category, reason, turn_id, include_logs, + destination, } => { - self.submit_feedback(app_server, category, reason, turn_id, include_logs); + self.submit_feedback(app_server, category, reason, turn_id, include_logs, destination); } AppEvent::FeedbackSubmitted { origin_thread_id, diff --git a/codex-rs/tui/src/app/feedback_upload.rs b/codex-rs/tui/src/app/feedback_upload.rs index 13ba9b9d10..b342082473 100644 --- a/codex-rs/tui/src/app/feedback_upload.rs +++ b/codex-rs/tui/src/app/feedback_upload.rs @@ -1,14 +1,20 @@ -//! Stage bounded TUI diagnostics on the app-server host for a single feedback upload. -//! Log consent gates all staging; cleanup runs after staging or upload failures too. +//! Stage consented TUI diagnostics for feedback uploads and local ZIP saves. +use crate::app_event::FeedbackDestination; +use crate::app_event::FeedbackReceipt; use crate::app_server_session::fs::AppServerFileSystem; +use crate::app_server_session::is_method_unsupported; use codex_app_server_client::AppServerPath; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::FeedbackSaveParams; +use codex_app_server_protocol::FeedbackSaveResponse; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::RequestId; use codex_feedback::CodexFeedback; +use codex_utils_path_uri::LegacyAppPathString; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use color_eyre::eyre::eyre; @@ -17,25 +23,30 @@ use uuid::Uuid; // Base64 adds only a third to this size, keeping fs/writeFile below the 16 MiB frame limit. const MAX_CLIENT_LOG_SIZE: usize = 1024 * 1024; +const FEEDBACK_DIR: &str = "feedback"; -pub(super) async fn fetch_feedback_upload( +pub(super) async fn deliver_feedback( request_handle: AppServerRequestHandle, codex_home: Option, mut params: FeedbackUploadParams, feedback: CodexFeedback, -) -> Result { + destination: FeedbackDestination, +) -> Result { let fs = AppServerFileSystem { request_handle }; - let staging_dir = codex_home.filter(|_| params.include_logs).map(|home| { - home.join("tmp") - .join(format!("tui-feedback-{}", Uuid::new_v4())) - }); + let staging_dir = codex_home + .as_ref() + .filter(|_| params.include_logs) + .map(|home| { + home.join("tmp") + .join(format!("tui-feedback-{}", Uuid::new_v4())) + }); if params.include_logs { let staged = async { let directory = staging_dir.as_ref().ok_or_else(|| { eyre!("App server did not report $CODEX_HOME; cannot stage feedback logs") })?; - // These RPCs use the app-server's local filesystem, where feedback/upload - // reads extraLogFiles, independently of the thread's executor environment. + // These RPCs use the app-server's local filesystem, where both feedback methods + // read extraLogFiles, independently of the thread's executor environment. fs.fs_create_directory_all_path(directory).await?; let path = directory.join("client-logs.txt"); let mut logs = feedback @@ -60,12 +71,22 @@ pub(super) async fn fetch_feedback_upload( } } } - let request_id = RequestId::String(format!("feedback-upload-{}", Uuid::new_v4())); - let result = fs - .request_handle - .request_typed(ClientRequest::FeedbackUpload { request_id, params }) - .await - .wrap_err("feedback/upload failed in TUI"); + let result = match destination { + FeedbackDestination::OpenAi => fs + .request_handle + .request_typed::(ClientRequest::FeedbackUpload { + request_id: RequestId::String(format!("feedback-upload-{}", Uuid::new_v4())), + params, + }) + .await + .map(|response| FeedbackReceipt::Uploaded { + thread_id: response.thread_id, + }) + .wrap_err("Failed to upload feedback"), + FeedbackDestination::LocalZip => save_feedback_zip(&fs, codex_home.as_ref(), params) + .await + .wrap_err("Failed to save feedback ZIP"), + }; if let Some(directory) = staging_dir && let Err(err) = fs.fs_remove_path(&directory).await @@ -74,3 +95,41 @@ pub(super) async fn fetch_feedback_upload( } result } + +async fn save_feedback_zip( + fs: &AppServerFileSystem, + codex_home: Option<&AppServerPath>, + params: FeedbackUploadParams, +) -> Result { + let home = codex_home + .ok_or_else(|| eyre!("The app server did not report its Codex home directory"))?; + let directory = home.join(FEEDBACK_DIR); + fs.fs_create_directory_all_path(&directory) + .await + .wrap_err_with(|| format!("Could not create the feedback directory at {directory}"))?; + let id = Uuid::new_v4(); + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%SZ"); + let suffix = id.as_fields().0; + let path = directory.join(format!("codex-diagnostics-{timestamp}-{suffix:08x}.zip")); + let response: FeedbackSaveResponse = fs + .request_handle + .request_typed(ClientRequest::FeedbackSave { + request_id: RequestId::String(format!("feedback-save-{id}")), + params: FeedbackSaveParams { + feedback: params, + save_path: LegacyAppPathString::from_string(path.as_str()), + }, + }) + .await + .map_err(|err| match &err { + TypedRequestError::Server { source, .. } + if is_method_unsupported(source, "feedback/save") => + { + eyre!("Update the Codex server to save a feedback ZIP") + } + _ => color_eyre::Report::from(err), + })?; + Ok(FeedbackReceipt::Saved { + path: response.saved_path.to_string(), + }) +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__feedback_saved_receipt.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__feedback_saved_receipt.snap new file mode 100644 index 0000000000..630102f031 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__feedback_saved_receipt.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests.rs +expression: next_history_message(&mut events) +--- +• Feedback ZIP saved to /home/dev/.codex/feedback/codex-diagnostics-2026-11-09T23-53-00Z-a1b2c3d4.zip diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 4864e46ce1..f39a812d76 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4958,7 +4958,9 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() origin_thread_id: Some(side_thread_id), category: FeedbackCategory::Bug, include_logs: false, - result: Ok("uploaded-thread".to_string()), + result: Ok(crate::app_event::FeedbackReceipt::Uploaded { + thread_id: "uploaded-thread".to_string(), + }), }); app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id) .await?; @@ -10002,23 +10004,25 @@ async fn feedback_submission_stages_logs_cleans_up_and_emits_error_history_cell( if staging_fails { std::fs::write(&tmp, "not a directory")?; } - let mut params = background_requests::build_feedback_upload_params( + let mut params = background_requests::build_feedback_params( /*origin_thread_id*/ None, Some(PathBuf::from("existing-rollout.jsonl")), FeedbackCategory::Bug, /*reason*/ None, /*turn_id*/ None, include_logs, + crate::app_event::FeedbackDestination::OpenAi, ); // Reject before network upload, after the client has staged its diagnostics. params.thread_id = Some("invalid-thread-id".into()); - let error = feedback_upload::fetch_feedback_upload( + let error = feedback_upload::deliver_feedback( server.request_handle(), Some(AppServerPath::from_app_server( home.path().display().to_string(), )), params, app.feedback.clone(), + crate::app_event::FeedbackDestination::OpenAi, ) .await .unwrap_err(); @@ -10071,7 +10075,7 @@ async fn feedback_submission_stages_logs_cleans_up_and_emits_error_history_cell( /*origin_thread_id*/ None, FeedbackCategory::Bug, /*include_logs*/ true, - Err("boom".to_string()), + Err("Failed to upload feedback: boom".to_string()), ) .await; @@ -10086,6 +10090,112 @@ async fn feedback_submission_stages_logs_cleans_up_and_emits_error_history_cell( Ok(()) } +#[tokio::test] +async fn local_feedback_saves_a_zip_with_only_consented_logs_and_never_uploads() -> Result<()> { + use crate::app_event::FeedbackDestination; + use crate::app_event::FeedbackReceipt; + + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let (server, requests, proxy) = + session_lifecycle_requests::start_recording_remote_app_server(&app.config).await?; + // A queued local save must also respect the connection active at dispatch. + app.submit_feedback( + &server, + FeedbackCategory::Bug, + /*reason*/ None, + /*turn_id*/ None, + /*include_logs*/ true, + FeedbackDestination::LocalZip, + ); + assert!(next_history_message(&mut events).contains("requires a local app server")); + + let home = tempdir()?; + let save = |directory: &std::path::Path, include_logs| { + let params = background_requests::build_feedback_params( + /*origin_thread_id*/ None, + Some(directory.join("rollout-not-yet-created.jsonl")), + FeedbackCategory::Bug, + Some("Review before sharing".into()), + /*turn_id*/ None, + include_logs, + FeedbackDestination::LocalZip, + ); + feedback_upload::deliver_feedback( + server.request_handle(), + Some(AppServerPath::from_app_server( + directory.display().to_string(), + )), + params, + app.feedback.clone(), + FeedbackDestination::LocalZip, + ) + }; + for include_logs in [true, false] { + let receipt = save(home.path(), include_logs).await?; + let FeedbackReceipt::Saved { path } = receipt else { + panic!("expected a saved ZIP"); + }; + let path = PathBuf::from(path); + assert_eq!(path.parent(), Some(home.path().join("feedback").as_path())); + assert_eq!(path.extension().and_then(|ext| ext.to_str()), Some("zip")); + let name = path.file_stem().unwrap().to_str().unwrap(); + let (timestamp, suffix) = name + .strip_prefix("codex-diagnostics-") + .unwrap() + .rsplit_once('-') + .unwrap(); + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H-%M-%SZ")?; + assert_eq!(suffix.len(), 8); + u32::from_str_radix(suffix, 16)?; + assert!(std::fs::read(&path)?.starts_with(b"PK")); + let recorded = std::mem::take(&mut *requests.lock().unwrap()); + let save = recorded + .iter() + .find(|r| r.method == "feedback/save") + .unwrap(); + let save: codex_app_server_protocol::FeedbackSaveParams = + serde_json::from_value(save.params.clone().unwrap())?; + assert_eq!(save.feedback.include_logs, include_logs); + assert_eq!( + recorded.iter().any(|r| r.method == "fs/writeFile"), + include_logs + ); + assert!(!recorded.iter().any(|r| r.method == "feedback/upload")); + } + // A destination this server cannot write must never turn into an upload. + let invalid_home = home.path().join("plain-file"); + std::fs::write(&invalid_home, "not a directory")?; + let error = save(&invalid_home, /*include_logs*/ false) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.starts_with("Failed to save feedback ZIP: Could not create the feedback directory") + ); + assert!(message.contains("fs/createDirectory")); + let recorded = std::mem::take(&mut *requests.lock().unwrap()); + assert!(!recorded.iter().any(|r| r.method == "feedback/upload")); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn feedback_saved_receipt_shows_the_full_archive_path() { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + app.handle_feedback_submitted( + /*origin_thread_id*/ None, + FeedbackCategory::Bug, + /*include_logs*/ false, + Ok(crate::app_event::FeedbackReceipt::Saved { + path: "/home/dev/.codex/feedback/codex-diagnostics-2026-11-09T23-53-00Z-a1b2c3d4.zip" + .into(), + }), + ) + .await; + insta::assert_snapshot!("feedback_saved_receipt", next_history_message(&mut events)); +} + #[tokio::test] async fn feedback_submission_for_inactive_thread_replays_into_origin_thread() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -10117,7 +10227,9 @@ async fn feedback_submission_for_inactive_thread_replays_into_origin_thread() { Some(origin_thread_id), FeedbackCategory::Bug, /*include_logs*/ true, - Ok("uploaded-thread".to_string()), + Ok(crate::app_event::FeedbackReceipt::Uploaded { + thread_id: "uploaded-thread".to_string(), + }), ) .await; diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index fec1b59fb2..108da7b311 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -84,7 +84,7 @@ pub(super) struct FeedbackThreadEvent { pub(super) category: FeedbackCategory, pub(super) include_logs: bool, pub(super) feedback_audience: FeedbackAudience, - pub(super) result: Result, + pub(super) result: Result, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a60bfdafa4..113e4a4c0c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1771,13 +1771,16 @@ pub(crate) enum AppEvent { crate::bottom_pane::user_verification::UserVerificationRequest, ), + /// Open feedback for the active connection. + OpenFeedback, + /// Open the feedback note entry overlay after the user selects a category. OpenFeedbackNote { category: FeedbackCategory, include_logs: bool, }, - /// Open the upload consent popup for feedback after selecting a category. + /// Open the attachment consent popup for feedback after selecting a category. OpenFeedbackConsent { category: FeedbackCategory, }, @@ -1788,14 +1791,15 @@ pub(crate) enum AppEvent { reason: Option, turn_id: Option, include_logs: bool, + destination: FeedbackDestination, }, - /// Result of a feedback upload request initiated by the TUI. + /// Delivery result for the thread that requested feedback. FeedbackSubmitted { origin_thread_id: Option, category: FeedbackCategory, include_logs: bool, - result: Result, + result: Result, }, /// Launch the external editor after a normal draw has completed. @@ -1981,6 +1985,18 @@ pub(crate) enum FeedbackCategory { Other, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FeedbackDestination { + OpenAi, + LocalZip, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FeedbackReceipt { + Uploaded { thread_id: String }, + Saved { path: String }, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum SideStartMode { Reuse, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 0bb59cda7a..73351e555f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -334,10 +334,9 @@ pub(crate) async fn request_thread_start_with_history_fallback( } } -fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool { +pub(crate) fn is_method_unsupported(source: &JSONRPCErrorError, method: &str) -> bool { source.code == JSONRPC_METHOD_NOT_FOUND - || (source.code == JSONRPC_INVALID_REQUEST - && source.message.contains(THREAD_SETTINGS_UPDATE_METHOD)) + || (source.code == JSONRPC_INVALID_REQUEST && source.message.contains(method)) } /// Data collected during the TUI bootstrap phase that the main event loop @@ -1736,7 +1735,7 @@ impl AppServerSession { { Ok(_) => Ok(true), Err(TypedRequestError::Server { source, .. }) - if is_thread_settings_update_unsupported(&source) => + if is_method_unsupported(&source, THREAD_SETTINGS_UPDATE_METHOD) => { // Older remote app servers can reject this experimental method as // method-not-found, experimental-capability-gated, or an unknown @@ -3504,7 +3503,7 @@ mod tests { message: message.to_string(), }; assert_eq!( - is_thread_settings_update_unsupported(&source), + is_method_unsupported(&source, THREAD_SETTINGS_UPDATE_METHOD), expected, "{message}" ); diff --git a/codex-rs/tui/src/bottom_pane/feedback_note_view.rs b/codex-rs/tui/src/bottom_pane/feedback_note_view.rs index 488876f5b8..f9eefe9229 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_note_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_note_view.rs @@ -1,6 +1,6 @@ //! Collects a feedback note with audience-specific disclosure and independent log consent. //! The editor and submission controls remain visible when the disclosure must scroll. -//! Unbracketed paste bursts insert newlines; Enter submits after the burst ends. +//! Ctrl+S saves on local connections. Enter sends when uploads are enabled and the paste burst has ended. use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -20,6 +20,7 @@ use std::time::Instant; use crate::app_event::AppEvent; use crate::app_event::FeedbackCategory; +use crate::app_event::FeedbackDestination; use crate::app_event_sender::AppEventSender; use crate::key_hint; use crate::key_hint::has_ctrl_or_alt; @@ -32,10 +33,10 @@ use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; use super::feedback_view::FeedbackAudience; use super::paste_burst::PasteBurst; -use super::popup_consts::accept_cancel_hint_line; use super::textarea::TextArea; use super::textarea::TextAreaState; +const SAVE_LOCALLY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('s')); const FEEDBACK_POLICY_URL: &str = "https://help.openai.com/en/articles/5722486-how-your-data-is-used-to-improve-model-performance"; const EMPLOYEE_FEEDBACK_FAQ_URL: &str = "https://go/codex-employee-feedback-faq"; @@ -51,7 +52,8 @@ pub(crate) struct FeedbackNoteView { turn_id: Option, app_event_tx: AppEventSender, include_logs: bool, - feedback_audience: FeedbackAudience, + upload_audience: Option, + can_save: bool, // UI state textarea: TextArea, @@ -68,14 +70,16 @@ impl FeedbackNoteView { turn_id: Option, app_event_tx: AppEventSender, include_logs: bool, - feedback_audience: FeedbackAudience, + upload_audience: Option, + can_save: bool, ) -> Self { Self { category, turn_id, app_event_tx, include_logs, - feedback_audience, + upload_audience, + can_save, textarea: TextArea::new(), textarea_state: RefCell::new(TextAreaState::default()), paste_burst: PasteBurst::default(), @@ -85,7 +89,7 @@ impl FeedbackNoteView { } } - fn submit(&mut self) { + fn submit(&mut self, destination: FeedbackDestination) { let note = self.textarea.text().trim().to_string(); let reason = if note.is_empty() { None } else { Some(note) }; self.app_event_tx.send(AppEvent::SubmitFeedback { @@ -93,12 +97,18 @@ impl FeedbackNoteView { reason, turn_id: self.turn_id.clone(), include_logs: self.include_logs, + destination, }); self.complete = true; } fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) { match key_event { + key if SAVE_LOCALLY.is_press(key) => { + if self.can_save { + self.submit(FeedbackDestination::LocalZip); + } + } KeyEvent { code: KeyCode::PageUp, .. @@ -132,10 +142,10 @@ impl FeedbackNoteView { if self.paste_burst.direct_insert_newline_should_insert(now) { self.paste_burst.extend_window(now); self.textarea.insert_str("\n"); - } else if modifiers == KeyModifiers::NONE { - self.submit(); - } else { + } else if modifiers != KeyModifiers::NONE { self.textarea.input(key_event); + } else if self.upload_audience.is_some() { + self.submit(FeedbackDestination::OpenAi); } } KeyEvent { @@ -261,11 +271,13 @@ impl Renderable for FeedbackNoteView { buf, ); } - let disclosure_url = match self.feedback_audience { - FeedbackAudience::OpenAiEmployee => EMPLOYEE_FEEDBACK_FAQ_URL, - FeedbackAudience::External => FEEDBACK_POLICY_URL, - }; - mark_underlined_hyperlink(buf, intro_area, disclosure_url); + if let Some(audience) = self.upload_audience { + let url = match audience { + FeedbackAudience::OpenAiEmployee => EMPLOYEE_FEEDBACK_FAQ_URL, + FeedbackAudience::External => FEEDBACK_POLICY_URL, + }; + mark_underlined_hyperlink(buf, intro_area, url); + } // Input line if input_area.width >= 2 { @@ -331,13 +343,28 @@ impl Renderable for FeedbackNoteView { let hint_y = hint_blank_y.saturating_add(hint_spacing); if hint_y < area.y.saturating_add(area.height) { - Paragraph::new(accept_cancel_hint_line( - Some(key_hint::plain(KeyCode::Enter).into()), - "to submit", - Some(key_hint::plain(KeyCode::Esc).into()), - "to cancel", - )) - .render( + let action_line = |save: &str, send: &str| { + let mut actions = Vec::new(); + if self.can_save { + actions.push((SAVE_LOCALLY.display_label(), save.to_owned())); + } + if self.upload_audience.is_some() { + actions.push(( + key_hint::plain(KeyCode::Enter).display_label(), + send.to_owned(), + )); + } + actions.push(( + key_hint::plain(KeyCode::Esc).display_label(), + "cancel".into(), + )); + super::footer::footer_hint_items_line(&actions) + }; + let mut hints = action_line("save ZIP", "send to OpenAI"); + if hints.width() > usize::from(area.width) { + hints = action_line("save", "send"); + } + Paragraph::new(hints).render( Rect { x: area.x, y: hint_y, @@ -379,10 +406,11 @@ impl FeedbackNoteView { IntroContent::Full => vec![title.bold().into(), "".into()], IntroContent::DisclosureOnly => Vec::new(), }; - match self.feedback_audience { - FeedbackAudience::OpenAiEmployee => { + match self.upload_audience { + None => lines.push("Feedback uploads are disabled.".dim().into()), + Some(FeedbackAudience::OpenAiEmployee) => { lines.push( - "Your data may be used to improve our models and products" + "Feedback sent to OpenAI may be used to improve our models and products" .bold() .into(), ); @@ -392,12 +420,20 @@ impl FeedbackNoteView { ". Please avoid sharing tented information under NDA, or privileged, HR-related, or sensitive personal information.".dim(), ])); } - FeedbackAudience::External => lines.push(Line::from(vec![ - "Your feedback can be used to improve ChatGPT. ".dim(), + Some(FeedbackAudience::External) => lines.push(Line::from(vec![ + "Feedback sent to OpenAI can be used to improve ChatGPT. ".dim(), "Learn more".fg(crate::style::accent_color()).underlined(), ".".dim(), ])), } + if self.can_save { + let disclosure = if self.upload_audience.is_some() { + "You can also save feedback to a ZIP file, which is not sent to OpenAI." + } else { + "You can save feedback to a ZIP file, which is not sent to OpenAI." + }; + lines.push(disclosure.dim().into()); + } word_wrap_lines( lines, RtOptions::new(usize::from(width.max(1))) diff --git a/codex-rs/tui/src/bottom_pane/feedback_note_view_tests.rs b/codex-rs/tui/src/bottom_pane/feedback_note_view_tests.rs index b00ec3f0d1..7136352c9f 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_note_view_tests.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_note_view_tests.rs @@ -43,7 +43,8 @@ fn make_view(category: FeedbackCategory) -> FeedbackNoteView { /*turn_id*/ None, tx, /*include_logs*/ true, - FeedbackAudience::External, + Some(FeedbackAudience::External), + /*can_save*/ true, ) } @@ -92,7 +93,7 @@ fn feedback_disclosure_matches_audience_at_normal_and_narrow_widths() { ), ] { let mut view = make_view(FeedbackCategory::Bug); - view.feedback_audience = audience; + view.upload_audience = Some(audience); for width in [80, 40] { let area = Rect::new( /*x*/ 0, @@ -116,6 +117,12 @@ fn feedback_disclosure_matches_audience_at_normal_and_narrow_widths() { )); } } + let mut remote_view = make_view(FeedbackCategory::Bug); + remote_view.can_save = false; + rendered.push(format!( + "remote server (80 columns)\n{}", + render(&remote_view, /*width*/ 80) + )); insta::assert_snapshot!("feedback_disclosures", rendered.join("\n\n")); } @@ -124,7 +131,7 @@ fn feedback_disclosure_scrolls_without_hiding_input_in_short_panes() { let mut rendered = Vec::new(); for (width, height) in [(40, 12), (80, 8)] { let mut view = make_view(FeedbackCategory::Bug); - view.feedback_audience = FeedbackAudience::OpenAiEmployee; + view.upload_audience = Some(FeedbackAudience::OpenAiEmployee); view.handle_paste("A visible note.".to_string()); let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); for (stage, key) in [ @@ -139,7 +146,7 @@ fn feedback_disclosure_scrolls_without_hiding_input_in_short_panes() { view.render(area, &mut buf); let text = render_buffer(area, &buf); assert!(text.contains("A visible note.")); - assert!(text.contains("enter to submit")); + assert!(text.contains("enter send")); assert!( view.cursor_pos(area) .is_some_and(|(x, y)| x < width && y < height) @@ -157,7 +164,7 @@ fn feedback_disclosure_scrolls_without_hiding_input_in_short_panes() { #[test] fn feedback_disclosure_keeps_editor_visible_in_four_rows() { let mut view = make_view(FeedbackCategory::Bug); - view.feedback_audience = FeedbackAudience::OpenAiEmployee; + view.upload_audience = Some(FeedbackAudience::OpenAiEmployee); view.handle_paste("A visible note.".to_string()); let area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, @@ -170,14 +177,20 @@ fn feedback_disclosure_keeps_editor_visible_in_four_rows() { .is_some_and(|(x, y)| area.contains((x, y).into())) ); let initial = render_buffer(area, &buf); - assert!(initial.contains("Your data may be used to improve our models and products")); + assert!( + initial.contains("Feedback sent to OpenAI may be used to improve our models and products") + ); + let mut saw_sensitive_information_notice = false; for _ in view.intro_lines(area.width, IntroContent::Full) { view.handle_key_event(KeyEvent::from(KeyCode::PageDown)); buf = Buffer::empty(area); view.render(area, &mut buf); + saw_sensitive_information_notice |= + render_buffer(area, &buf).contains("personal information."); } let scrolled = render_buffer(area, &buf); - assert!(scrolled.contains("personal information.")); + assert!(saw_sensitive_information_notice); + assert!(scrolled.contains("which is not sent to OpenAI")); insta::assert_snapshot!( "feedback_disclosure_four_rows", format!("{initial}\n\n{scrolled}") @@ -186,15 +199,14 @@ fn feedback_disclosure_keeps_editor_visible_in_four_rows() { let narrow_area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4, ); - for (audience, disclosure) in [ - (FeedbackAudience::OpenAiEmployee, "Your data"), - (FeedbackAudience::External, "Your feedback"), - ] { + for audience in [FeedbackAudience::OpenAiEmployee, FeedbackAudience::External] { let mut view = make_view(FeedbackCategory::SafetyCheck); - view.feedback_audience = audience; + view.upload_audience = Some(audience); let mut buf = Buffer::empty(narrow_area); view.render(narrow_area, &mut buf); - assert!(render_buffer(narrow_area, &buf).contains(disclosure)); + let text = render_buffer(narrow_area, &buf); + assert!(text.contains("Feedback sent to")); + assert!(text.contains("save · enter send")); } } @@ -208,7 +220,8 @@ fn feedback_paste_burst_preserves_multiline_note_until_submit() { /*turn_id*/ None, AppEventSender::new(tx), include_logs, - FeedbackAudience::External, + Some(FeedbackAudience::External), + /*can_save*/ true, ); let now = Instant::now(); for (index, ch) in note.chars().enumerate() { @@ -240,6 +253,7 @@ fn feedback_paste_burst_preserves_multiline_note_until_submit() { reason, turn_id, include_logs: submitted_logs, + destination: FeedbackDestination::OpenAi, } = rx.try_recv().expect("submit feedback event") else { panic!("expected feedback submission"); @@ -270,7 +284,8 @@ fn feedback_bracketed_paste_does_not_reach_chat_composer() { /*turn_id*/ None, tx, include_logs, - FeedbackAudience::External, + Some(FeedbackAudience::External), + /*can_save*/ true, ))); // An explicit paste ends any preceding keystroke burst. pane.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); @@ -298,7 +313,7 @@ fn feedback_bracketed_paste_does_not_reach_chat_composer() { } #[test] -fn submit_feedback_emits_submit_event_with_trimmed_note() { +fn remote_save_shortcut_keeps_the_note_for_explicit_upload() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut view = FeedbackNoteView::new( @@ -306,9 +321,13 @@ fn submit_feedback_emits_submit_event_with_trimmed_note() { Some("turn-123".to_string()), tx, /*include_logs*/ true, - FeedbackAudience::OpenAiEmployee, + Some(FeedbackAudience::OpenAiEmployee), + /*can_save*/ false, ); view.textarea.insert_str(" something broke "); + view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(!view.is_complete()); + assert!(rx.try_recv().is_err()); view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -320,6 +339,7 @@ fn submit_feedback_emits_submit_event_with_trimmed_note() { reason: Some(reason), turn_id: Some(turn_id), include_logs: true, + destination: FeedbackDestination::OpenAi, } if reason == "something broke" && turn_id == "turn-123" )); assert_eq!(view.is_complete(), true); @@ -334,10 +354,11 @@ fn submit_feedback_omits_empty_note() { /*turn_id*/ None, tx, /*include_logs*/ false, - FeedbackAudience::External, + Some(FeedbackAudience::External), + /*can_save*/ true, ); - view.submit(); + view.handle_key_event(KeyCode::Enter.into()); let event = rx.try_recv().expect("submit feedback event"); assert!(matches!( @@ -347,10 +368,45 @@ fn submit_feedback_omits_empty_note() { reason: None, turn_id: None, include_logs: false, + destination: FeedbackDestination::OpenAi, } )); } +#[test] +fn save_shortcut_keeps_note_and_consent_with_or_without_uploads() { + for audience in [None, Some(FeedbackAudience::External)] { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut view = FeedbackNoteView::new( + FeedbackCategory::Bug, + Some("turn-123".into()), + AppEventSender::new(tx), + /*include_logs*/ false, + audience, + /*can_save*/ true, + ); + view.handle_paste("Review before sharing".into()); + if audience.is_none() { + insta::assert_snapshot!( + "feedback_note_uploads_disabled", + render(&view, /*width*/ 60) + ); + view.handle_key_event(KeyCode::Enter.into()); + assert!(!view.is_complete()); + assert!(rx.try_recv().is_err()); + } + view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(matches!(rx.try_recv(), Ok(AppEvent::SubmitFeedback { + destination: FeedbackDestination::LocalZip, + category: FeedbackCategory::Bug, + reason: Some(note), + turn_id: Some(turn), + include_logs: false, + }) if note == "Review before sharing" && turn == "turn-123")); + assert!(view.is_complete()); + } +} + #[test] fn feedback_note_cancel_does_not_submit() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -359,7 +415,8 @@ fn feedback_note_cancel_does_not_submit() { /*turn_id*/ None, AppEventSender::new(tx_raw), /*include_logs*/ false, - FeedbackAudience::OpenAiEmployee, + Some(FeedbackAudience::OpenAiEmployee), + /*can_save*/ true, ); view.textarea.insert_str("unfinished feedback"); diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index f576f13bb5..40be9d92f0 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -6,6 +6,8 @@ use codex_feedback::FeedbackDiagnostics; use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; use crate::app_event::AppEvent; use crate::app_event::FeedbackCategory; @@ -182,21 +184,6 @@ pub(crate) fn feedback_selection_params( } } -/// Build the selection popup params shown when feedback is disabled. -pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams { - super::SelectionViewParams { - title: Some("Sending feedback is disabled".to_string()), - subtitle: Some("This action is disabled by configuration.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items: vec![super::SelectionItem { - name: "Close".to_string(), - dismiss_on_select: true, - ..Default::default() - }], - ..super::SelectionViewParams::picker() - } -} - fn make_feedback_item( app_event_tx: AppEventSender, name: &str, @@ -215,16 +202,16 @@ fn make_feedback_item( } } -/// Build the upload consent popup params for a given feedback category. -pub(crate) fn feedback_upload_consent_params( +/// Choose optional attachments before saving or sending the report. +pub(crate) fn feedback_consent_params( app_event_tx: AppEventSender, category: FeedbackCategory, rollout_path: Option, auto_review_rollout_filename: Option, include_windows_sandbox_log: bool, + can_save: bool, feedback_diagnostics: &FeedbackDiagnostics, ) -> super::SelectionViewParams { - use super::popup_consts::standard_popup_hint_line; let yes_action: super::SelectionAction = Box::new({ let tx = app_event_tx.clone(); move |sender: &AppEventSender| { @@ -247,11 +234,18 @@ pub(crate) fn feedback_upload_consent_params( } }); - // Build header listing files that would be sent if user consents. + let disclosure = if can_save { + "Saving a ZIP does not submit feedback to OpenAI.\nFiles may contain prompts, commands, and tool output:" + } else { + "Files may contain prompts, commands, and tool output:" + }; let mut header_lines: Vec> = vec![ - Line::from("Upload logs?".bold()).into(), + Line::from("Include logs and session history?".bold()).into(), Line::from("").into(), - Line::from("The following files will be sent:".dim()).into(), + Paragraph::new(disclosure) + .dim() + .wrap(Wrap { trim: false }) + .into(), Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), Line::from(vec![" • ".into(), "client-logs.txt (if available)".into()]).into(), Line::from(vec![ @@ -313,16 +307,14 @@ pub(crate) fn feedback_upload_consent_params( items: vec![ super::SelectionItem { name: "Yes".to_string(), - description: Some( - "Share the current Codex session logs and diagnostics with the team for troubleshooting" - .to_string(), - ), + description: Some("Include session logs and diagnostics in the report".to_string()), actions: vec![yes_action], dismiss_on_select: true, ..Default::default() }, super::SelectionItem { name: "No".to_string(), + description: Some("Keep only your note and report metadata".to_string()), actions: vec![no_action], dismiss_on_select: true, ..Default::default() @@ -398,12 +390,13 @@ mod tests { fn feedback_upload_consent_lists_doctor_report() { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let params = feedback_upload_consent_params( + let params = feedback_consent_params( tx, FeedbackCategory::Bug, Some(std::path::PathBuf::from("rollout.jsonl")), Some("auto-review-rollout.jsonl".to_string()), /*include_windows_sandbox_log*/ false, + /*can_save*/ true, &FeedbackDiagnostics::default(), ); @@ -416,12 +409,13 @@ mod tests { fn feedback_upload_consent_lists_windows_sandbox_log_when_included() { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let params = feedback_upload_consent_params( + let params = feedback_consent_params( tx, FeedbackCategory::Bug, Some(std::path::PathBuf::from("rollout.jsonl")), Some("auto-review-rollout.jsonl".to_string()), /*include_windows_sandbox_log*/ true, + /*can_save*/ true, &FeedbackDiagnostics::default(), ); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c1d8257b64..de92ccf921 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -199,10 +199,9 @@ mod feedback_view; mod hooks_browser_view; pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_classification; -pub(crate) use feedback_view::feedback_disabled_params; +pub(crate) use feedback_view::feedback_consent_params; pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_success_cell; -pub(crate) use feedback_view::feedback_upload_consent_params; pub(crate) use skills_toggle_view::SkillsToggleItem; pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_four_rows.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_four_rows.snap index 78d4b6dc19..331a8e2618 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_four_rows.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_four_rows.snap @@ -2,12 +2,12 @@ source: tui/src/bottom_pane/feedback_note_view_tests.rs expression: "format!(\"{initial}\\n\\n{scrolled}\")" --- -▌ Your data may be used to improve our models and products +▌ Feedback sent to OpenAI may be used to improve our models and products ▌ ▌ A visible note. -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel -▌ sensitive personal information. +▌ You can also save feedback to a ZIP file, which is not sent to OpenAI. ▌ ▌ A visible note. -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_short_panes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_short_panes.snap index c3a5555208..b04af11a1b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_short_panes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_short_panes.snap @@ -5,8 +5,8 @@ expression: "rendered.join(\"\\n\\n\")" 40x12 initial ▌ Tell us more (bug) ▌ -▌ Your data may be used to improve our -▌ models and products +▌ Feedback sent to OpenAI may be used to +▌ improve our models and products ▌ By submitting feedback, you agree that ▌ OpenAI can use your feedback for ▌ safety purposes and internal model @@ -14,27 +14,27 @@ expression: "rendered.join(\"\\n\\n\")" ▌ ▌ A visible note. pgup/pgdn scroll disclosure -Press enter to submit or esc to cancel +⌃s save · enter send · esc cancel 40x12 scrolled -▌ By submitting feedback, you agree that -▌ OpenAI can use your feedback for ▌ safety purposes and internal model ▌ training, as explained in more detail ▌ here. Please avoid sharing tented ▌ information under NDA, or privileged, ▌ HR-related, or sensitive personal ▌ information. +▌ You can also save feedback to a ZIP +▌ file, which is not sent to OpenAI. ▌ ▌ A visible note. pgup/pgdn scroll disclosure -Press enter to submit or esc to cancel +⌃s save · enter send · esc cancel 40x12 restored ▌ Tell us more (bug) ▌ -▌ Your data may be used to improve our -▌ models and products +▌ Feedback sent to OpenAI may be used to +▌ improve our models and products ▌ By submitting feedback, you agree that ▌ OpenAI can use your feedback for ▌ safety purposes and internal model @@ -42,34 +42,34 @@ Press enter to submit or esc to cancel ▌ ▌ A visible note. pgup/pgdn scroll disclosure -Press enter to submit or esc to cancel +⌃s save · enter send · esc cancel 80x8 initial ▌ Tell us more (bug) ▌ -▌ Your data may be used to improve our models and products +▌ Feedback sent to OpenAI may be used to improve our models and products ▌ By submitting feedback, you agree that OpenAI can use your feedback for safety ▌ ▌ A visible note. pgup/pgdn scroll disclosure -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel 80x8 scrolled -▌ By submitting feedback, you agree that OpenAI can use your feedback for safety ▌ purposes and internal model training, as explained in more detail here. Please ▌ avoid sharing tented information under NDA, or privileged, HR-related, or ▌ sensitive personal information. +▌ You can also save feedback to a ZIP file, which is not sent to OpenAI. ▌ ▌ A visible note. pgup/pgdn scroll disclosure -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel 80x8 restored ▌ Tell us more (bug) ▌ -▌ Your data may be used to improve our models and products +▌ Feedback sent to OpenAI may be used to improve our models and products ▌ By submitting feedback, you agree that OpenAI can use your feedback for safety ▌ ▌ A visible note. pgup/pgdn scroll disclosure -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosures.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosures.snap index 8440474e66..29de768120 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosures.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosures.snap @@ -5,40 +5,44 @@ expression: "rendered.join(\"\\n\\n\")" external (80 columns) ▌ Tell us more (bug) ▌ -▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ Feedback sent to OpenAI can be used to improve ChatGPT. Learn more. +▌ You can also save feedback to a ZIP file, which is not sent to OpenAI. ▌ ▌ (optional) Write a short description to help us further -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel external (40 columns) ▌ Tell us more (bug) ▌ -▌ Your feedback can be used to improve -▌ ChatGPT. Learn more. +▌ Feedback sent to OpenAI can be used to +▌ improve ChatGPT. Learn more. +▌ You can also save feedback to a ZIP +▌ file, which is not sent to OpenAI. ▌ ▌ (optional) Write a short description t -Press enter to submit or esc to cancel +⌃s save · enter send · esc cancel employee (80 columns) ▌ Tell us more (bug) ▌ -▌ Your data may be used to improve our models and products +▌ Feedback sent to OpenAI may be used to improve our models and products ▌ By submitting feedback, you agree that OpenAI can use your feedback for safety ▌ purposes and internal model training, as explained in more detail here. Please ▌ avoid sharing tented information under NDA, or privileged, HR-related, or ▌ sensitive personal information. +▌ You can also save feedback to a ZIP file, which is not sent to OpenAI. ▌ ▌ (optional) Write a short description to help us further -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel employee (40 columns) ▌ Tell us more (bug) ▌ -▌ Your data may be used to improve our -▌ models and products +▌ Feedback sent to OpenAI may be used to +▌ improve our models and products ▌ By submitting feedback, you agree that ▌ OpenAI can use your feedback for ▌ safety purposes and internal model @@ -47,7 +51,18 @@ employee (40 columns) ▌ information under NDA, or privileged, ▌ HR-related, or sensitive personal ▌ information. +▌ You can also save feedback to a ZIP +▌ file, which is not sent to OpenAI. ▌ ▌ (optional) Write a short description t -Press enter to submit or esc to cancel +⌃s save · enter send · esc cancel + +remote server (80 columns) +▌ Tell us more (bug) +▌ +▌ Feedback sent to OpenAI can be used to improve ChatGPT. Learn more. +▌ +▌ (optional) Write a short description to help us further + +enter send to OpenAI · esc cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_multiline_paste.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_multiline_paste.snap index 4b2ff023a1..d4fc186531 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_multiline_paste.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_multiline_paste.snap @@ -4,9 +4,12 @@ expression: "render(&view, 60)" --- ▌ Tell us more (bug) ▌ -▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ Feedback sent to OpenAI can be used to improve ChatGPT. +▌ Learn more. +▌ You can also save feedback to a ZIP file, which is not +▌ sent to OpenAI. ▌ ▌ foo ▌ bar -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_note_uploads_disabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_note_uploads_disabled.snap new file mode 100644 index 0000000000..dccf2aa78b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_note_uploads_disabled.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: "render(&view, 60)" +--- +▌ Tell us more (bug) +▌ +▌ Feedback uploads are disabled. +▌ You can save feedback to a ZIP file, which is not sent to +▌ OpenAI. +▌ +▌ Review before sharing + +⌃s save ZIP · esc cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bug.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bug.snap index 2183ee6105..947bb5e6dd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bug.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bug.snap @@ -4,8 +4,11 @@ expression: rendered --- ▌ Tell us more (bug) ▌ -▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ Feedback sent to OpenAI can be used to improve ChatGPT. +▌ Learn more. +▌ You can also save feedback to a ZIP file, which is not +▌ sent to OpenAI. ▌ ▌ (optional) Write a short description to help us further -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_safety_check.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_safety_check.snap index faee63e0e4..a59e2b8436 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_safety_check.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_safety_check.snap @@ -4,8 +4,11 @@ expression: rendered --- ▌ Tell us more (safety check) ▌ -▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ Feedback sent to OpenAI can be used to improve ChatGPT. +▌ Learn more. +▌ You can also save feedback to a ZIP file, which is not +▌ sent to OpenAI. ▌ ▌ (optional) Share what was refused and why it should have b -Press enter to submit or esc to cancel +⌃s save ZIP · enter send to OpenAI · esc cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap index bbc1737568..26639260b3 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap @@ -1,11 +1,11 @@ --- source: tui/src/bottom_pane/feedback_view.rs -assertion_line: 728 expression: rendered --- -Upload logs? +Include logs and session history? -The following files will be sent: +Saving a ZIP does not submit feedback to OpenAI. +Files may contain prompts, commands, and tool output: • codex-logs.log • client-logs.txt (if available) • codex-doctor-report.json diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap index edb8291a8e..9e9ef83a17 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap @@ -1,11 +1,11 @@ --- source: tui/src/bottom_pane/feedback_view.rs -assertion_line: 746 expression: rendered --- -Upload logs? +Include logs and session history? -The following files will be sent: +Saving a ZIP does not submit feedback to OpenAI. +Files may contain prompts, commands, and tool output: • codex-logs.log • client-logs.txt (if available) • codex-doctor-report.json diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0eb1bb5db6..8e4a882272 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1001,18 +1001,36 @@ impl ChatWidget { } } + pub(crate) fn open_feedback(&mut self, mode: crate::ThreadParamsMode) { + use crate::ThreadParamsMode; + let unavailable = match mode { + ThreadParamsMode::Cloud => "Feedback is unavailable in Cloud threads.", + ThreadParamsMode::Remote if !self.config.feedback_enabled => { + "Feedback uploads are disabled, and saving a ZIP requires a local app server." + } + _ => { + return self.show_selection_view(crate::bottom_pane::feedback_selection_params( + self.app_event_tx.clone(), + )); + } + }; + self.add_info_message(unavailable.to_string(), /*hint*/ None); + } + pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, include_logs: bool, feedback_audience: crate::bottom_pane::FeedbackAudience, + can_save: bool, ) { let view = crate::bottom_pane::FeedbackNoteView::new( category, self.turn_lifecycle.last_turn_id.clone(), self.app_event_tx.clone(), include_logs, - feedback_audience, + self.config.feedback_enabled.then_some(feedback_audience), + can_save, ); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); @@ -1054,7 +1072,11 @@ impl ChatWidget { } } - pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { + pub(crate) fn open_feedback_consent( + &mut self, + category: crate::app_event::FeedbackCategory, + can_save: bool, + ) { let snapshot = self.feedback.snapshot(self.thread_id); #[cfg(target_os = "windows")] let include_windows_sandbox_log = @@ -1064,13 +1086,14 @@ impl ChatWidget { .is_file(); #[cfg(not(target_os = "windows"))] let include_windows_sandbox_log = false; - let params = crate::bottom_pane::feedback_upload_consent_params( + let params = crate::bottom_pane::feedback_consent_params( self.app_event_tx.clone(), category, self.current_rollout_path.clone(), self.thread_id .map(|thread_id| format!("auto-review-rollout-{thread_id}.jsonl")), include_windows_sandbox_log, + can_save, snapshot.feedback_diagnostics(), ); self.bottom_pane.show_selection_view(params); diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index ee1c21bd40..d28b12f6e3 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -239,17 +239,7 @@ impl ChatWidget { }); } 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()); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); + self.app_event_tx.send(AppEvent::OpenFeedback); } SlashCommand::New => { self.show_session_checkout_picker(ManagedWorktreeMode::New, /*name*/ None); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_command_suggestion.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_command_suggestion.snap new file mode 100644 index 0000000000..0f5e1805ba --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_command_suggestion.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))" +--- +› /feedback send logs to maintainers or save a ZIP + +› /feedback + + GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap index ff69171a61..0cc35621ff 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap @@ -2,10 +2,11 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- - Upload logs? + Include logs and session history? › As - The following files will be sent: - GP • codex-logs.log + Saving a ZIP does not submit feedback to OpenAI. + GP Files may contain prompts, commands, and tool output: + • codex-logs.log • client-logs.txt (if available) • codex-doctor-report.json • codex-apps-tools-cache.json (if available) @@ -14,8 +15,7 @@ expression: popup • codex-connectivity-diagnostics.txt - › 1. Yes Share the current Codex session logs and diagnostics with the - team for troubleshooting - 2. No + › 1. Yes Include session logs and diagnostics in the report + 2. No Keep only your note and report metadata enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index 99a9916b3f..db57a4e8c9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -2,10 +2,11 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- - Upload logs? + Include logs and session history? › As - The following files will be sent: - GP • codex-logs.log + Saving a ZIP does not submit feedback to OpenAI. + GP Files may contain prompts, commands, and tool output: + • codex-logs.log • client-logs.txt (if available) • codex-doctor-report.json • codex-apps-tools-cache.json (if available) @@ -19,8 +20,7 @@ expression: popup - HTTPS_PROXY = hello - › 1. Yes Share the current Codex session logs and diagnostics with the - team for troubleshooting - 2. No + › 1. Yes Include session logs and diagnostics in the report + 2. No Keep only your note and report metadata enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index e8db7d8f8c..8107d9a524 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1048,6 +1048,7 @@ async fn live_app_server_turn_started_sets_feedback_turn_id() { crate::app_event::FeedbackCategory::Bug, /*include_logs*/ false, crate::bottom_pane::FeedbackAudience::External, + /*can_save*/ true, ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -1058,6 +1059,7 @@ async fn live_app_server_turn_started_sets_feedback_turn_id() { reason: None, turn_id: Some(turn_id), include_logs: false, + destination: crate::app_event::FeedbackDestination::OpenAi, }) if turn_id == "turn-1" ); } diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 6a5d4e044f..28238ca8dd 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -4318,25 +4318,54 @@ async fn auto_model_advertising_advanced_effort_opens_reasoning_picker() { #[tokio::test] async fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - // Open the feedback category selection popup via slash command. - chat.dispatch_command(SlashCommand::Feedback); + chat.bottom_pane + .set_composer_text("/feedback".into(), Vec::new(), Vec::new()); + assert_chatwidget_snapshot!( + "feedback_command_suggestion", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + ); + chat.handle_key_event(KeyCode::Enter.into()); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenFeedback)) + ); + chat.open_feedback(crate::ThreadParamsMode::Embedded); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("feedback_selection_popup", popup); } +#[tokio::test] +async fn feedback_stops_before_collecting_a_note_when_no_destination_is_available() { + use crate::ThreadParamsMode; + + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.open_feedback(ThreadParamsMode::Cloud); + chat.config.feedback_enabled = false; + chat.open_feedback(ThreadParamsMode::Remote); + + assert!(chat.bottom_pane.no_modal_or_popup_active()); + let messages = lines_to_single_string(&drain_insert_history(&mut rx).concat()); + insta::assert_snapshot!(messages.trim(), @" + • Feedback is unavailable in Cloud threads. + + • Feedback uploads are disabled, and saving a ZIP requires a local app server. + "); +} + #[tokio::test] async fn feedback_upload_consent_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.show_selection_view(crate::bottom_pane::feedback_upload_consent_params( + chat.show_selection_view(crate::bottom_pane::feedback_consent_params( chat.app_event_tx.clone(), crate::app_event::FeedbackCategory::Bug, chat.current_rollout_path.clone(), Some("auto-review-rollout-thread-1.jsonl".to_string()), /*include_windows_sandbox_log*/ true, + /*can_save*/ true, &codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), @@ -4352,12 +4381,13 @@ async fn feedback_upload_consent_popup_snapshot() { async fn feedback_good_result_consent_popup_includes_connectivity_diagnostics_filename() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.show_selection_view(crate::bottom_pane::feedback_upload_consent_params( + chat.show_selection_view(crate::bottom_pane::feedback_consent_params( chat.app_event_tx.clone(), crate::app_event::FeedbackCategory::GoodResult, chat.current_rollout_path.clone(), Some("auto-review-rollout-thread-1.jsonl".to_string()), /*include_windows_sandbox_log*/ false, + /*can_save*/ true, &codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index dd614df096..42f90a6b4c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -93,7 +93,7 @@ impl SlashCommand { pub fn description(self) -> &'static str { match self { SlashCommand::Config => "configure Codex preferences", - SlashCommand::Feedback => "send logs to maintainers", + SlashCommand::Feedback => "send logs to maintainers or save a ZIP", SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",