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
This commit is contained in:
smurching-oai
2026-11-10 07:06:53 +00:00
committed by copyberry
parent c875667eb8
commit 36e074a2fb
29 changed files with 595 additions and 204 deletions
+39 -23
View File
@@ -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<String>,
turn_id: Option<String>,
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<ThreadId>,
category: FeedbackCategory,
include_logs: bool,
result: Result<String, String>,
result: Result<FeedbackReceipt, String>,
) {
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<ThreadId>,
rollout_path: Option<PathBuf>,
category: FeedbackCategory,
reason: Option<String>,
turn_id: Option<String>,
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");
+10 -3
View File
@@ -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,
+75 -16
View File
@@ -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<AppServerPath>,
mut params: FeedbackUploadParams,
feedback: CodexFeedback,
) -> Result<FeedbackUploadResponse> {
destination: FeedbackDestination,
) -> Result<FeedbackReceipt> {
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::<FeedbackUploadResponse>(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<FeedbackReceipt> {
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(),
})
}
@@ -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
+117 -5
View File
@@ -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;
+1 -1
View File
@@ -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<String, String>,
pub(super) result: Result<crate::app_event::FeedbackReceipt, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+19 -3
View File
@@ -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<String>,
turn_id: Option<String>,
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<ThreadId>,
category: FeedbackCategory,
include_logs: bool,
result: Result<String, String>,
result: Result<FeedbackReceipt, String>,
},
/// 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,
+4 -5
View File
@@ -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}"
);
@@ -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<String>,
app_event_tx: AppEventSender,
include_logs: bool,
feedback_audience: FeedbackAudience,
upload_audience: Option<FeedbackAudience>,
can_save: bool,
// UI state
textarea: TextArea,
@@ -68,14 +70,16 @@ impl FeedbackNoteView {
turn_id: Option<String>,
app_event_tx: AppEventSender,
include_logs: bool,
feedback_audience: FeedbackAudience,
upload_audience: Option<FeedbackAudience>,
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)))
@@ -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::<AppEvent>();
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::<AppEvent>();
@@ -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");
+21 -27
View File
@@ -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<std::path::PathBuf>,
auto_review_rollout_filename: Option<String>,
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<Box<dyn crate::render::renderable::Renderable>> = 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::<AppEvent>();
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::<AppEvent>();
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(),
);
+1 -2
View File
@@ -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;
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+26 -3
View File
@@ -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);
+1 -11
View File
@@ -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);
@@ -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
@@ -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
@@ -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
@@ -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"
);
}
@@ -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(),
+1 -1
View File
@@ -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",