Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codex-rs/tui/src/bottom_pane/list_selection_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ impl Renderable for ListSelectionView {
"no matches",
ColumnWidthMode::Fixed,
),
}
};
}

if footer_area.height > 0 {
Expand Down
104 changes: 102 additions & 2 deletions codex-rs/tui/src/bottom_pane/request_user_input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,12 @@ impl RequestUserInputOverlay {
let prefix = if selected { '›' } else { ' ' };
let label = opt.label.as_str();
let number = idx + 1;
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
GenericDisplayRow {
name: format!("{prefix} {number}. {label}"),
name: format!("{prefix_label}{label}"),
description: Some(opt.description.clone()),
wrap_indent: Some(wrap_indent),
..Default::default()
}
})
Expand All @@ -293,9 +296,12 @@ impl RequestUserInputOverlay {
let selected = selected_idx.is_some_and(|sel| sel == idx);
let prefix = if selected { '›' } else { ' ' };
let number = idx + 1;
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
rows.push(GenericDisplayRow {
name: format!("{prefix} {number}. {OTHER_OPTION_LABEL}"),
name: format!("{prefix_label}{OTHER_OPTION_LABEL}"),
description: Some(OTHER_OPTION_DESCRIPTION.to_string()),
wrap_indent: Some(wrap_indent),
..Default::default()
});
}
Expand Down Expand Up @@ -1382,6 +1388,57 @@ mod tests {
}
}

fn question_with_very_long_option_text(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Choose one option.".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/unknown (Recommended when triaging long-running background work and status transitions)".to_string(),
description: "Keep async job statuses for progress tracking and include enough context for debugging retries, stale workers, and unexpected expiration paths.".to_string(),
},
RequestUserInputQuestionOption {
label: "Add a short status model".to_string(),
description: "Simpler labels with less detail for quick rollouts.".to_string(),
},
]),
}
}

fn question_with_long_scroll_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question:
"Choose one option; each hint is intentionally very long to test wrapped scrolling."
.to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Use Detailed Hint A (Recommended)".to_string(),
description: "Select this if you want a deliberately overextended explanatory hint that reads like a miniature specification, including context, rationale, expected behavior, and an explicit statement that this choice is mainly for testing how gracefully the interface wraps, truncates, and preserves readability under unusually verbose helper text conditions.".to_string(),
},
RequestUserInputQuestionOption {
label: "Use Detailed Hint B".to_string(),
description: "Select this if you want an equally verbose but differently phrased guidance block that emphasizes user-facing clarity, spacing tolerance, multiline wrapping, visual hierarchy interactions, and whether long descriptive metadata remains understandable when scanned quickly in a constrained layout where cognitive load is already high.".to_string(),
},
RequestUserInputQuestionOption {
label: "Use Detailed Hint C".to_string(),
description: "Select this when you specifically want to verify that navigating downward will keep the currently highlighted option visible, even when previous options consume many wrapped lines and would otherwise push the selection out of the viewport.".to_string(),
},
RequestUserInputQuestionOption {
label: "None of the above".to_string(),
description:
"Use this only if the previous long-form options do not apply.".to_string(),
},
]),
}
}

fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
Expand Down Expand Up @@ -2575,6 +2632,49 @@ mod tests {
);
}

#[test]
fn request_user_input_long_option_text_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_very_long_option_text("q1", "Status")],
),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 120, 18);
insta::assert_snapshot!(
"request_user_input_long_option_text",
render_snapshot(&overlay, area)
);
}

#[test]
fn selected_long_wrapped_option_stays_visible() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_long_scroll_options("q1", "Scroll")],
),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(2);

let rendered = render_snapshot(&overlay, Rect::new(0, 0, 80, 20));
assert!(
rendered.contains("› 3. Use Detailed Hint C"),
"expected selected option to be visible in viewport\n{rendered}"
);
}

#[test]
fn request_user_input_footer_wrap_snapshot() {
let (tx, _rx) = test_sender();
Expand Down
44 changes: 43 additions & 1 deletion codex-rs/tui/src/bottom_pane/request_user_input/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ impl RequestUserInputOverlay {
// Ensure the selected option is visible in the scroll window.
options_state
.ensure_visible(option_rows.len(), sections.options_area.height as usize);
render_rows(
render_rows_bottom_aligned(
sections.options_area,
buf,
&option_rows,
Expand Down Expand Up @@ -431,6 +431,48 @@ fn line_width(line: &Line<'_>) -> usize {
.sum()
}

/// Render rows into `area`, bottom-aligning the visible rows when fewer than
/// `area.height` lines are produced.
///
/// This keeps footer spacing stable by anchoring the options block to the
/// bottom of its allocated region.
fn render_rows_bottom_aligned(
area: Rect,
buf: &mut Buffer,
rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) {
if area.width == 0 || area.height == 0 {
return;
}

let scratch_area = Rect::new(0, 0, area.width, area.height);
let mut scratch = Buffer::empty(scratch_area);
for y in 0..area.height {
for x in 0..area.width {
scratch[(x, y)] = buf[(area.x + x, area.y + y)].clone();
}
}
let rendered_height = render_rows(
scratch_area,
&mut scratch,
rows,
state,
max_results,
empty_message,
);

let visible_height = rendered_height.min(area.height);
let y_offset = area.height.saturating_sub(visible_height);
for y in 0..visible_height {
for x in 0..area.width {
buf[(area.x + x, area.y + y_offset + y)] = scratch[(x, y)].clone();
}
}
}

/// 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---

Question 1/1 (1 unanswered)
Choose one option.

› 1. Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/ Keep async job statuses for
unknown (Recommended when triaging long-running background work and status progress tracking and include
transitions) enough context for debugging
retries, stale workers, and
unexpected expiration paths.
2. Add a short status model Simpler labels with less detail for
quick rollouts.

tab to add notes | enter to submit answer | esc to interrupt
Loading
Loading