mirror of
https://github.com/openai/codex.git
synced 2026-04-26 23:55:25 +00:00
## Summary Polishes the `request_user_input` TUI overlay Question 1 (unanswered) <img width="853" height="167" alt="Screenshot 2026-01-27 at 1 30 09 PM" src="https://github.com/user-attachments/assets/3c305644-449e-4e8d-a47b-d689ebd8702c" /> Tab to add notes <img width="856" height="198" alt="Screenshot 2026-01-27 at 1 30 25 PM" src="https://github.com/user-attachments/assets/0d2801b0-df0c-49ae-85af-e6d56fc2c67c" /> Question 2 (unanswered) <img width="854" height="168" alt="Screenshot 2026-01-27 at 1 30 55 PM" src="https://github.com/user-attachments/assets/b3723062-51f9-49c9-a9ab-bb1b32964542" /> Ctrl+p or h to go back to q1 (answered) <img width="853" height="195" alt="Screenshot 2026-01-27 at 1 31 27 PM" src="https://github.com/user-attachments/assets/c602f183-1c25-4c51-8f9f-e565cb6bd637" /> Unanswered freeform <img width="856" height="126" alt="Screenshot 2026-01-27 at 1 31 42 PM" src="https://github.com/user-attachments/assets/7e3d9d8b-820b-4b9a-9ef2-4699eed484c5" /> ## Key changes - Footer tips wrap at tip boundaries (no truncation mid‑tip); footer height scales to wrapped tips. - Keep tooltip text as Esc: interrupt in all states. - Make the full Tab: add notes tip cyan/bold when applicable; hide notes UI by default. - Notes toggling/backspace: - Tab opens notes when an option is selected; Tab again clears notes and hides the notes UI. - Backspace in options clears the current selection. - Backspace in empty notes closes notes and returns to options. - Selection/answering behavior: - Option questions highlight a default option but are not answered until Enter. - Enter no longer auto‑selects when there’s no selection (prevents accidental answers). - Notes submission can commit the selected option when present. - Freeform questions require Enter with non‑empty text to mark answered; drafts are not submitted unless committed. - Unanswered cues: - Skipped option questions count as unanswered. - Unanswered question titles are highlighted for visibility. - Typing/navigation in options: - Typing no longer opens notes; notes are Tab‑only. - j/k move option selection; h/l switch questions (Ctrl+n/Ctrl+p still work). ## Tests - Added unit coverage for: - tip‑level wrapping - focus reset when switching questions with existing drafts - backspace clearing selection - backspace closing empty notes - typing in options does not open notes - freeform draft submission gating - h/l question navigation in options - Updated snapshots, including narrow footer wrap. ## Why These changes make the ask‑user‑question overlay: - safer (no silent auto‑selection or accidental freeform submission), - clearer (tips wrap cleanly and unanswered states stand out), - more ergonomic (Tab explicitly controls notes; backspace acts like undo/close). ## Codex author `codex fork 019bfc3c-2c42-7982-9119-fee8b9315c2f` --------- Co-authored-by: Ahmed Ibrahim <aibrahim@openai.com>
370 lines
13 KiB
Rust
370 lines
13 KiB
Rust
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::Widget;
|
|
use unicode_width::UnicodeWidthChar;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
|
|
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
|
|
use crate::bottom_pane::selection_popup_common::render_menu_surface;
|
|
use crate::bottom_pane::selection_popup_common::render_rows;
|
|
use crate::render::renderable::Renderable;
|
|
|
|
use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN;
|
|
use super::RequestUserInputOverlay;
|
|
use super::TIP_SEPARATOR;
|
|
|
|
impl Renderable for RequestUserInputOverlay {
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
let outer = Rect::new(0, 0, width, u16::MAX);
|
|
let inner = menu_surface_inset(outer);
|
|
let inner_width = inner.width.max(1);
|
|
let has_options = self.has_options();
|
|
let question_height = self.wrapped_question_lines(inner_width).len();
|
|
let options_height = if has_options {
|
|
self.options_preferred_height(inner_width) as usize
|
|
} else {
|
|
0
|
|
};
|
|
let notes_visible = !has_options || self.notes_ui_visible();
|
|
let notes_height = if notes_visible {
|
|
self.notes_input_height(inner_width) as usize
|
|
} else {
|
|
0
|
|
};
|
|
let spacer_rows = if has_options && !notes_visible {
|
|
DESIRED_SPACERS_WHEN_NOTES_HIDDEN as usize
|
|
} else {
|
|
0
|
|
};
|
|
let footer_height = self.footer_required_height(inner_width) as usize;
|
|
|
|
// Tight minimum height: progress + header + question + (optional) titles/options
|
|
// + notes composer + footer + menu padding.
|
|
let mut height = question_height
|
|
.saturating_add(options_height)
|
|
.saturating_add(spacer_rows)
|
|
.saturating_add(notes_height)
|
|
.saturating_add(footer_height)
|
|
.saturating_add(2); // progress + header
|
|
if has_options && notes_visible {
|
|
height = height.saturating_add(1); // notes title
|
|
}
|
|
height = height.saturating_add(menu_surface_padding_height() as usize);
|
|
height.max(8) as u16
|
|
}
|
|
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
self.render_ui(area, buf);
|
|
}
|
|
|
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
|
self.cursor_pos_impl(area)
|
|
}
|
|
}
|
|
|
|
impl RequestUserInputOverlay {
|
|
/// Render the full request-user-input overlay.
|
|
pub(super) fn render_ui(&self, area: Rect, buf: &mut Buffer) {
|
|
if area.width == 0 || area.height == 0 {
|
|
return;
|
|
}
|
|
// Paint the same menu surface used by other bottom-pane overlays and
|
|
// then render the overlay content inside its inset area.
|
|
let content_area = render_menu_surface(area, buf);
|
|
if content_area.width == 0 || content_area.height == 0 {
|
|
return;
|
|
}
|
|
let sections = self.layout_sections(content_area);
|
|
let notes_visible = self.notes_ui_visible();
|
|
let unanswered = self.unanswered_count();
|
|
|
|
// Progress header keeps the user oriented across multiple questions.
|
|
let progress_line = if self.question_count() > 0 {
|
|
let idx = self.current_index() + 1;
|
|
let total = self.question_count();
|
|
let base = format!("Question {idx}/{total}");
|
|
if unanswered > 0 {
|
|
Line::from(format!("{base} ({unanswered} unanswered)").dim())
|
|
} else {
|
|
Line::from(base.dim())
|
|
}
|
|
} else {
|
|
Line::from("No questions".dim())
|
|
};
|
|
Paragraph::new(progress_line).render(sections.progress_area, buf);
|
|
|
|
// Question title and wrapped prompt text.
|
|
let question_header = self.current_question().map(|q| q.header.clone());
|
|
let answered = self.current_question_answered();
|
|
let header_line = if let Some(header) = question_header {
|
|
if answered {
|
|
Line::from(header.bold())
|
|
} else {
|
|
Line::from(header.cyan().bold())
|
|
}
|
|
} else {
|
|
Line::from("No questions".dim())
|
|
};
|
|
Paragraph::new(header_line).render(sections.header_area, buf);
|
|
|
|
let question_y = sections.question_area.y;
|
|
for (offset, line) in sections.question_lines.iter().enumerate() {
|
|
if question_y.saturating_add(offset as u16)
|
|
>= sections.question_area.y + sections.question_area.height
|
|
{
|
|
break;
|
|
}
|
|
Paragraph::new(Line::from(line.clone())).render(
|
|
Rect {
|
|
x: sections.question_area.x,
|
|
y: question_y.saturating_add(offset as u16),
|
|
width: sections.question_area.width,
|
|
height: 1,
|
|
},
|
|
buf,
|
|
);
|
|
}
|
|
|
|
// Build rows with selection markers for the shared selection renderer.
|
|
let option_rows = self.option_rows();
|
|
|
|
if self.has_options() {
|
|
let mut options_ui_state = self
|
|
.current_answer()
|
|
.map(|answer| answer.options_ui_state)
|
|
.unwrap_or_default();
|
|
if sections.options_area.height > 0 {
|
|
// Ensure the selected option is visible in the scroll window.
|
|
options_ui_state
|
|
.ensure_visible(option_rows.len(), sections.options_area.height as usize);
|
|
render_rows(
|
|
sections.options_area,
|
|
buf,
|
|
&option_rows,
|
|
&options_ui_state,
|
|
option_rows.len().max(1),
|
|
"No options",
|
|
);
|
|
}
|
|
}
|
|
|
|
if notes_visible && sections.notes_title_area.height > 0 {
|
|
let notes_label = if self.has_options()
|
|
&& self
|
|
.current_answer()
|
|
.is_some_and(|answer| answer.committed_option_idx.is_some())
|
|
{
|
|
if let Some(label) = self.current_option_label() {
|
|
format!("Notes for {label}")
|
|
} else {
|
|
"Notes".to_string()
|
|
}
|
|
} else {
|
|
"Notes".to_string()
|
|
};
|
|
let notes_active = self.focus_is_notes();
|
|
let notes_title = if notes_active {
|
|
notes_label.as_str().cyan().bold()
|
|
} else {
|
|
notes_label.as_str().dim()
|
|
};
|
|
Paragraph::new(Line::from(notes_title)).render(sections.notes_title_area, buf);
|
|
}
|
|
|
|
if notes_visible && sections.notes_area.height > 0 {
|
|
self.render_notes_input(sections.notes_area, buf);
|
|
}
|
|
|
|
let footer_y = sections
|
|
.notes_area
|
|
.y
|
|
.saturating_add(sections.notes_area.height);
|
|
let footer_area = Rect {
|
|
x: content_area.x,
|
|
y: footer_y,
|
|
width: content_area.width,
|
|
height: sections.footer_lines,
|
|
};
|
|
if footer_area.height == 0 {
|
|
return;
|
|
}
|
|
let tip_lines = self.footer_tip_lines(footer_area.width);
|
|
for (row_idx, tips) in tip_lines
|
|
.into_iter()
|
|
.take(footer_area.height as usize)
|
|
.enumerate()
|
|
{
|
|
let mut spans = Vec::new();
|
|
for (tip_idx, tip) in tips.into_iter().enumerate() {
|
|
if tip_idx > 0 {
|
|
spans.push(TIP_SEPARATOR.into());
|
|
}
|
|
if tip.highlight {
|
|
spans.push(tip.text.cyan().bold().not_dim());
|
|
} else {
|
|
spans.push(tip.text.into());
|
|
}
|
|
}
|
|
let line = Line::from(spans).dim();
|
|
let line = truncate_line_word_boundary_with_ellipsis(line, footer_area.width as usize);
|
|
let row_area = Rect {
|
|
x: footer_area.x,
|
|
y: footer_area.y.saturating_add(row_idx as u16),
|
|
width: footer_area.width,
|
|
height: 1,
|
|
};
|
|
Paragraph::new(line).render(row_area, buf);
|
|
}
|
|
}
|
|
|
|
/// Return the cursor position when editing notes, if visible.
|
|
pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> {
|
|
let has_options = self.has_options();
|
|
let notes_visible = self.notes_ui_visible();
|
|
|
|
if !self.focus_is_notes() {
|
|
return None;
|
|
}
|
|
if has_options && !notes_visible {
|
|
return None;
|
|
}
|
|
let content_area = menu_surface_inset(area);
|
|
if content_area.width == 0 || content_area.height == 0 {
|
|
return None;
|
|
}
|
|
let sections = self.layout_sections(content_area);
|
|
let input_area = sections.notes_area;
|
|
if input_area.width == 0 || input_area.height == 0 {
|
|
return None;
|
|
}
|
|
self.composer.cursor_pos(input_area)
|
|
}
|
|
|
|
/// Render the notes composer.
|
|
fn render_notes_input(&self, area: Rect, buf: &mut Buffer) {
|
|
if area.width == 0 || area.height == 0 {
|
|
return;
|
|
}
|
|
self.composer.render(area, buf);
|
|
}
|
|
}
|
|
|
|
fn line_width(line: &Line<'_>) -> usize {
|
|
line.iter()
|
|
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
|
.sum()
|
|
}
|
|
|
|
/// Truncate a styled line to `max_width`, preferring a word boundary, and append an ellipsis.
|
|
///
|
|
/// This walks spans character-by-character, tracking the last width-safe position and the last
|
|
/// whitespace boundary within the available width (excluding the ellipsis width). If the line
|
|
/// overflows, it truncates at the last word boundary when possible (falling back to the last
|
|
/// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the
|
|
/// last visible span (or the line style if nothing was kept).
|
|
fn truncate_line_word_boundary_with_ellipsis(
|
|
line: Line<'static>,
|
|
max_width: usize,
|
|
) -> Line<'static> {
|
|
if max_width == 0 {
|
|
return Line::from(Vec::<Span<'static>>::new());
|
|
}
|
|
|
|
if line_width(&line) <= max_width {
|
|
return line;
|
|
}
|
|
|
|
let ellipsis = "…";
|
|
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
|
if ellipsis_width >= max_width {
|
|
return Line::from(ellipsis);
|
|
}
|
|
let limit = max_width.saturating_sub(ellipsis_width);
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct BreakPoint {
|
|
span_idx: usize,
|
|
byte_end: usize,
|
|
}
|
|
|
|
// Track display width as we scan, along with the best "cut here" positions.
|
|
let mut used = 0usize;
|
|
let mut last_fit: Option<BreakPoint> = None;
|
|
let mut last_word_break: Option<BreakPoint> = None;
|
|
let mut overflowed = false;
|
|
|
|
'outer: for (span_idx, span) in line.spans.iter().enumerate() {
|
|
let text = span.content.as_ref();
|
|
for (byte_idx, ch) in text.char_indices() {
|
|
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
|
if used.saturating_add(ch_width) > limit {
|
|
overflowed = true;
|
|
break 'outer;
|
|
}
|
|
used = used.saturating_add(ch_width);
|
|
let bp = BreakPoint {
|
|
span_idx,
|
|
byte_end: byte_idx + ch.len_utf8(),
|
|
};
|
|
last_fit = Some(bp);
|
|
if ch.is_whitespace() {
|
|
last_word_break = Some(bp);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we never overflowed, the original line already fits.
|
|
if !overflowed {
|
|
return line;
|
|
}
|
|
|
|
// Prefer breaking on whitespace; otherwise fall back to the last fitting character.
|
|
let chosen_break = last_word_break.or(last_fit);
|
|
let Some(chosen_break) = chosen_break else {
|
|
return Line::from(ellipsis);
|
|
};
|
|
|
|
let line_style = line.style;
|
|
let mut spans_out: Vec<Span<'static>> = Vec::new();
|
|
for (idx, span) in line.spans.into_iter().enumerate() {
|
|
if idx < chosen_break.span_idx {
|
|
spans_out.push(span);
|
|
continue;
|
|
}
|
|
if idx == chosen_break.span_idx {
|
|
let text = span.content.into_owned();
|
|
let truncated = text[..chosen_break.byte_end].to_string();
|
|
if !truncated.is_empty() {
|
|
spans_out.push(Span::styled(truncated, span.style));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
while let Some(last) = spans_out.last_mut() {
|
|
let trimmed = last
|
|
.content
|
|
.trim_end_matches(char::is_whitespace)
|
|
.to_string();
|
|
if trimmed.is_empty() {
|
|
spans_out.pop();
|
|
} else {
|
|
last.content = trimmed.into();
|
|
break;
|
|
}
|
|
}
|
|
|
|
let ellipsis_style = spans_out
|
|
.last()
|
|
.map(|span| span.style)
|
|
.unwrap_or(line_style);
|
|
spans_out.push(Span::styled(ellipsis, ellipsis_style));
|
|
|
|
Line::from(spans_out).style(line_style)
|
|
}
|