Add log upload support (#5257)
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
224
codex-rs/tui/src/bottom_pane/feedback_view.rs
Normal file
224
codex-rs/tui/src/bottom_pane/feedback_view.rs
Normal 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 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::<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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
<LOG_PATH>
|
||||
|
||||
|
||||
› 1. Yes Share the current Codex session logs with the team for
|
||||
troubleshooting.
|
||||
2. No
|
||||
3. Cancel
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user