2025-10-24 22:28:14 -07:00
|
|
|
|
use std::cell::RefCell;
|
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
2025-10-16 21:03:23 -07:00
|
|
|
|
use ratatui::buffer::Buffer;
|
|
|
|
|
|
use ratatui::layout::Rect;
|
|
|
|
|
|
use ratatui::style::Stylize;
|
|
|
|
|
|
use ratatui::text::Line;
|
2025-10-24 22:28:14 -07:00
|
|
|
|
use ratatui::text::Span;
|
|
|
|
|
|
use ratatui::widgets::Clear;
|
|
|
|
|
|
use ratatui::widgets::Paragraph;
|
|
|
|
|
|
use ratatui::widgets::StatefulWidgetRef;
|
|
|
|
|
|
use ratatui::widgets::Widget;
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
use crate::app_event::AppEvent;
|
|
|
|
|
|
use crate::app_event::FeedbackCategory;
|
|
|
|
|
|
use crate::app_event_sender::AppEventSender;
|
|
|
|
|
|
use crate::history_cell;
|
|
|
|
|
|
use crate::render::renderable::Renderable;
|
|
|
|
|
|
|
|
|
|
|
|
use super::CancellationEvent;
|
|
|
|
|
|
use super::bottom_pane_view::BottomPaneView;
|
|
|
|
|
|
use super::popup_consts::standard_popup_hint_line;
|
|
|
|
|
|
use super::textarea::TextArea;
|
|
|
|
|
|
use super::textarea::TextAreaState;
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
|
|
|
|
|
const BASE_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml";
|
|
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
/// Minimal input overlay to collect an optional feedback note, then upload
|
|
|
|
|
|
/// both logs and rollout with classification + metadata.
|
|
|
|
|
|
pub(crate) struct FeedbackNoteView {
|
|
|
|
|
|
category: FeedbackCategory,
|
|
|
|
|
|
snapshot: codex_feedback::CodexLogSnapshot,
|
|
|
|
|
|
rollout_path: Option<PathBuf>,
|
|
|
|
|
|
app_event_tx: AppEventSender,
|
|
|
|
|
|
include_logs: bool,
|
|
|
|
|
|
|
|
|
|
|
|
// UI state
|
|
|
|
|
|
textarea: TextArea,
|
|
|
|
|
|
textarea_state: RefCell<TextAreaState>,
|
|
|
|
|
|
complete: bool,
|
|
|
|
|
|
}
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
impl FeedbackNoteView {
|
|
|
|
|
|
pub(crate) fn new(
|
|
|
|
|
|
category: FeedbackCategory,
|
2025-10-16 21:03:23 -07:00
|
|
|
|
snapshot: codex_feedback::CodexLogSnapshot,
|
2025-10-24 22:28:14 -07:00
|
|
|
|
rollout_path: Option<PathBuf>,
|
|
|
|
|
|
app_event_tx: AppEventSender,
|
|
|
|
|
|
include_logs: bool,
|
|
|
|
|
|
) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
category,
|
|
|
|
|
|
snapshot,
|
|
|
|
|
|
rollout_path,
|
|
|
|
|
|
app_event_tx,
|
|
|
|
|
|
include_logs,
|
|
|
|
|
|
textarea: TextArea::new(),
|
|
|
|
|
|
textarea_state: RefCell::new(TextAreaState::default()),
|
|
|
|
|
|
complete: false,
|
|
|
|
|
|
}
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
fn submit(&mut self) {
|
|
|
|
|
|
let note = self.textarea.text().trim().to_string();
|
|
|
|
|
|
let reason_opt = if note.is_empty() {
|
|
|
|
|
|
None
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Some(note.as_str())
|
|
|
|
|
|
};
|
|
|
|
|
|
let rollout_path_ref = self.rollout_path.as_deref();
|
|
|
|
|
|
let classification = feedback_classification(self.category);
|
|
|
|
|
|
|
|
|
|
|
|
let mut thread_id = self.snapshot.thread_id.clone();
|
|
|
|
|
|
|
2025-10-24 23:07:40 -07:00
|
|
|
|
let result = self.snapshot.upload_feedback(
|
|
|
|
|
|
classification,
|
|
|
|
|
|
reason_opt,
|
|
|
|
|
|
self.include_logs,
|
|
|
|
|
|
if self.include_logs {
|
|
|
|
|
|
rollout_path_ref
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-10-24 22:28:14 -07:00
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
|
let issue_url = format!("{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}");
|
|
|
|
|
|
let prefix = if self.include_logs {
|
|
|
|
|
|
"• Feedback uploaded."
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"• Feedback recorded (no logs)."
|
|
|
|
|
|
};
|
|
|
|
|
|
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
|
|
|
|
history_cell::PlainHistoryCell::new(vec![
|
|
|
|
|
|
Line::from(format!(
|
|
|
|
|
|
"{prefix} Please open an issue using the following URL:"
|
|
|
|
|
|
)),
|
2025-10-16 21:03:23 -07:00
|
|
|
|
"".into(),
|
|
|
|
|
|
Line::from(vec![" ".into(), issue_url.cyan().underlined()]),
|
|
|
|
|
|
"".into(),
|
2025-10-24 22:28:14 -07:00
|
|
|
|
Line::from(vec![
|
|
|
|
|
|
" Or mention your thread ID ".into(),
|
|
|
|
|
|
std::mem::take(&mut thread_id).bold(),
|
|
|
|
|
|
" in an existing issue.".into(),
|
|
|
|
|
|
]),
|
|
|
|
|
|
]),
|
|
|
|
|
|
)));
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
|
|
|
|
history_cell::new_error_event(format!("Failed to upload feedback: {e}")),
|
|
|
|
|
|
)));
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
2025-10-24 22:28:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
self.complete = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
impl BottomPaneView for FeedbackNoteView {
|
|
|
|
|
|
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
|
|
|
|
|
match key_event {
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Esc, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
self.on_ctrl_c();
|
|
|
|
|
|
}
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Enter,
|
|
|
|
|
|
modifiers: KeyModifiers::NONE,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
self.submit();
|
|
|
|
|
|
}
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Enter,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
self.textarea.input(key_event);
|
|
|
|
|
|
}
|
|
|
|
|
|
other => {
|
|
|
|
|
|
self.textarea.input(other);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
|
|
|
|
|
self.complete = true;
|
|
|
|
|
|
CancellationEvent::Handled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn is_complete(&self) -> bool {
|
|
|
|
|
|
self.complete
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn handle_paste(&mut self, pasted: String) -> bool {
|
|
|
|
|
|
if pasted.is_empty() {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.textarea.insert_str(&pasted);
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
2025-11-05 09:50:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Renderable for FeedbackNoteView {
|
|
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
|
|
|
|
1u16 + self.input_height(width) + 3u16
|
|
|
|
|
|
}
|
2025-10-24 22:28:14 -07:00
|
|
|
|
|
|
|
|
|
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
|
|
|
|
|
if area.height < 2 || area.width <= 2 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
|
|
|
|
|
if text_area_height == 0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let top_line_count = 1u16; // title only
|
|
|
|
|
|
let textarea_rect = Rect {
|
|
|
|
|
|
x: area.x.saturating_add(2),
|
|
|
|
|
|
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
|
|
|
|
|
width: area.width.saturating_sub(2),
|
|
|
|
|
|
height: text_area_height,
|
2025-10-16 21:03:23 -07:00
|
|
|
|
};
|
2025-10-24 22:28:14 -07:00
|
|
|
|
let state = *self.textarea_state.borrow();
|
|
|
|
|
|
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
|
|
|
|
if area.height == 0 || area.width == 0 {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let (title, placeholder) = feedback_title_and_placeholder(self.category);
|
|
|
|
|
|
let input_height = self.input_height(area.width);
|
|
|
|
|
|
|
|
|
|
|
|
// Title line
|
|
|
|
|
|
let title_area = Rect {
|
|
|
|
|
|
x: area.x,
|
|
|
|
|
|
y: area.y,
|
|
|
|
|
|
width: area.width,
|
|
|
|
|
|
height: 1,
|
2025-10-16 21:03:23 -07:00
|
|
|
|
};
|
2025-10-24 22:28:14 -07:00
|
|
|
|
let title_spans: Vec<Span<'static>> = vec![gutter(), title.bold()];
|
|
|
|
|
|
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
// Input line
|
|
|
|
|
|
let input_area = Rect {
|
|
|
|
|
|
x: area.x,
|
|
|
|
|
|
y: area.y.saturating_add(1),
|
|
|
|
|
|
width: area.width,
|
|
|
|
|
|
height: input_height,
|
2025-10-16 21:03:23 -07:00
|
|
|
|
};
|
2025-10-24 22:28:14 -07:00
|
|
|
|
if input_area.width >= 2 {
|
|
|
|
|
|
for row in 0..input_area.height {
|
|
|
|
|
|
Paragraph::new(Line::from(vec![gutter()])).render(
|
|
|
|
|
|
Rect {
|
|
|
|
|
|
x: input_area.x,
|
|
|
|
|
|
y: input_area.y.saturating_add(row),
|
|
|
|
|
|
width: 2,
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
buf,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let text_area_height = input_area.height.saturating_sub(1);
|
|
|
|
|
|
if text_area_height > 0 {
|
|
|
|
|
|
if input_area.width > 2 {
|
|
|
|
|
|
let blank_rect = Rect {
|
|
|
|
|
|
x: input_area.x.saturating_add(2),
|
|
|
|
|
|
y: input_area.y,
|
|
|
|
|
|
width: input_area.width.saturating_sub(2),
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
Clear.render(blank_rect, buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
let textarea_rect = Rect {
|
|
|
|
|
|
x: input_area.x.saturating_add(2),
|
|
|
|
|
|
y: input_area.y.saturating_add(1),
|
|
|
|
|
|
width: input_area.width.saturating_sub(2),
|
|
|
|
|
|
height: text_area_height,
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut state = self.textarea_state.borrow_mut();
|
|
|
|
|
|
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
|
|
|
|
|
if self.textarea.text().is_empty() {
|
|
|
|
|
|
Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let hint_blank_y = input_area.y.saturating_add(input_height);
|
|
|
|
|
|
if hint_blank_y < area.y.saturating_add(area.height) {
|
|
|
|
|
|
let blank_area = Rect {
|
|
|
|
|
|
x: area.x,
|
|
|
|
|
|
y: hint_blank_y,
|
|
|
|
|
|
width: area.width,
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
Clear.render(blank_area, buf);
|
|
|
|
|
|
}
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
let hint_y = hint_blank_y.saturating_add(1);
|
|
|
|
|
|
if hint_y < area.y.saturating_add(area.height) {
|
|
|
|
|
|
Paragraph::new(standard_popup_hint_line()).render(
|
|
|
|
|
|
Rect {
|
|
|
|
|
|
x: area.x,
|
|
|
|
|
|
y: hint_y,
|
|
|
|
|
|
width: area.width,
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
buf,
|
|
|
|
|
|
);
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
impl FeedbackNoteView {
|
|
|
|
|
|
fn input_height(&self, width: u16) -> u16 {
|
|
|
|
|
|
let usable_width = width.saturating_sub(2);
|
|
|
|
|
|
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
|
|
|
|
|
|
text_height.saturating_add(1).min(9)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn gutter() -> Span<'static> {
|
|
|
|
|
|
"▌ ".cyan()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) {
|
|
|
|
|
|
match category {
|
|
|
|
|
|
FeedbackCategory::BadResult => (
|
|
|
|
|
|
"Tell us more (bad result)".to_string(),
|
|
|
|
|
|
"(optional) Write a short description to help us further".to_string(),
|
|
|
|
|
|
),
|
|
|
|
|
|
FeedbackCategory::GoodResult => (
|
|
|
|
|
|
"Tell us more (good result)".to_string(),
|
|
|
|
|
|
"(optional) Write a short description to help us further".to_string(),
|
|
|
|
|
|
),
|
|
|
|
|
|
FeedbackCategory::Bug => (
|
|
|
|
|
|
"Tell us more (bug)".to_string(),
|
|
|
|
|
|
"(optional) Write a short description to help us further".to_string(),
|
|
|
|
|
|
),
|
|
|
|
|
|
FeedbackCategory::Other => (
|
|
|
|
|
|
"Tell us more (other)".to_string(),
|
|
|
|
|
|
"(optional) Write a short description to help us further".to_string(),
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
fn feedback_classification(category: FeedbackCategory) -> &'static str {
|
|
|
|
|
|
match category {
|
|
|
|
|
|
FeedbackCategory::BadResult => "bad_result",
|
|
|
|
|
|
FeedbackCategory::GoodResult => "good_result",
|
|
|
|
|
|
FeedbackCategory::Bug => "bug",
|
|
|
|
|
|
FeedbackCategory::Other => "other",
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
2025-10-24 22:28:14 -07:00
|
|
|
|
}
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
// Build the selection popup params for feedback categories.
|
|
|
|
|
|
pub(crate) fn feedback_selection_params(
|
|
|
|
|
|
app_event_tx: AppEventSender,
|
|
|
|
|
|
) -> super::SelectionViewParams {
|
|
|
|
|
|
super::SelectionViewParams {
|
|
|
|
|
|
title: Some("How was this?".to_string()),
|
|
|
|
|
|
items: vec![
|
|
|
|
|
|
make_feedback_item(
|
|
|
|
|
|
app_event_tx.clone(),
|
|
|
|
|
|
"bug",
|
|
|
|
|
|
"Crash, error message, hang, or broken UI/behavior.",
|
|
|
|
|
|
FeedbackCategory::Bug,
|
|
|
|
|
|
),
|
|
|
|
|
|
make_feedback_item(
|
|
|
|
|
|
app_event_tx.clone(),
|
|
|
|
|
|
"bad result",
|
|
|
|
|
|
"Output was off-target, incorrect, incomplete, or unhelpful.",
|
|
|
|
|
|
FeedbackCategory::BadResult,
|
|
|
|
|
|
),
|
|
|
|
|
|
make_feedback_item(
|
|
|
|
|
|
app_event_tx.clone(),
|
|
|
|
|
|
"good result",
|
|
|
|
|
|
"Helpful, correct, high‑quality, or delightful result worth celebrating.",
|
|
|
|
|
|
FeedbackCategory::GoodResult,
|
2025-10-16 21:03:23 -07:00
|
|
|
|
),
|
2025-10-24 22:28:14 -07:00
|
|
|
|
make_feedback_item(
|
|
|
|
|
|
app_event_tx,
|
|
|
|
|
|
"other",
|
|
|
|
|
|
"Slowness, feature suggestion, UX feedback, or anything else.",
|
|
|
|
|
|
FeedbackCategory::Other,
|
2025-10-16 21:03:23 -07:00
|
|
|
|
),
|
2025-10-24 22:28:14 -07:00
|
|
|
|
],
|
|
|
|
|
|
..Default::default()
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
fn make_feedback_item(
|
|
|
|
|
|
app_event_tx: AppEventSender,
|
|
|
|
|
|
name: &str,
|
|
|
|
|
|
description: &str,
|
|
|
|
|
|
category: FeedbackCategory,
|
|
|
|
|
|
) -> super::SelectionItem {
|
|
|
|
|
|
let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| {
|
|
|
|
|
|
app_event_tx.send(AppEvent::OpenFeedbackConsent { category });
|
|
|
|
|
|
});
|
|
|
|
|
|
super::SelectionItem {
|
|
|
|
|
|
name: name.to_string(),
|
|
|
|
|
|
description: Some(description.to_string()),
|
|
|
|
|
|
actions: vec![action],
|
|
|
|
|
|
dismiss_on_select: true,
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Build the upload consent popup params for a given feedback category.
|
|
|
|
|
|
pub(crate) fn feedback_upload_consent_params(
|
|
|
|
|
|
app_event_tx: AppEventSender,
|
|
|
|
|
|
category: FeedbackCategory,
|
2025-10-24 23:07:40 -07:00
|
|
|
|
rollout_path: Option<std::path::PathBuf>,
|
2025-10-24 22:28:14 -07:00
|
|
|
|
) -> 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| {
|
|
|
|
|
|
let _ = sender;
|
|
|
|
|
|
tx.send(AppEvent::OpenFeedbackNote {
|
|
|
|
|
|
category,
|
|
|
|
|
|
include_logs: true,
|
|
|
|
|
|
});
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
2025-10-24 22:28:14 -07:00
|
|
|
|
});
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
let no_action: super::SelectionAction = Box::new({
|
|
|
|
|
|
let tx = app_event_tx;
|
|
|
|
|
|
move |sender: &AppEventSender| {
|
|
|
|
|
|
let _ = sender;
|
|
|
|
|
|
tx.send(AppEvent::OpenFeedbackNote {
|
|
|
|
|
|
category,
|
|
|
|
|
|
include_logs: false,
|
|
|
|
|
|
});
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
2025-10-24 22:28:14 -07:00
|
|
|
|
});
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 23:07:40 -07:00
|
|
|
|
// Build header listing files that would be sent if user consents.
|
|
|
|
|
|
let mut header_lines: Vec<Box<dyn crate::render::renderable::Renderable>> = vec![
|
|
|
|
|
|
Line::from("Upload logs?".bold()).into(),
|
|
|
|
|
|
Line::from("").into(),
|
|
|
|
|
|
Line::from("The following files will be sent:".dim()).into(),
|
|
|
|
|
|
Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(),
|
|
|
|
|
|
];
|
|
|
|
|
|
if let Some(path) = rollout_path.as_deref()
|
|
|
|
|
|
&& let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string())
|
|
|
|
|
|
{
|
|
|
|
|
|
header_lines.push(Line::from(vec![" • ".into(), name.into()]).into());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
super::SelectionViewParams {
|
|
|
|
|
|
footer_hint: Some(standard_popup_hint_line()),
|
|
|
|
|
|
items: vec![
|
|
|
|
|
|
super::SelectionItem {
|
|
|
|
|
|
name: "Yes".to_string(),
|
|
|
|
|
|
description: Some(
|
|
|
|
|
|
"Share the current Codex session logs with the team for troubleshooting."
|
|
|
|
|
|
.to_string(),
|
|
|
|
|
|
),
|
|
|
|
|
|
actions: vec![yes_action],
|
|
|
|
|
|
dismiss_on_select: true,
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
super::SelectionItem {
|
|
|
|
|
|
name: "No".to_string(),
|
|
|
|
|
|
description: Some("".to_string()),
|
|
|
|
|
|
actions: vec![no_action],
|
|
|
|
|
|
dismiss_on_select: true,
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2025-10-24 23:07:40 -07:00
|
|
|
|
header: Box::new(crate::render::renderable::ColumnRenderable::with(
|
|
|
|
|
|
header_lines,
|
|
|
|
|
|
)),
|
2025-10-24 22:28:14 -07:00
|
|
|
|
..Default::default()
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use crate::app_event::AppEvent;
|
2025-10-24 22:28:14 -07:00
|
|
|
|
use crate::app_event_sender::AppEventSender;
|
|
|
|
|
|
|
|
|
|
|
|
fn render(view: &FeedbackNoteView, width: u16) -> String {
|
|
|
|
|
|
let height = view.desired_height(width);
|
|
|
|
|
|
let area = Rect::new(0, 0, width, height);
|
|
|
|
|
|
let mut buf = Buffer::empty(area);
|
|
|
|
|
|
view.render(area, &mut buf);
|
|
|
|
|
|
|
|
|
|
|
|
let mut lines: Vec<String> = (0..area.height)
|
2025-10-16 21:03:23 -07:00
|
|
|
|
.map(|row| {
|
|
|
|
|
|
let mut line = String::new();
|
2025-10-24 22:28:14 -07:00
|
|
|
|
for col in 0..area.width {
|
|
|
|
|
|
let symbol = buf[(area.x + col, area.y + row)].symbol();
|
2025-10-16 21:03:23 -07:00
|
|
|
|
if symbol.is_empty() {
|
|
|
|
|
|
line.push(' ');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
line.push_str(symbol);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
line.trim_end().to_string()
|
|
|
|
|
|
})
|
2025-10-24 22:28:14 -07:00
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
while lines.first().is_some_and(|l| l.trim().is_empty()) {
|
|
|
|
|
|
lines.remove(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
while lines.last().is_some_and(|l| l.trim().is_empty()) {
|
|
|
|
|
|
lines.pop();
|
|
|
|
|
|
}
|
|
|
|
|
|
lines.join("\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn make_view(category: FeedbackCategory) -> FeedbackNoteView {
|
|
|
|
|
|
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let tx = AppEventSender::new(tx_raw);
|
|
|
|
|
|
let snapshot = codex_feedback::CodexFeedback::new().snapshot(None);
|
|
|
|
|
|
FeedbackNoteView::new(category, snapshot, None, tx, true)
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2025-10-24 22:28:14 -07:00
|
|
|
|
fn feedback_view_bad_result() {
|
|
|
|
|
|
let view = make_view(FeedbackCategory::BadResult);
|
|
|
|
|
|
let rendered = render(&view, 60);
|
|
|
|
|
|
insta::assert_snapshot!("feedback_view_bad_result", rendered);
|
|
|
|
|
|
}
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn feedback_view_good_result() {
|
|
|
|
|
|
let view = make_view(FeedbackCategory::GoodResult);
|
|
|
|
|
|
let rendered = render(&view, 60);
|
|
|
|
|
|
insta::assert_snapshot!("feedback_view_good_result", rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn feedback_view_bug() {
|
|
|
|
|
|
let view = make_view(FeedbackCategory::Bug);
|
|
|
|
|
|
let rendered = render(&view, 60);
|
|
|
|
|
|
insta::assert_snapshot!("feedback_view_bug", rendered);
|
|
|
|
|
|
}
|
2025-10-16 21:03:23 -07:00
|
|
|
|
|
2025-10-24 22:28:14 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn feedback_view_other() {
|
|
|
|
|
|
let view = make_view(FeedbackCategory::Other);
|
|
|
|
|
|
let rendered = render(&view, 60);
|
|
|
|
|
|
insta::assert_snapshot!("feedback_view_other", rendered);
|
2025-10-16 21:03:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|