Codex Handbook
tui/src/bottom_pane/feedback_view.rs 933 lines
use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME;use codex_feedback::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME;use codex_feedback::FeedbackDiagnostics;use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyModifiers;use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::text::Span;use ratatui::widgets::Clear;use ratatui::widgets::Paragraph;use ratatui::widgets::StatefulWidgetRef;use ratatui::widgets::Widget;use std::cell::RefCell;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;const BASE_CLI_BUG_ISSUE_URL: &str =    "https://github.com/openai/codex/issues/new?template=3-cli.yml";/// Internal routing link for employee feedback follow-ups. This must not be shown to external users.const CODEX_FEEDBACK_INTERNAL_URL: &str = "http://go/codex-feedback-internal";/// The target audience for feedback follow-up instructions.////// This is used strictly for messaging/links after feedback upload completes. It/// must not change feedback upload behavior itself.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum FeedbackAudience {    OpenAiEmployee,    External,}/// Minimal input overlay to collect an optional feedback note, then submit it/// through the app-server-managed feedback flow.pub(crate) struct FeedbackNoteView {    category: FeedbackCategory,    turn_id: Option<String>,    app_event_tx: AppEventSender,    include_logs: bool,    // UI state    textarea: TextArea,    textarea_state: RefCell<TextAreaState>,    complete: bool,}impl FeedbackNoteView {    pub(crate) fn new(        category: FeedbackCategory,        turn_id: Option<String>,        app_event_tx: AppEventSender,        include_logs: bool,    ) -> Self {        Self {            category,            turn_id,            app_event_tx,            include_logs,            textarea: TextArea::new(),            textarea_state: RefCell::new(TextAreaState::default()),            complete: false,        }    }    fn submit(&mut self) {        let note = self.textarea.text().trim().to_string();        let reason = if note.is_empty() { None } else { Some(note) };        self.app_event_tx.send(AppEvent::SubmitFeedback {            category: self.category,            reason,            turn_id: self.turn_id.clone(),            include_logs: self.include_logs,        });        self.complete = true;    }}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    }}impl Renderable for FeedbackNoteView {    fn desired_height(&self, width: u16) -> u16 {        self.intro_lines(width).len() as u16 + self.input_height(width) + 2u16    }    fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {        if area.height < 2 || area.width <= 2 {            return None;        }        let intro_height = self.intro_lines(area.width).len() as u16;        let text_area_height = self.input_height(area.width).saturating_sub(1);        if text_area_height == 0 {            return None;        }        let textarea_rect = Rect {            x: area.x.saturating_add(2),            y: area.y.saturating_add(intro_height).saturating_add(1),            width: area.width.saturating_sub(2),            height: text_area_height,        };        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 intro_lines = self.intro_lines(area.width);        let (_, placeholder) = feedback_title_and_placeholder(self.category);        let input_height = self.input_height(area.width);        for (offset, line) in intro_lines.iter().enumerate() {            Paragraph::new(line.clone()).render(                Rect {                    x: area.x,                    y: area.y.saturating_add(offset as u16),                    width: area.width,                    height: 1,                },                buf,            );        }        // Input line        let input_area = Rect {            x: area.x,            y: area.y.saturating_add(intro_lines.len() as u16),            width: area.width,            height: input_height,        };        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);        }        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,            );        }    }}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 intro_lines(&self, _width: u16) -> Vec<Line<'static>> {        let (title, _) = feedback_title_and_placeholder(self.category);        vec![Line::from(vec![gutter(), title.bold()])]    }}pub(crate) fn should_show_feedback_connectivity_details(    category: FeedbackCategory,    diagnostics: &FeedbackDiagnostics,) -> bool {    category != FeedbackCategory::GoodResult && !diagnostics.is_empty()}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::SafetyCheck => (            "Tell us more (safety check)".to_string(),            "(optional) Share what was refused and why it should have been allowed".to_string(),        ),        FeedbackCategory::Other => (            "Tell us more (other)".to_string(),            "(optional) Write a short description to help us further".to_string(),        ),    }}pub(crate) fn feedback_classification(category: FeedbackCategory) -> &'static str {    match category {        FeedbackCategory::BadResult => "bad_result",        FeedbackCategory::GoodResult => "good_result",        FeedbackCategory::Bug => "bug",        FeedbackCategory::SafetyCheck => "safety_check",        FeedbackCategory::Other => "other",    }}pub(crate) fn feedback_success_cell(    category: FeedbackCategory,    include_logs: bool,    thread_id: &str,    feedback_audience: FeedbackAudience,) -> history_cell::WebHyperlinkHistoryCell {    let prefix = if include_logs {        "• Feedback uploaded."    } else {        "• Feedback recorded (no logs)."    };    let issue_url = issue_url_for_category(category, thread_id, feedback_audience);    let mut lines = vec![Line::from(match issue_url.as_ref() {        Some(_) if feedback_audience == FeedbackAudience::OpenAiEmployee => {            format!("{prefix} Please report this in #codex-feedback:")        }        Some(_) => format!("{prefix} Please open an issue using the following URL:"),        None => format!("{prefix} Thanks for the feedback!"),    })];    match issue_url {        Some(url) if feedback_audience == FeedbackAudience::OpenAiEmployee => {            lines.extend([                "".into(),                Line::from(vec!["  ".into(), url.cyan().underlined()]),                "".into(),                Line::from("  Share this and add some info about your problem:"),                Line::from(vec![                    "    ".into(),                    format!("https://go/codex-feedback/{thread_id}").bold(),                ]),            ]);        }        Some(url) => {            lines.extend([                "".into(),                Line::from(vec!["  ".into(), url.cyan().underlined()]),                "".into(),                Line::from(vec![                    "  Or mention your thread ID ".into(),                    thread_id.to_string().bold(),                    " in an existing issue.".into(),                ]),            ]);        }        None => {            lines.extend([                "".into(),                Line::from(vec!["  Thread ID: ".into(), thread_id.to_string().bold()]),            ]);        }    }    history_cell::WebHyperlinkHistoryCell::new(lines)}fn issue_url_for_category(    category: FeedbackCategory,    thread_id: &str,    feedback_audience: FeedbackAudience,) -> Option<String> {    // Only certain categories provide a follow-up link. We intentionally keep    // the external GitHub behavior identical while routing internal users to    // the internal go link.    match category {        FeedbackCategory::Bug        | FeedbackCategory::BadResult        | FeedbackCategory::SafetyCheck        | FeedbackCategory::Other => Some(match feedback_audience {            FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id),            FeedbackAudience::External => {                format!("{BASE_CLI_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}")            }        }),        FeedbackCategory::GoodResult => None,    }}/// Build the internal follow-up URL.////// We accept a `thread_id` so the call site stays symmetric with the external/// path, but we currently point to a fixed channel without prefilling text.fn slack_feedback_url(_thread_id: &str) -> String {    CODEX_FEEDBACK_INTERNAL_URL.to_string()}// 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,            ),            make_feedback_item(                app_event_tx.clone(),                "safety check",                "Benign usage blocked due to safety checks or refusals.",                FeedbackCategory::SafetyCheck,            ),            make_feedback_item(                app_event_tx,                "other",                "Slowness, feature suggestion, UX feedback, or anything else.",                FeedbackCategory::Other,            ),        ],        ..Default::default()    }}/// Build the selection popup params shown when feedback is disabled.pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams {    super::SelectionViewParams {        title: Some("Sending feedback is disabled".to_string()),        subtitle: Some("This action is disabled by configuration.".to_string()),        footer_hint: Some(standard_popup_hint_line()),        items: vec![super::SelectionItem {            name: "Close".to_string(),            dismiss_on_select: true,            ..Default::default()        }],        ..Default::default()    }}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,    rollout_path: Option<std::path::PathBuf>,    auto_review_rollout_filename: Option<String>,    include_windows_sandbox_log: bool,    feedback_diagnostics: &FeedbackDiagnostics,) -> super::SelectionViewParams {    use super::popup_consts::standard_popup_hint_line;    let yes_action: super::SelectionAction = Box::new({        let tx = app_event_tx.clone();        move |sender: &AppEventSender| {            let _ = sender;            tx.send(AppEvent::OpenFeedbackNote {                category,                include_logs: true,            });        }    });    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,            });        }    });    // 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(),        Line::from(vec![            "  • ".into(),            DOCTOR_REPORT_ATTACHMENT_FILENAME.into(),        ])        .into(),    ];    if include_windows_sandbox_log {        header_lines.push(            Line::from(vec![                "  • ".into(),                WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.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());    }    if let Some(filename) = auto_review_rollout_filename {        header_lines.push(Line::from(vec!["  • ".into(), filename.into()]).into());    }    if !feedback_diagnostics.is_empty() {        header_lines.push(            Line::from(vec![                "  • ".into(),                FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME.into(),            ])            .into(),        );    }    if should_show_feedback_connectivity_details(category, feedback_diagnostics) {        header_lines.push(Line::from("").into());        header_lines.push(Line::from("Connectivity diagnostics".bold()).into());        for diagnostic in feedback_diagnostics.diagnostics() {            header_lines                .push(Line::from(vec!["  - ".into(), diagnostic.headline.clone().into()]).into());            for detail in &diagnostic.details {                header_lines.push(Line::from(vec!["    - ".dim(), detail.clone().into()]).into());            }        }    }    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 and diagnostics with the team for troubleshooting."                        .to_string(),                ),                actions: vec![yes_action],                dismiss_on_select: true,                ..Default::default()            },            super::SelectionItem {                name: "No".to_string(),                actions: vec![no_action],                dismiss_on_select: true,                ..Default::default()            },        ],        header: Box::new(crate::render::renderable::ColumnRenderable::with(            header_lines,        )),        ..Default::default()    }}#[cfg(test)]mod tests {    use super::*;    use crate::app_event::AppEvent;    use crate::app_event_sender::AppEventSender;    use codex_feedback::FeedbackDiagnostic;    use pretty_assertions::assert_eq;    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);        render_buffer(area, &buf)    }    fn render_renderable(renderable: &dyn Renderable, width: u16) -> String {        let height = renderable.desired_height(width);        let area = Rect::new(0, 0, width, height);        let mut buf = Buffer::empty(area);        renderable.render(area, &mut buf);        render_buffer(area, &buf)    }    fn render_buffer(area: Rect, buf: &Buffer) -> String {        let mut lines: Vec<String> = (0..area.height)            .map(|row| {                let mut line = String::new();                for col in 0..area.width {                    let symbol = buf[(area.x + col, area.y + row)].symbol();                    if symbol.is_empty() {                        line.push(' ');                    } else {                        line.push_str(symbol);                    }                }                line.trim_end().to_string()            })            .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 render_cell(cell: &impl history_cell::HistoryCell, width: u16) -> String {        cell.display_lines(width)            .into_iter()            .map(|line| {                line.spans                    .into_iter()                    .map(|span| span.content.into_owned())                    .collect::<String>()                    .trim_end()                    .to_string()            })            .collect::<Vec<_>>()            .join("\n")    }    fn make_view(category: FeedbackCategory) -> FeedbackNoteView {        let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();        let tx = AppEventSender::new(tx_raw);        FeedbackNoteView::new(            category, /*turn_id*/ None, tx, /*include_logs*/ true,        )    }    #[test]    fn feedback_view_bad_result() {        let view = make_view(FeedbackCategory::BadResult);        let rendered = render(&view, /*width*/ 60);        insta::assert_snapshot!("feedback_view_bad_result", rendered);    }    #[test]    fn feedback_view_good_result() {        let view = make_view(FeedbackCategory::GoodResult);        let rendered = render(&view, /*width*/ 60);        insta::assert_snapshot!("feedback_view_good_result", rendered);    }    #[test]    fn feedback_view_bug() {        let view = make_view(FeedbackCategory::Bug);        let rendered = render(&view, /*width*/ 60);        insta::assert_snapshot!("feedback_view_bug", rendered);    }    #[test]    fn feedback_view_other() {        let view = make_view(FeedbackCategory::Other);        let rendered = render(&view, /*width*/ 60);        insta::assert_snapshot!("feedback_view_other", rendered);    }    #[test]    fn feedback_view_safety_check() {        let view = make_view(FeedbackCategory::SafetyCheck);        let rendered = render(&view, /*width*/ 60);        insta::assert_snapshot!("feedback_view_safety_check", rendered);    }    #[test]    fn feedback_view_with_connectivity_diagnostics() {        let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();        let tx = AppEventSender::new(tx_raw);        let view = FeedbackNoteView::new(            FeedbackCategory::Bug,            /*turn_id*/ None,            tx,            /*include_logs*/ false,        );        let rendered = render(&view, /*width*/ 60);        insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered);    }    #[test]    fn feedback_upload_consent_lists_doctor_report() {        let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();        let tx = AppEventSender::new(tx_raw);        let params = feedback_upload_consent_params(            tx,            FeedbackCategory::Bug,            Some(std::path::PathBuf::from("rollout.jsonl")),            Some("auto-review-rollout.jsonl".to_string()),            /*include_windows_sandbox_log*/ false,            &FeedbackDiagnostics::default(),        );        let rendered = render_renderable(params.header.as_ref(), /*width*/ 60);        insta::assert_snapshot!("feedback_upload_consent_lists_doctor_report", rendered);    }    #[test]    fn feedback_upload_consent_lists_windows_sandbox_log_when_included() {        let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();        let tx = AppEventSender::new(tx_raw);        let params = feedback_upload_consent_params(            tx,            FeedbackCategory::Bug,            Some(std::path::PathBuf::from("rollout.jsonl")),            Some("auto-review-rollout.jsonl".to_string()),            /*include_windows_sandbox_log*/ true,            &FeedbackDiagnostics::default(),        );        let rendered = render_renderable(params.header.as_ref(), /*width*/ 60);        insta::assert_snapshot!(            "feedback_upload_consent_lists_windows_sandbox_log_when_included",            rendered        );    }    #[test]    fn submit_feedback_emits_submit_event_with_trimmed_note() {        let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();        let tx = AppEventSender::new(tx_raw);        let mut view = FeedbackNoteView::new(            FeedbackCategory::Bug,            Some("turn-123".to_string()),            tx,            /*include_logs*/ true,        );        view.textarea.insert_str("  something broke  ");        view.submit();        let event = rx.try_recv().expect("submit feedback event");        assert!(matches!(            event,            AppEvent::SubmitFeedback {                category: FeedbackCategory::Bug,                reason: Some(reason),                turn_id: Some(turn_id),                include_logs: true,            } if reason == "something broke" && turn_id == "turn-123"        ));        assert_eq!(view.is_complete(), true);    }    #[test]    fn submit_feedback_omits_empty_note() {        let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();        let tx = AppEventSender::new(tx_raw);        let mut view = FeedbackNoteView::new(            FeedbackCategory::GoodResult,            /*turn_id*/ None,            tx,            /*include_logs*/ false,        );        view.submit();        let event = rx.try_recv().expect("submit feedback event");        assert!(matches!(            event,            AppEvent::SubmitFeedback {                category: FeedbackCategory::GoodResult,                reason: None,                turn_id: None,                include_logs: false,            }        ));    }    #[test]    fn should_show_feedback_connectivity_details_only_for_non_good_result_with_diagnostics() {        let diagnostics = FeedbackDiagnostics::new(vec![FeedbackDiagnostic {            headline: "Proxy environment variables are set and may affect connectivity."                .to_string(),            details: vec!["HTTP_PROXY = http://proxy.example.com:8080".to_string()],        }]);        assert_eq!(            should_show_feedback_connectivity_details(FeedbackCategory::Bug, &diagnostics),            true        );        assert_eq!(            should_show_feedback_connectivity_details(FeedbackCategory::GoodResult, &diagnostics),            false        );        assert_eq!(            should_show_feedback_connectivity_details(                FeedbackCategory::BadResult,                &FeedbackDiagnostics::default()            ),            false        );    }    #[test]    fn issue_url_available_for_bug_bad_result_safety_check_and_other() {        let bug_url = issue_url_for_category(            FeedbackCategory::Bug,            "thread-1",            FeedbackAudience::OpenAiEmployee,        );        let expected_slack_url = "http://go/codex-feedback-internal".to_string();        assert_eq!(bug_url.as_deref(), Some(expected_slack_url.as_str()));        let bad_result_url = issue_url_for_category(            FeedbackCategory::BadResult,            "thread-2",            FeedbackAudience::OpenAiEmployee,        );        assert!(bad_result_url.is_some());        let other_url = issue_url_for_category(            FeedbackCategory::Other,            "thread-3",            FeedbackAudience::OpenAiEmployee,        );        assert!(other_url.is_some());        let safety_check_url = issue_url_for_category(            FeedbackCategory::SafetyCheck,            "thread-4",            FeedbackAudience::OpenAiEmployee,        );        assert!(safety_check_url.is_some());        assert!(            issue_url_for_category(                FeedbackCategory::GoodResult,                "t",                FeedbackAudience::OpenAiEmployee            )            .is_none()        );        let bug_url_non_employee =            issue_url_for_category(FeedbackCategory::Bug, "t", FeedbackAudience::External);        let expected_external_url = "https://github.com/openai/codex/issues/new?template=3-cli.yml&steps=Uploaded%20thread:%20t";        assert_eq!(bug_url_non_employee.as_deref(), Some(expected_external_url));    }    #[test]    fn feedback_success_cell_matches_external_bug_copy() {        let rendered = render_cell(            &feedback_success_cell(                FeedbackCategory::Bug,                /*include_logs*/ true,                "thread-1",                FeedbackAudience::External,            ),            /*width*/ 120,        );        assert_eq!(            rendered,            "• Feedback uploaded. Please open an issue using the following URL:\n\n  https://github.com/openai/codex/issues/new?template=3-cli.yml&steps=Uploaded%20thread:%20thread-1\n\n  Or mention your thread ID thread-1 in an existing issue."        );    }    #[test]    fn feedback_success_cell_matches_employee_bug_copy() {        let rendered = render_cell(            &feedback_success_cell(                FeedbackCategory::Bug,                /*include_logs*/ true,                "thread-2",                FeedbackAudience::OpenAiEmployee,            ),            /*width*/ 120,        );        assert_eq!(            rendered,            "• Feedback uploaded. Please report this in #codex-feedback:\n\n  http://go/codex-feedback-internal\n\n  Share this and add some info about your problem:\n    https://go/codex-feedback/thread-2"        );    }    #[test]    fn feedback_success_cell_matches_good_result_copy() {        let rendered = render_cell(            &feedback_success_cell(                FeedbackCategory::GoodResult,                /*include_logs*/ false,                "thread-3",                FeedbackAudience::External,            ),            /*width*/ 120,        );        assert_eq!(            rendered,            "• Feedback recorded (no logs). Thanks for the feedback!\n\n  Thread ID: thread-3"        );    }    #[test]    fn feedback_success_cell_uses_issue_links_for_remaining_categories() {        for category in [            FeedbackCategory::BadResult,            FeedbackCategory::SafetyCheck,            FeedbackCategory::Other,        ] {            let rendered = render_cell(                &feedback_success_cell(                    category,                    /*include_logs*/ false,                    "thread-4",                    FeedbackAudience::External,                ),                /*width*/ 120,            );            assert!(rendered.contains("Please open an issue using the following URL:"));            assert!(rendered.contains("thread-4"));        }    }}