From da5492694b1c8488e230ba79aa95238f753b67c7 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 16 Oct 2025 21:03:23 -0700 Subject: [PATCH] Add log upload support (#5257) --- codex-rs/Cargo.lock | 190 ++++++++++++++ codex-rs/Cargo.toml | 5 +- codex-rs/feedback/Cargo.toml | 13 + codex-rs/feedback/src/lib.rs | 231 ++++++++++++++++++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 9 +- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/bottom_pane/feedback_view.rs | 224 +++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 2 + ...ack_view__tests__feedback_view_render.snap | 17 ++ codex-rs/tui/src/chatwidget.rs | 26 ++ codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/lib.rs | 32 ++- codex-rs/tui/src/slash_command.rs | 11 +- 14 files changed, 750 insertions(+), 14 deletions(-) create mode 100644 codex-rs/feedback/Cargo.toml create mode 100644 codex-rs/feedback/src/lib.rs create mode 100644 codex-rs/tui/src/bottom_pane/feedback_view.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 70514dd5..da19466c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1144,6 +1144,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-feedback" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-protocol", + "pretty_assertions", + "sentry", + "tracing-subscriber", +] + [[package]] name = "codex-file-search" version = "0.0.0" @@ -1388,6 +1399,7 @@ dependencies = [ "codex-arg0", "codex-common", "codex-core", + "codex-feedback", "codex-file-search", "codex-git-tooling", "codex-login", @@ -1824,6 +1836,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "debugserver-types" version = "0.5.0" @@ -2302,6 +2324,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "fixed_decimal" version = "0.7.0" @@ -2670,6 +2704,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "http" version = "1.3.1" @@ -4906,6 +4951,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -5220,6 +5274,120 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "sentry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +dependencies = [ + "httpdate", + "native-tls", + "reqwest", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +dependencies = [ + "once_cell", + "rand 0.8.5", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +dependencies = [ + "debugid", + "hex", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.226" @@ -6427,6 +6595,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicase" version = "2.8.1" @@ -6486,6 +6663,19 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.4" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1c1640be..4ca4b7e6 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -6,6 +6,7 @@ members = [ "app-server-protocol", "apply-patch", "arg0", + "feedback", "codex-backend-openapi-models", "cloud-tasks", "cloud-tasks-client", @@ -56,6 +57,7 @@ codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } codex-exec = { path = "exec" } +codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } codex-git-tooling = { path = "git-tooling" } codex-linux-sandbox = { path = "linux-sandbox" } @@ -83,8 +85,8 @@ ansi-to-tui = "7.0.0" anyhow = "1" arboard = "3" askama = "0.12" -assert_matches = "1.5.0" assert_cmd = "2" +assert_matches = "1.5.0" async-channel = "2.3.1" async-stream = "0.3.6" async-trait = "0.1.89" @@ -147,6 +149,7 @@ reqwest = "0.12" rmcp = { version = "0.8.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" +sentry = "0.34.0" serde = "1" serde_json = "1" serde_with = "3.14" diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml new file mode 100644 index 00000000..b104f512 --- /dev/null +++ b/codex-rs/feedback/Cargo.toml @@ -0,0 +1,13 @@ +[package] +edition.workspace = true +name = "codex-feedback" +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-protocol = { workspace = true } +sentry = { version = "0.34" } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs new file mode 100644 index 00000000..285ab066 --- /dev/null +++ b/codex-rs/feedback/src/lib.rs @@ -0,0 +1,231 @@ +use std::collections::VecDeque; +use std::fs; +use std::io::Write; +use std::io::{self}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use codex_protocol::ConversationId; +use tracing_subscriber::fmt::writer::MakeWriter; + +const DEFAULT_MAX_BYTES: usize = 2 * 1024 * 1024; // 2 MiB +const SENTRY_DSN: &str = + "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; +const UPLOAD_TIMEOUT_SECS: u64 = 10; + +#[derive(Clone)] +pub struct CodexFeedback { + inner: Arc, +} + +impl Default for CodexFeedback { + fn default() -> Self { + Self::new() + } +} + +impl CodexFeedback { + pub fn new() -> Self { + Self::with_capacity(DEFAULT_MAX_BYTES) + } + + pub(crate) fn with_capacity(max_bytes: usize) -> Self { + Self { + inner: Arc::new(FeedbackInner::new(max_bytes)), + } + } + + pub fn make_writer(&self) -> FeedbackMakeWriter { + FeedbackMakeWriter { + inner: self.inner.clone(), + } + } + + pub fn snapshot(&self, session_id: Option) -> CodexLogSnapshot { + let bytes = { + let guard = self.inner.ring.lock().expect("mutex poisoned"); + guard.snapshot_bytes() + }; + CodexLogSnapshot { + bytes, + thread_id: session_id + .map(|id| id.to_string()) + .unwrap_or("no-active-thread-".to_string() + &ConversationId::new().to_string()), + } + } +} + +struct FeedbackInner { + ring: Mutex, +} + +impl FeedbackInner { + fn new(max_bytes: usize) -> Self { + Self { + ring: Mutex::new(RingBuffer::new(max_bytes)), + } + } +} + +#[derive(Clone)] +pub struct FeedbackMakeWriter { + inner: Arc, +} + +impl<'a> MakeWriter<'a> for FeedbackMakeWriter { + type Writer = FeedbackWriter; + + fn make_writer(&'a self) -> Self::Writer { + FeedbackWriter { + inner: self.inner.clone(), + } + } +} + +pub struct FeedbackWriter { + inner: Arc, +} + +impl Write for FeedbackWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut guard = self.inner.ring.lock().map_err(|_| io::ErrorKind::Other)?; + guard.push_bytes(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct RingBuffer { + max: usize, + buf: VecDeque, +} + +impl RingBuffer { + fn new(capacity: usize) -> Self { + Self { + max: capacity, + buf: VecDeque::with_capacity(capacity), + } + } + + fn len(&self) -> usize { + self.buf.len() + } + + fn push_bytes(&mut self, data: &[u8]) { + if data.is_empty() { + return; + } + + // If the incoming chunk is larger than capacity, keep only the trailing bytes. + if data.len() >= self.max { + self.buf.clear(); + let start = data.len() - self.max; + self.buf.extend(data[start..].iter().copied()); + return; + } + + // Evict from the front if we would exceed capacity. + let needed = self.len() + data.len(); + if needed > self.max { + let to_drop = needed - self.max; + for _ in 0..to_drop { + let _ = self.buf.pop_front(); + } + } + + self.buf.extend(data.iter().copied()); + } + + fn snapshot_bytes(&self) -> Vec { + self.buf.iter().copied().collect() + } +} + +pub struct CodexLogSnapshot { + bytes: Vec, + pub thread_id: String, +} + +impl CodexLogSnapshot { + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn save_to_temp_file(&self) -> io::Result { + let dir = std::env::temp_dir(); + let filename = format!("codex-feedback-{}.log", self.thread_id); + let path = dir.join(filename); + fs::write(&path, self.as_bytes())?; + Ok(path) + } + + pub fn upload_to_sentry(&self) -> Result<()> { + use std::collections::BTreeMap; + use std::str::FromStr; + use std::sync::Arc; + + use sentry::Client; + use sentry::ClientOptions; + use sentry::protocol::Attachment; + use sentry::protocol::Envelope; + use sentry::protocol::EnvelopeItem; + use sentry::protocol::Event; + use sentry::protocol::Level; + use sentry::transports::DefaultTransportFactory; + use sentry::types::Dsn; + + let client = Client::from_config(ClientOptions { + dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {}", e))?), + transport: Some(Arc::new(DefaultTransportFactory {})), + ..Default::default() + }); + + let tags = BTreeMap::from([(String::from("thread_id"), self.thread_id.to_string())]); + + let event = Event { + level: Level::Error, + message: Some("Codex Log Upload ".to_string() + &self.thread_id), + tags, + ..Default::default() + }; + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Event(event)); + envelope.add_item(EnvelopeItem::Attachment(Attachment { + buffer: self.bytes.clone(), + filename: String::from("codex-logs.log"), + content_type: Some("text/plain".to_string()), + ty: None, + })); + + client.send_envelope(envelope); + client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ring_buffer_drops_front_when_full() { + let fb = CodexFeedback::with_capacity(8); + { + let mut w = fb.make_writer().make_writer(); + w.write_all(b"abcdefgh").unwrap(); + w.write_all(b"ij").unwrap(); + } + let snap = fb.snapshot(None); + // Capacity 8: after writing 10 bytes, we should keep the last 8. + pretty_assertions::assert_eq!(std::str::from_utf8(snap.as_bytes()).unwrap(), "cdefghij"); + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1ba3f086..41ffa310 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -40,6 +40,7 @@ codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-feedback = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diffy = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 11a10d6d..e1c2d062 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -74,12 +74,13 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, - + pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, } impl App { + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, @@ -88,6 +89,7 @@ impl App { initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, + feedback: codex_feedback::CodexFeedback, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -110,6 +112,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + feedback: feedback.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -132,6 +135,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + feedback: feedback.clone(), }; ChatWidget::new_from_existing( init, @@ -158,6 +162,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + feedback: feedback.clone(), pending_update_action: None, }; @@ -236,6 +241,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); @@ -549,6 +555,7 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, } } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 8f889623..b3e948ec 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -339,6 +339,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs new file mode 100644 index 00000000..9c75e32b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -0,0 +1,224 @@ +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; +use crate::history_cell::PlainHistoryCell; +use crate::render::renderable::Renderable; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::path::PathBuf; + +use super::BottomPane; +use super::SelectionAction; +use super::SelectionItem; +use super::SelectionViewParams; + +const BASE_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; + +pub(crate) struct FeedbackView; + +impl FeedbackView { + pub fn show( + bottom_pane: &mut BottomPane, + file_path: PathBuf, + snapshot: codex_feedback::CodexLogSnapshot, + ) { + bottom_pane.show_selection_view(Self::selection_params(file_path, snapshot)); + } + + fn selection_params( + file_path: PathBuf, + snapshot: codex_feedback::CodexLogSnapshot, + ) -> SelectionViewParams { + let header = FeedbackHeader::new(file_path); + + let thread_id = snapshot.thread_id.clone(); + + let upload_action_tread_id = thread_id.clone(); + let upload_action: SelectionAction = Box::new(move |tx: &AppEventSender| { + match snapshot.upload_to_sentry() { + Ok(()) => { + let issue_url = format!( + "{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{upload_action_tread_id}", + ); + tx.send(AppEvent::InsertHistoryCell(Box::new(PlainHistoryCell::new(vec![ + Line::from( + "• Codex logs uploaded. Please open an issue using the following URL:", + ), + "".into(), + Line::from(vec![" ".into(), issue_url.cyan().underlined()]), + "".into(), + Line::from(vec![" Or mention your thread ID ".into(), upload_action_tread_id.clone().bold(), " in an existing issue.".into()]) + ])))); + } + Err(e) => { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!("Failed to upload logs: {e}")), + ))); + } + } + }); + + let upload_item = SelectionItem { + name: "Yes".to_string(), + description: Some( + "Share the current Codex session logs with the team for troubleshooting." + .to_string(), + ), + actions: vec![upload_action], + dismiss_on_select: true, + ..Default::default() + }; + + let no_action: SelectionAction = Box::new(move |tx: &AppEventSender| { + let issue_url = format!("{BASE_ISSUE_URL}&steps=Thread%20ID:%20{thread_id}",); + + tx.send(AppEvent::InsertHistoryCell(Box::new( + PlainHistoryCell::new(vec![ + Line::from("• Please open an issue using the following URL:"), + "".into(), + Line::from(vec![" ".into(), issue_url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + thread_id.clone().bold(), + " in an existing issue.".into(), + ]), + ]), + ))); + }); + + let no_item = SelectionItem { + name: "No".to_string(), + actions: vec![no_action], + dismiss_on_select: true, + ..Default::default() + }; + + let cancel_item = SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }; + + SelectionViewParams { + header: Box::new(header), + items: vec![upload_item, no_item, cancel_item], + ..Default::default() + } + } +} + +struct FeedbackHeader { + file_path: PathBuf, +} + +impl FeedbackHeader { + fn new(file_path: PathBuf) -> Self { + Self { file_path } + } + + fn lines(&self) -> Vec> { + vec![ + Line::from("Do you want to upload logs before reporting issue?".bold()), + "".into(), + Line::from( + "Logs may include the full conversation history of this Codex process, including prompts, tool calls, and their results.", + ), + Line::from( + "These logs are retained for 90 days and are used solely for troubleshooting and diagnostic purposes.", + ), + "".into(), + Line::from(vec![ + "You can review the exact content of the logs before they’re uploaded at:".into(), + ]), + Line::from(self.file_path.display().to_string().dim()), + "".into(), + ] + } +} + +impl Renderable for FeedbackHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + + for (i, line) in self.lines().into_iter().enumerate() { + let y = area.y.saturating_add(i as u16); + if y >= area.y.saturating_add(area.height) { + break; + } + let line_area = Rect::new(area.x, y, area.width, 1).intersection(area); + line.render(line_area, buf); + } + } + + fn desired_height(&self, width: u16) -> u16 { + self.lines() + .iter() + .map(|line| line.desired_height(width)) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::list_selection_view::ListSelectionView; + use crate::style::user_message_style; + use codex_feedback::CodexFeedback; + use codex_protocol::ConversationId; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Color; + use tokio::sync::mpsc::unbounded_channel; + + fn buffer_to_string(buffer: &Buffer) -> String { + (0..buffer.area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..buffer.area.width { + let symbol = buffer[(buffer.area.x + col, buffer.area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect::>() + .join("\n") + } + + #[test] + fn renders_feedback_view_header() { + let (tx_raw, _rx) = unbounded_channel::(); + let app_event_tx = AppEventSender::new(tx_raw); + let snapshot = CodexFeedback::new().snapshot(Some( + ConversationId::from_string("550e8400-e29b-41d4-a716-446655440000").unwrap(), + )); + let file_path = PathBuf::from("/tmp/codex-feedback.log"); + + let params = FeedbackView::selection_params(file_path.clone(), snapshot); + let view = ListSelectionView::new(params, app_event_tx); + + let width = 72; + let height = view.desired_height(width).max(1); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let rendered = + buffer_to_string(&buf).replace(&file_path.display().to_string(), ""); + assert_snapshot!("feedback_view_render", rendered); + + let cell_style = buf[(area.x, area.y)].style(); + let expected_bg = user_message_style().bg.unwrap_or(Color::Reset); + assert_eq!(cell_style.bg.unwrap_or(Color::Reset), expected_bg); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d8c3265f..8d9e84f2 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -27,11 +27,13 @@ mod footer; mod list_selection_view; mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; +mod feedback_view; mod paste_burst; pub mod popup_consts; mod scroll_state; mod selection_popup_common; mod textarea; +pub(crate) use feedback_view::FeedbackView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap new file mode 100644 index 00000000..bafa94b0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- + Do you want to upload logs before reporting issue? + + Logs may include the full conversation history of this Codex process + These logs are retained for 90 days and are used solely for troubles + + You can review the exact content of the logs before they’re uploaded + + + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + 3. Cancel diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 775242a5..f5926953 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -224,6 +224,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, + pub(crate) feedback: codex_feedback::CodexFeedback, } pub(crate) struct ChatWidget { @@ -272,6 +273,8 @@ pub(crate) struct ChatWidget { needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, + // Feedback sink for /feedback + feedback: codex_feedback::CodexFeedback, } struct UserMessage { @@ -917,6 +920,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -963,6 +967,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -980,6 +985,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1028,6 +1034,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -1131,6 +1138,25 @@ impl ChatWidget { return; } match cmd { + SlashCommand::Feedback => { + let snapshot = self.feedback.snapshot(self.conversation_id); + match snapshot.save_to_temp_file() { + Ok(path) => { + crate::bottom_pane::FeedbackView::show( + &mut self.bottom_pane, + path, + snapshot, + ); + self.request_redraw(); + } + Err(e) => { + self.add_to_history(history_cell::new_error_event(format!( + "Failed to save feedback logs: {e}" + ))); + self.request_redraw(); + } + } + } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index adadcd0a..2055e208 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -235,6 +235,7 @@ async fn helpers_are_available_and_do_not_panic() { initial_images: Vec::new(), enhanced_keys_supported: false, auth_manager, + feedback: codex_feedback::CodexFeedback::new(), }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -291,6 +292,7 @@ fn make_chatwidget_manual() -> ( ghost_snapshots_disabled: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback: codex_feedback::CodexFeedback::new(), }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7447ef5f..aa40b05c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -23,6 +23,7 @@ use std::path::PathBuf; use tracing::error; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; +use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; mod app; @@ -219,13 +220,21 @@ pub async fn run_main( }) }; - // Build layered subscriber: let file_layer = tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_target(false) .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); + let feedback = codex_feedback::CodexFeedback::new(); + let targets = Targets::new().with_default(tracing::Level::TRACE); + + let feedback_layer = tracing_subscriber::fmt::layer() + .with_writer(feedback.make_writer()) + .with_ansi(false) + .with_target(false) + .with_filter(targets); + if cli.oss { codex_ollama::ensure_oss_ready(&config) .await @@ -250,15 +259,26 @@ pub async fn run_main( let _ = tracing_subscriber::registry() .with(file_layer) + .with(feedback_layer) .with(otel_layer) .try_init(); } else { - let _ = tracing_subscriber::registry().with(file_layer).try_init(); + let _ = tracing_subscriber::registry() + .with(file_layer) + .with(feedback_layer) + .try_init(); }; - run_ratatui_app(cli, config, overrides, cli_kv_overrides, active_profile) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app( + cli, + config, + overrides, + cli_kv_overrides, + active_profile, + feedback, + ) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -267,6 +287,7 @@ async fn run_ratatui_app( overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, + feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -462,6 +483,7 @@ async fn run_ratatui_app( prompt, images, resume_selection, + feedback, ) .await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 14604a73..22da09f3 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -25,6 +25,7 @@ pub enum SlashCommand { Mcp, Logout, Quit, + Feedback, #[cfg(debug_assertions)] TestApproval, } @@ -33,6 +34,7 @@ impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { + SlashCommand::Feedback => "send logs to maintainers", 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", @@ -72,6 +74,7 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Status | SlashCommand::Mcp + | SlashCommand::Feedback | SlashCommand::Quit => true, #[cfg(debug_assertions)] @@ -85,13 +88,7 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { let show_beta_features = beta_features_enabled(); SlashCommand::iter() - .filter(|cmd| { - if *cmd == SlashCommand::Undo { - show_beta_features - } else { - true - } - }) + .filter(|cmd| *cmd != SlashCommand::Undo || show_beta_features) .map(|c| (c.command(), c)) .collect() }