mirror of
https://github.com/openai/codex.git
synced 2026-10-21 03:38:57 +00:00
Extract the note input view into its own module (#42792)
## What changed Move the optional note input and its focused tests out of the broader submission module. Update the bottom-pane export and snapshot metadata to match the new module layout without changing submission behavior. GitOrigin-RevId: 383ad052b47571f8a46e1aa6182261c370008be0
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
//! Collects an optional feedback note and submits it with the selected log consent.
|
||||
|
||||
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::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;
|
||||
|
||||
/// 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()])]
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "feedback_note_view_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
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_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 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 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,
|
||||
}
|
||||
));
|
||||
}
|
||||
@@ -4,31 +4,15 @@ 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";
|
||||
@@ -45,225 +29,6 @@ pub(crate) enum FeedbackAudience {
|
||||
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,
|
||||
@@ -271,35 +36,6 @@ pub(crate) fn should_show_feedback_connectivity_details(
|
||||
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",
|
||||
@@ -593,16 +329,11 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::render::renderable::Renderable;
|
||||
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)
|
||||
}
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
fn render_renderable(renderable: &dyn Renderable, width: u16) -> String {
|
||||
let height = renderable.desired_height(width);
|
||||
@@ -652,64 +383,6 @@ mod tests {
|
||||
.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>();
|
||||
@@ -749,58 +422,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -137,6 +137,7 @@ pub(crate) use list_selection_view::popup_content_width;
|
||||
pub(crate) use list_selection_view::side_by_side_layout_widths;
|
||||
pub(crate) use memories_settings_view::MemoriesSettingsView;
|
||||
use slash_commands::ServiceTierCommand;
|
||||
mod feedback_note_view;
|
||||
mod feedback_view;
|
||||
mod hooks_browser_view;
|
||||
pub(crate) use feedback_view::FeedbackAudience;
|
||||
@@ -166,7 +167,7 @@ mod selection_tabs;
|
||||
mod startup;
|
||||
mod textarea;
|
||||
mod unified_exec_footer;
|
||||
pub(crate) use feedback_view::FeedbackNoteView;
|
||||
pub(crate) use feedback_note_view::FeedbackNoteView;
|
||||
pub(crate) use hooks_browser_view::HooksBrowserView;
|
||||
pub(crate) use selection_tabs::SelectionTab;
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
source: tui/src/bottom_pane/feedback_note_view_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (bad result)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
source: tui/src/bottom_pane/feedback_note_view_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (bug)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
source: tui/src/bottom_pane/feedback_note_view_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (good result)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
source: tui/src/bottom_pane/feedback_note_view_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (other)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
source: tui/src/bottom_pane/feedback_note_view_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (safety check)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
source: tui/src/bottom_pane/feedback_note_view_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (bug)
|
||||
Reference in New Issue
Block a user