Refine request_user_input TUI interactions and option UX (#10025)

## Summary
Overhaul the ask‑user‑questions TUI to support “Other/None” answers,
better notes handling, improved option selection
UX, and a safer submission flow with confirmation for unanswered
questions.

Multiple choice (number keys for quick selection, up/down or jk for
cycling through options):
<img width="856" height="169" alt="Screenshot 2026-01-27 at 7 22 29 PM"
src="https://github.com/user-attachments/assets/cabd1b0e-25e0-4859-bd8f-9941192ca274"
/>

Tab to add notes:
<img width="856" height="197" alt="Screenshot 2026-01-27 at 7 22 45 PM"
src="https://github.com/user-attachments/assets/a807db5e-e966-412c-af91-6edc60062f35"
/>

Freeform (also note enter tooltip is highlighted on last question to
indicate questions UI will be exited upon submission):
<img width="854" height="112" alt="Screenshot 2026-01-27 at 7 23 13 PM"
src="https://github.com/user-attachments/assets/2e7b88bf-062b-4b9f-a9da-c9d8c8a59643"
/>

Confirmation dialogue (submitting with unanswered questions):
<img width="854" height="126" alt="Screenshot 2026-01-27 at 7 23 29 PM"
src="https://github.com/user-attachments/assets/93965c8f-54ac-45bc-a660-9625bcd101f8"
/>

## Key Changes
- **Options UI refresh**
- Render options as numbered entries; allow number keys to select &
submit.
- Remove “Option X/Y” header and allow the question UI height to expand
naturally.
- Keep spacing between question, options, and notes even when notes are
visible.
- Hide the title line and render the question prompt in cyan **only when
uncommitted**.

- **“Other / None of the above” support**
  - Wire `isOther` to add “None of the above”.
  - Add guidance text: “Optionally, add details in notes (tab).”

- **Notes composer UX**
- Remove “Notes” heading; place composer directly under the selected
option.
- Preserve pending paste placeholders across question navigation and
after submission.
  - Ctrl+C clears notes **only when the notes composer has focus**.
  - Ctrl+C now triggers an immediate redraw so the clear is visible.

- **Committed vs uncommitted state**
  - Introduce a unified `answer_committed` flag per question.
- Editing notes (including adding text or pastes) marks the answer
uncommitted.
- Changing the option highlight (j/k, up/down) marks the answer
uncommitted.
  - Clearing options (Backspace/Delete) also clears pending notes.
  - Question prompt turns cyan only when the answer is uncommitted.

- **Submission safety & confirmation**
  - Only submit notes/freeform text once explicitly committed.
- Last-question submit with unanswered questions shows a confirmation
dialog.
  - Confirmation options:
    1. **Proceed** (default)
    2. **Go back**
  - Description reflects count: “Submit with N unanswered question(s).”
  - Esc/Backspace in confirmation returns to first unanswered question.
  - Ctrl+C in confirmation interrupts and exits the overlay.

- **Footer hints**
- Cyan highlight restored for “enter to submit answer” / “enter to
submit all”.

## Codex author
`codex fork 019c00ed-323a-7000-bdb5-9f9c5a635bd9`
This commit is contained in:
Charley Cunningham
2026-01-28 09:41:59 -08:00
committed by GitHub
parent 74bd6d7178
commit 96386755b6
17 changed files with 1164 additions and 407 deletions

View File

@@ -21,6 +21,12 @@ pub(crate) trait BottomPaneView: Renderable {
CancellationEvent::NotHandled
}
/// Return true if Esc should be routed through `handle_key_event` instead
/// of the `on_ctrl_c` cancellation path.
fn prefer_esc_to_handle_key_event(&self) -> bool {
false
}
/// Optional paste handler. Return true if the view modified its state and
/// needs a redraw.
fn handle_paste(&mut self, _pasted: String) -> bool {

View File

@@ -653,6 +653,18 @@ impl ChatComposer {
text
}
pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> {
self.pending_pastes.clone()
}
pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) {
let text = self.textarea.text().to_string();
self.pending_pastes = pending_pastes
.into_iter()
.filter(|(placeholder, _)| text.contains(placeholder))
.collect();
}
/// Override the footer hint items displayed beneath the composer. Passing
/// `None` restores the default shortcut footer.
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
@@ -1431,7 +1443,7 @@ impl ChatComposer {
}
/// Expand large-paste placeholders using element ranges and rebuild other element spans.
fn expand_pending_pastes(
pub(crate) fn expand_pending_pastes(
text: &str,
mut elements: Vec<TextElement>,
pending_pastes: &[(String, String)],

View File

@@ -269,7 +269,10 @@ impl BottomPane {
let (ctrl_c_completed, view_complete, view_in_paste_burst) = {
let last_index = self.view_stack.len() - 1;
let view = &mut self.view_stack[last_index];
let prefer_esc =
key_event.code == KeyCode::Esc && view.prefer_esc_to_handle_key_event();
let ctrl_c_completed = key_event.code == KeyCode::Esc
&& !prefer_esc
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled)
&& view.is_complete();
if ctrl_c_completed {
@@ -338,6 +341,7 @@ impl BottomPane {
self.on_active_view_complete();
}
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
self.request_redraw();
}
event
} else if self.composer_is_empty() {
@@ -346,6 +350,7 @@ impl BottomPane {
self.view_stack.pop();
self.clear_composer_for_ctrl_c();
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
self.request_redraw();
CancellationEvent::Handled
}
}
@@ -815,7 +820,9 @@ mod tests {
use insta::assert_snapshot;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::cell::Cell;
use std::path::PathBuf;
use std::rc::Rc;
use tokio::sync::mpsc::unbounded_channel;
fn snapshot_buffer(buf: &Buffer) -> String {
@@ -1242,4 +1249,63 @@ mod tests {
"expected Esc to send Op::Interrupt while a task is running"
);
}
#[test]
fn esc_routes_to_handle_key_event_when_requested() {
#[derive(Default)]
struct EscRoutingView {
on_ctrl_c_calls: Rc<Cell<usize>>,
handle_calls: Rc<Cell<usize>>,
}
impl Renderable for EscRoutingView {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
impl BottomPaneView for EscRoutingView {
fn handle_key_event(&mut self, _key_event: KeyEvent) {
self.handle_calls
.set(self.handle_calls.get().saturating_add(1));
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.on_ctrl_c_calls
.set(self.on_ctrl_c_calls.get().saturating_add(1));
CancellationEvent::Handled
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
}
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
let on_ctrl_c_calls = Rc::new(Cell::new(0));
let handle_calls = Rc::new(Cell::new(0));
pane.push_view(Box::new(EscRoutingView {
on_ctrl_c_calls: Rc::clone(&on_ctrl_c_calls),
handle_calls: Rc::clone(&handle_calls),
}));
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(on_ctrl_c_calls.get(), 0);
assert_eq!(handle_calls.get(), 1);
}
}

View File

@@ -1,16 +1,14 @@
use ratatui::layout::Rect;
use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN;
use super::DESIRED_SPACERS_BETWEEN_SECTIONS;
use super::RequestUserInputOverlay;
pub(super) struct LayoutSections {
pub(super) progress_area: Rect,
pub(super) header_area: Rect,
pub(super) question_area: Rect,
// Wrapped question text lines to render in the question area.
pub(super) question_lines: Vec<String>,
pub(super) options_area: Rect,
pub(super) notes_title_area: Rect,
pub(super) notes_area: Rect,
// Number of footer rows (status + hints).
pub(super) footer_lines: u16,
@@ -26,16 +24,7 @@ impl RequestUserInputOverlay {
let mut question_lines = self.wrapped_question_lines(area.width);
let question_height = question_lines.len() as u16;
let (
question_height,
progress_height,
spacer_after_question,
options_height,
spacer_after_options,
notes_title_height,
notes_height,
footer_lines,
) = if has_options {
let layout = if has_options {
self.layout_with_options(
OptionsLayoutArgs {
available_height: area.height,
@@ -57,98 +46,52 @@ impl RequestUserInputOverlay {
)
};
let (progress_area, header_area, question_area, options_area, notes_title_area, notes_area) =
self.build_layout_areas(
area,
LayoutHeights {
progress_height,
question_height,
spacer_after_question,
options_height,
spacer_after_options,
notes_title_height,
notes_height,
},
);
let (progress_area, question_area, options_area, notes_area) =
self.build_layout_areas(area, layout);
LayoutSections {
progress_area,
header_area,
question_area,
question_lines,
options_area,
notes_title_area,
notes_area,
footer_lines,
footer_lines: layout.footer_lines,
}
}
/// Layout calculation when options are present.
///
/// Handles both tight layout (when space is constrained) and normal layout
/// (when there's sufficient space for all elements).
///
/// Returns: (question_height, progress_height, spacer_after_question, options_height, spacer_after_options, notes_title_height, notes_height, footer_lines)
fn layout_with_options(
&self,
args: OptionsLayoutArgs,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
) -> LayoutPlan {
let OptionsLayoutArgs {
available_height,
width,
question_height,
mut question_height,
notes_pref_height,
footer_pref,
notes_visible,
} = args;
let options_heights = OptionsHeights {
preferred: self.options_preferred_height(width),
full: self.options_required_height(width),
};
let min_options_height = 1u16;
let required = 1u16
.saturating_add(question_height)
.saturating_add(options_heights.preferred);
if required > available_height {
self.layout_with_options_tight(
let min_options_height = available_height.min(1);
let max_question_height = available_height.saturating_sub(min_options_height);
if question_height > max_question_height {
question_height = max_question_height;
question_lines.truncate(question_height as usize);
}
self.layout_with_options_normal(
OptionsNormalArgs {
available_height,
question_height,
min_options_height,
question_lines,
)
} else {
self.layout_with_options_normal(
OptionsNormalArgs {
available_height,
question_height,
notes_pref_height,
footer_pref,
notes_visible,
},
options_heights,
)
}
}
/// Tight layout for options case: allocate header + question + options first
/// and drop everything else when space is constrained.
fn layout_with_options_tight(
&self,
available_height: u16,
question_height: u16,
min_options_height: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let max_question_height =
available_height.saturating_sub(1u16.saturating_add(min_options_height));
let adjusted_question_height = question_height.min(max_question_height);
question_lines.truncate(adjusted_question_height as usize);
let options_height =
available_height.saturating_sub(1u16.saturating_add(adjusted_question_height));
(adjusted_question_height, 0, 0, options_height, 0, 0, 0, 0)
notes_pref_height,
footer_pref,
notes_visible,
},
OptionsHeights {
preferred: self.options_preferred_height(width),
full: self.options_required_height(width),
},
)
}
/// Normal layout for options case: allocate footer + progress first, and
@@ -157,7 +100,7 @@ impl RequestUserInputOverlay {
&self,
args: OptionsNormalArgs,
options: OptionsHeights,
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
) -> LayoutPlan {
let OptionsNormalArgs {
available_height,
question_height,
@@ -165,19 +108,23 @@ impl RequestUserInputOverlay {
footer_pref,
notes_visible,
} = args;
let min_options_height = 1u16;
let mut options_height = options.preferred.max(min_options_height);
let used = 1u16
.saturating_add(question_height)
.saturating_add(options_height);
let max_options_height = available_height.saturating_sub(question_height);
let min_options_height = max_options_height.min(1);
let mut options_height = options
.preferred
.min(max_options_height)
.max(min_options_height);
let used = question_height.saturating_add(options_height);
let mut remaining = available_height.saturating_sub(used);
// When notes are hidden, prefer to reserve room for progress, footer,
// and spacers by shrinking the options window if needed.
let desired_spacers = if notes_visible {
0
// Notes already separate options from the footer, so only keep a
// single spacer between the question and options.
1
} else {
DESIRED_SPACERS_WHEN_NOTES_HIDDEN
DESIRED_SPACERS_BETWEEN_SECTIONS
};
let required_extra = footer_pref
.saturating_add(1) // progress line
@@ -211,45 +158,41 @@ impl RequestUserInputOverlay {
}
let grow_by = remaining.min(options.full.saturating_sub(options_height));
options_height = options_height.saturating_add(grow_by);
return (
return LayoutPlan {
question_height,
progress_height,
spacer_after_question,
options_height,
spacer_after_options,
0,
0,
notes_height: 0,
footer_lines,
);
};
}
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
// Prefer notes next, then labels, with any leftover rows expanding notes.
let spacer_after_question = 0;
// Prefer spacers before notes, then notes.
let mut spacer_after_question = 0;
if remaining > 0 {
spacer_after_question = 1;
remaining = remaining.saturating_sub(1);
}
let spacer_after_options = 0;
let mut notes_height = notes_pref_height.min(remaining);
remaining = remaining.saturating_sub(notes_height);
let mut notes_title_height = 0;
if remaining > 0 {
notes_title_height = 1;
remaining = remaining.saturating_sub(1);
}
notes_height = notes_height.saturating_add(remaining);
(
LayoutPlan {
question_height,
progress_height,
spacer_after_question,
options_height,
spacer_after_options,
notes_title_height,
notes_height,
footer_lines,
)
}
}
/// Layout calculation when no options are present.
@@ -257,7 +200,6 @@ impl RequestUserInputOverlay {
/// Handles both tight layout (when space is constrained) and normal layout
/// (when there's sufficient space for all elements).
///
/// Returns: (question_height, progress_height, spacer_after_question, options_height, spacer_after_options, notes_title_height, notes_height, footer_lines)
fn layout_without_options(
&self,
available_height: u16,
@@ -265,8 +207,8 @@ impl RequestUserInputOverlay {
notes_pref_height: u16,
footer_pref: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let required = 1u16.saturating_add(question_height);
) -> LayoutPlan {
let required = question_height;
if required > available_height {
self.layout_without_options_tight(available_height, question_height, question_lines)
} else {
@@ -285,12 +227,20 @@ impl RequestUserInputOverlay {
available_height: u16,
question_height: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let max_question_height = available_height.saturating_sub(1);
) -> LayoutPlan {
let max_question_height = available_height;
let adjusted_question_height = question_height.min(max_question_height);
question_lines.truncate(adjusted_question_height as usize);
(adjusted_question_height, 0, 0, 0, 0, 0, 0, 0)
LayoutPlan {
question_height: adjusted_question_height,
progress_height: 0,
spacer_after_question: 0,
options_height: 0,
spacer_after_options: 0,
notes_height: 0,
footer_lines: 0,
}
}
/// Normal layout for no-options case: allocate space for notes, footer, and progress.
@@ -300,8 +250,8 @@ impl RequestUserInputOverlay {
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let required = 1u16.saturating_add(question_height);
) -> LayoutPlan {
let required = question_height;
let mut remaining = available_height.saturating_sub(required);
let mut notes_height = notes_pref_height.min(remaining);
remaining = remaining.saturating_sub(notes_height);
@@ -317,29 +267,26 @@ impl RequestUserInputOverlay {
notes_height = notes_height.saturating_add(remaining);
(
LayoutPlan {
question_height,
progress_height,
0,
0,
0,
0,
spacer_after_question: 0,
options_height: 0,
spacer_after_options: 0,
notes_height,
footer_lines,
)
}
}
/// Build the final layout areas from computed heights.
fn build_layout_areas(
&self,
area: Rect,
heights: LayoutHeights,
heights: LayoutPlan,
) -> (
Rect, // progress_area
Rect, // header_area
Rect, // question_area
Rect, // options_area
Rect, // notes_title_area
Rect, // notes_area
) {
let mut cursor_y = area.y;
@@ -350,14 +297,6 @@ impl RequestUserInputOverlay {
height: heights.progress_height,
};
cursor_y = cursor_y.saturating_add(heights.progress_height);
let header_height = area.height.saturating_sub(heights.progress_height).min(1);
let header_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: header_height,
};
cursor_y = cursor_y.saturating_add(header_height);
let question_area = Rect {
x: area.x,
y: cursor_y,
@@ -376,13 +315,6 @@ impl RequestUserInputOverlay {
cursor_y = cursor_y.saturating_add(heights.options_height);
cursor_y = cursor_y.saturating_add(heights.spacer_after_options);
let notes_title_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.notes_title_height,
};
cursor_y = cursor_y.saturating_add(heights.notes_title_height);
let notes_area = Rect {
x: area.x,
y: cursor_y,
@@ -390,26 +322,19 @@ impl RequestUserInputOverlay {
height: heights.notes_height,
};
(
progress_area,
header_area,
question_area,
options_area,
notes_title_area,
notes_area,
)
(progress_area, question_area, options_area, notes_area)
}
}
#[derive(Clone, Copy, Debug)]
struct LayoutHeights {
struct LayoutPlan {
progress_height: u16,
question_height: u16,
spacer_after_question: u16,
options_height: u16,
spacer_after_options: u16,
notes_title_height: u16,
notes_height: u16,
footer_lines: u16,
}
#[derive(Clone, Copy, Debug)]

File diff suppressed because it is too large Load Diff

View File

@@ -5,21 +5,64 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::borrow::Cow;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::measure_rows_height;
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::bottom_pane::selection_popup_common::wrap_styled_line;
use crate::render::renderable::Renderable;
use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN;
use super::DESIRED_SPACERS_BETWEEN_SECTIONS;
use super::RequestUserInputOverlay;
use super::TIP_SEPARATOR;
const MIN_OVERLAY_HEIGHT: usize = 8;
const PROGRESS_ROW_HEIGHT: usize = 1;
const SPACER_ROWS_WITH_NOTES: usize = 1;
const SPACER_ROWS_NO_OPTIONS: usize = 0;
struct UnansweredConfirmationData {
title_line: Line<'static>,
subtitle_line: Line<'static>,
hint_line: Line<'static>,
rows: Vec<crate::bottom_pane::selection_popup_common::GenericDisplayRow>,
state: ScrollState,
}
struct UnansweredConfirmationLayout {
header_lines: Vec<Line<'static>>,
hint_lines: Vec<Line<'static>>,
rows: Vec<crate::bottom_pane::selection_popup_common::GenericDisplayRow>,
state: ScrollState,
}
fn line_to_owned(line: Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.into_iter()
.map(|span| Span {
style: span.style,
content: Cow::Owned(span.content.into_owned()),
})
.collect(),
}
}
impl Renderable for RequestUserInputOverlay {
fn desired_height(&self, width: u16) -> u16 {
if self.confirm_unanswered_active() {
return self.unanswered_confirmation_height(width);
}
let outer = Rect::new(0, 0, width, u16::MAX);
let inner = menu_surface_inset(outer);
let inner_width = inner.width.max(1);
@@ -36,26 +79,29 @@ impl Renderable for RequestUserInputOverlay {
} else {
0
};
let spacer_rows = if has_options && !notes_visible {
DESIRED_SPACERS_WHEN_NOTES_HIDDEN as usize
// When notes are visible, the composer already separates options from the footer.
// Without notes, we keep extra spacing so the footer hints don't crowd the options.
let spacer_rows = if has_options {
if notes_visible {
SPACER_ROWS_WITH_NOTES
} else {
DESIRED_SPACERS_BETWEEN_SECTIONS as usize
}
} else {
0
SPACER_ROWS_NO_OPTIONS
};
let footer_height = self.footer_required_height(inner_width) as usize;
// Tight minimum height: progress + header + question + (optional) titles/options
// Tight minimum height: progress + 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
}
.saturating_add(PROGRESS_ROW_HEIGHT); // progress
height = height.saturating_add(menu_surface_padding_height() as usize);
height.max(8) as u16
height.max(MIN_OVERLAY_HEIGHT) as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -68,11 +114,145 @@ impl Renderable for RequestUserInputOverlay {
}
impl RequestUserInputOverlay {
fn unanswered_confirmation_data(&self) -> UnansweredConfirmationData {
let unanswered = self.unanswered_question_count();
let subtitle = format!(
"{unanswered} unanswered question{}",
if unanswered == 1 { "" } else { "s" }
);
UnansweredConfirmationData {
title_line: Line::from(super::UNANSWERED_CONFIRM_TITLE.bold()),
subtitle_line: Line::from(subtitle.dim()),
hint_line: standard_popup_hint_line(),
rows: self.unanswered_confirmation_rows(),
state: self.confirm_unanswered.unwrap_or_default(),
}
}
fn unanswered_confirmation_layout(&self, width: u16) -> UnansweredConfirmationLayout {
let data = self.unanswered_confirmation_data();
let content_width = width.max(1);
let mut header_lines = wrap_styled_line(&data.title_line, content_width);
let mut subtitle_lines = wrap_styled_line(&data.subtitle_line, content_width);
header_lines.append(&mut subtitle_lines);
let header_lines = header_lines.into_iter().map(line_to_owned).collect();
let hint_lines = wrap_styled_line(&data.hint_line, content_width)
.into_iter()
.map(line_to_owned)
.collect();
UnansweredConfirmationLayout {
header_lines,
hint_lines,
rows: data.rows,
state: data.state,
}
}
fn unanswered_confirmation_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 layout = self.unanswered_confirmation_layout(inner_width);
let rows_height = measure_rows_height(
&layout.rows,
&layout.state,
layout.rows.len().max(1),
inner_width.max(1),
);
let height = layout.header_lines.len() as u16
+ 1
+ rows_height
+ 1
+ layout.hint_lines.len() as u16
+ menu_surface_padding_height();
height.max(MIN_OVERLAY_HEIGHT as u16)
}
fn render_unanswered_confirmation(&self, area: Rect, buf: &mut Buffer) {
let content_area = render_menu_surface(area, buf);
if content_area.width == 0 || content_area.height == 0 {
return;
}
let width = content_area.width.max(1);
let layout = self.unanswered_confirmation_layout(width);
let mut cursor_y = content_area.y;
for line in layout.header_lines {
if cursor_y >= content_area.y + content_area.height {
return;
}
Paragraph::new(line).render(
Rect {
x: content_area.x,
y: cursor_y,
width: content_area.width,
height: 1,
},
buf,
);
cursor_y = cursor_y.saturating_add(1);
}
if cursor_y < content_area.y + content_area.height {
cursor_y = cursor_y.saturating_add(1);
}
let remaining = content_area
.height
.saturating_sub(cursor_y.saturating_sub(content_area.y));
if remaining == 0 {
return;
}
let hint_height = layout.hint_lines.len() as u16;
let spacer_before_hint = u16::from(remaining > hint_height);
let rows_height = remaining.saturating_sub(hint_height + spacer_before_hint);
let rows_area = Rect {
x: content_area.x,
y: cursor_y,
width: content_area.width,
height: rows_height,
};
render_rows(
rows_area,
buf,
&layout.rows,
&layout.state,
layout.rows.len().max(1),
"No choices",
);
cursor_y = cursor_y.saturating_add(rows_height);
if spacer_before_hint > 0 {
cursor_y = cursor_y.saturating_add(1);
}
for (offset, line) in layout.hint_lines.into_iter().enumerate() {
let y = cursor_y.saturating_add(offset as u16);
if y >= content_area.y + content_area.height {
break;
}
Paragraph::new(line).render(
Rect {
x: content_area.x,
y,
width: content_area.width,
height: 1,
},
buf,
);
}
}
/// 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;
}
if self.confirm_unanswered_active() {
self.render_unanswered_confirmation(area, buf);
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);
@@ -98,28 +278,22 @@ impl RequestUserInputOverlay {
};
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);
// Question prompt text.
let question_y = sections.question_area.y;
let answered =
self.is_question_answered(self.current_index(), &self.composer.current_text());
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(
let question_line = if answered {
Line::from(line.clone())
} else {
Line::from(line.clone()).cyan()
};
Paragraph::new(question_line).render(
Rect {
x: sections.question_area.x,
y: question_y.saturating_add(offset as u16),
@@ -134,48 +308,25 @@ impl RequestUserInputOverlay {
let option_rows = self.option_rows();
if self.has_options() {
let mut options_ui_state = self
let mut options_state = self
.current_answer()
.map(|answer| answer.options_ui_state)
.map(|answer| answer.options_state)
.unwrap_or_default();
if sections.options_area.height > 0 {
// Ensure the selected option is visible in the scroll window.
options_ui_state
options_state
.ensure_visible(option_rows.len(), sections.options_area.height as usize);
render_rows(
sections.options_area,
buf,
&option_rows,
&options_ui_state,
&options_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);
}
@@ -193,7 +344,17 @@ impl RequestUserInputOverlay {
if footer_area.height == 0 {
return;
}
let tip_lines = self.footer_tip_lines(footer_area.width);
let options_hidden = self.has_options()
&& sections.options_area.height > 0
&& self.options_required_height(content_area.width) > sections.options_area.height;
let option_tip = if options_hidden {
let selected = self.selected_option_index().unwrap_or(0).saturating_add(1);
let total = self.options_len();
Some(super::FooterTip::new(format!("option {selected}/{total}")))
} else {
None
};
let tip_lines = self.footer_tip_lines_with_prefix(footer_area.width, option_tip);
for (row_idx, tips) in tip_lines
.into_iter()
.take(footer_area.height as usize)
@@ -224,6 +385,9 @@ impl RequestUserInputOverlay {
/// Return the cursor position when editing notes, if visible.
pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> {
if self.confirm_unanswered_active() {
return None;
}
let has_options = self.has_options();
let notes_visible = self.notes_ui_visible();

View File

@@ -3,14 +3,12 @@ source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/2 (1 unanswered)
Pick one
Question 1/2 (2 unanswered)
Choose an option.
( ) Option 1 First choice.
(x) Option 2 Second choice.
( ) Option 3 Third choice.
1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
Option 2 of 3 | ↑/↓ scroll | Tab: add notes
Enter: submit answer | Ctrl+n next
Esc: interrupt
tab to add notes | enter to submit answer
ctrl + n next question | esc to interrupt

View File

@@ -4,10 +4,10 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Goal
Share details.
Type your answer (optional)
Enter: submit answer | Esc: interrupt
enter to submit answer | esc to interrupt

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
What would you like to do next?
2. Run tests Pick a crate and run its tests.
3. Review a diff Summarize or review current changes.
4. Refactor Tighten structure and remove dead code.
option 4/5 | tab to add notes | enter to submit answer | esc to interrupt

View File

@@ -4,11 +4,10 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/2 (2 unanswered)
Area
Choose an option.
( ) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Ctrl+n next | Esc: interrupt
tab to add notes | enter to submit answer | ctrl + n next question | esc to interrupt

View File

@@ -4,7 +4,6 @@ expression: "render_snapshot(&overlay, area)"
---
Question 2/2 (2 unanswered)
Goal
Share details.
Type your answer (optional)
@@ -12,4 +11,5 @@ expression: "render_snapshot(&overlay, area)"
Enter: submit all answers | Ctrl+n next | Esc: interrupt
enter to submit all | ctrl + n first question | esc to interrupt

View File

@@ -4,11 +4,10 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Area
Choose an option.
( ) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt
tab to add notes | enter to submit answer | esc to interrupt

View File

@@ -3,17 +3,17 @@ source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Area
Question 1/1 (1 unanswered)
Choose an option.
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Notes for Option 1
1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
Add notes
Option 1 of 3 | ↑/↓ scroll | Tab: clear notes | Enter: submit answer | Esc: interrupt
tab to clear notes | enter to submit answer | esc to interrupt

View File

@@ -3,13 +3,13 @@ source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Next Step
Question 1/1 (1 unanswered)
What would you like to do next?
( ) Discuss a code change (Recommended) Walk through a plan and edit code together.
( ) Run tests Pick a crate and run its tests.
( ) Review a diff Summarize or review current changes.
(x) Refactor Tighten structure and remove dead code.
1. Discuss a code change (Recommended) Walk through a plan and edit code together.
2. Run tests Pick a crate and run its tests.
3. Review a diff Summarize or review current changes.
4. Refactor Tighten structure and remove dead code.
5. Ship it Finalize and open a PR.
Option 4 of 5 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt
tab to add notes | enter to submit answer | esc to interrupt

View File

@@ -4,10 +4,10 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Area
Choose an option.
( ) Option 1 First choice.
( ) Option 2 Second choice.
1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt
tab to add notes | enter to submit answer | esc to interrupt

View File

@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Submit with unanswered questions?
2 unanswered questions
1. Proceed Submit with 2 unanswered questions.
2. Go back Return to the first unanswered question.
Press enter to confirm or esc to go back

View File

@@ -3,12 +3,11 @@ source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Next Step
Question 1/1 (1 unanswered)
Choose the next step for this task.
(x) Discuss a code change Walk through a plan, then implement it together with careful checks.
( ) Run targeted tests Pick the most relevant crate and validate the current behavior first.
( ) Review the diff Summarize the changes and highlight the most important risks and gaps.
1. Discuss a code change Walk through a plan, then implement it together with careful checks.
2. Run targeted tests Pick the most relevant crate and validate the current behavior first.
3. Review the diff Summarize the changes and highlight the most important risks and gaps.
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt
tab to add notes | enter to submit answer | esc to interrupt