Add log upload support (#5257)

This commit is contained in:
pakrym-oai
2025-10-16 21:03:23 -07:00
committed by GitHub
parent a5d48a775b
commit da5492694b
14 changed files with 750 additions and 14 deletions

190
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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<FeedbackInner>,
}
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<ConversationId>) -> 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<RingBuffer>,
}
impl FeedbackInner {
fn new(max_bytes: usize) -> Self {
Self {
ring: Mutex::new(RingBuffer::new(max_bytes)),
}
}
}
#[derive(Clone)]
pub struct FeedbackMakeWriter {
inner: Arc<FeedbackInner>,
}
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<FeedbackInner>,
}
impl Write for FeedbackWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
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<u8>,
}
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<u8> {
self.buf.iter().copied().collect()
}
}
pub struct CodexLogSnapshot {
bytes: Vec<u8>,
pub thread_id: String,
}
impl CodexLogSnapshot {
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.bytes
}
pub fn save_to_temp_file(&self) -> io::Result<PathBuf> {
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");
}
}

View File

@@ -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 }

View File

@@ -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<UpdateAction>,
}
impl App {
#[allow(clippy::too_many_arguments)]
pub async fn run(
tui: &mut tui::Tui,
auth_manager: Arc<AuthManager>,
@@ -88,6 +89,7 @@ impl App {
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
resume_selection: ResumeSelection,
feedback: codex_feedback::CodexFeedback,
) -> Result<AppExitInfo> {
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,
}
}

View File

@@ -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);

View File

@@ -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<Line<'static>> {
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 theyre 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::<Vec<_>>()
.join("\n")
}
#[test]
fn renders_feedback_view_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
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(), "<LOG_PATH>");
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);
}
}

View File

@@ -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 {

View File

@@ -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 theyre uploaded
<LOG_PATH>
1. Yes Share the current Codex session logs with the team for
troubleshooting.
2. No
3. Cancel

View File

@@ -224,6 +224,7 @@ pub(crate) struct ChatWidgetInit {
pub(crate) initial_images: Vec<PathBuf>,
pub(crate) enhanced_keys_supported: bool,
pub(crate) auth_manager: Arc<AuthManager>,
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<Option<usize>>,
// 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);
}

View File

@@ -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)
}

View File

@@ -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<String>,
feedback: codex_feedback::CodexFeedback,
) -> color_eyre::Result<AppExitInfo> {
color_eyre::install()?;
@@ -462,6 +483,7 @@ async fn run_ratatui_app(
prompt,
images,
resume_selection,
feedback,
)
.await;

View File

@@ -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()
}