Ask user question UI footer improvements (#9949)

## 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>
This commit is contained in:
Charley Cunningham 2026-01-27 14:57:07 -08:00 committed by GitHub
parent 3ae966edd8
commit 19d8f71a98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1578 additions and 344 deletions

View file

@ -232,7 +232,6 @@ impl ChatComposerConfig {
}
}
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
@ -394,7 +393,6 @@ impl ChatComposer {
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
self.personality_command_enabled = enabled;
}
/// Centralized feature gating keeps config checks out of call sites.
fn popups_enabled(&self) -> bool {
self.config.popups_enabled

View file

@ -1,12 +1,12 @@
use ratatui::layout::Rect;
use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN;
use super::RequestUserInputOverlay;
pub(super) struct LayoutSections {
pub(super) progress_area: Rect,
pub(super) header_area: Rect,
pub(super) question_area: Rect,
pub(super) answer_title_area: Rect,
// Wrapped question text lines to render in the question area.
pub(super) question_lines: Vec<String>,
pub(super) options_area: Rect,
@ -20,7 +20,8 @@ impl RequestUserInputOverlay {
/// Compute layout sections, collapsing notes and hints as space shrinks.
pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections {
let has_options = self.has_options();
let footer_pref = if self.unanswered_count() > 0 { 2 } else { 1 };
let notes_visible = !has_options || self.notes_ui_visible();
let footer_pref = self.footer_required_height(area.width);
let notes_pref_height = self.notes_input_height(area.width);
let mut question_lines = self.wrapped_question_lines(area.width);
let question_height = question_lines.len() as u16;
@ -28,18 +29,22 @@ impl RequestUserInputOverlay {
let (
question_height,
progress_height,
answer_title_height,
spacer_after_question,
options_height,
spacer_after_options,
notes_title_height,
notes_height,
options_height,
footer_lines,
) = if has_options {
self.layout_with_options(
area.height,
area.width,
question_height,
notes_pref_height,
footer_pref,
OptionsLayoutArgs {
available_height: area.height,
width: area.width,
question_height,
notes_pref_height,
footer_pref,
notes_visible,
},
&mut question_lines,
)
} else {
@ -52,31 +57,24 @@ impl RequestUserInputOverlay {
)
};
let (
progress_area,
header_area,
question_area,
answer_title_area,
options_area,
notes_title_area,
notes_area,
) = self.build_layout_areas(
area,
LayoutHeights {
progress_height,
question_height,
answer_title_height,
options_height,
notes_title_height,
notes_height,
},
);
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,
},
);
LayoutSections {
progress_area,
header_area,
question_area,
answer_title_area,
question_lines,
options_area,
notes_title_area,
@ -90,21 +88,28 @@ 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, answer_title_height, notes_title_height, notes_height, options_height, footer_lines)
/// 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,
available_height: u16,
width: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
args: OptionsLayoutArgs,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16) {
let options_required_height = self.options_required_height(width);
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let OptionsLayoutArgs {
available_height,
width,
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_required_height);
.saturating_add(options_heights.preferred);
if required > available_height {
self.layout_with_options_tight(
@ -115,11 +120,14 @@ impl RequestUserInputOverlay {
)
} else {
self.layout_with_options_normal(
available_height,
question_height,
options_required_height,
notes_pref_height,
footer_pref,
OptionsNormalArgs {
available_height,
question_height,
notes_pref_height,
footer_pref,
notes_visible,
},
options_heights,
)
}
}
@ -132,7 +140,7 @@ impl RequestUserInputOverlay {
question_height: u16,
min_options_height: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16) {
) -> (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);
@ -140,42 +148,46 @@ impl RequestUserInputOverlay {
let options_height =
available_height.saturating_sub(1u16.saturating_add(adjusted_question_height));
(adjusted_question_height, 0, 0, 0, 0, options_height, 0)
(adjusted_question_height, 0, 0, options_height, 0, 0, 0, 0)
}
/// Normal layout for options case: allocate space for all elements with
/// preference order: notes, footer, labels, then progress.
/// Normal layout for options case: allocate footer + progress first, and
/// only allocate notes (and its label) when explicitly visible.
fn layout_with_options_normal(
&self,
available_height: u16,
question_height: u16,
options_required_height: u16,
notes_pref_height: u16,
footer_pref: u16,
) -> (u16, u16, u16, u16, u16, u16, u16) {
let options_height = options_required_height;
args: OptionsNormalArgs,
options: OptionsHeights,
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let OptionsNormalArgs {
available_height,
question_height,
notes_pref_height,
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 mut remaining = available_height.saturating_sub(used);
// Prefer notes next, then footer, then labels, with progress last.
let mut notes_height = notes_pref_height.min(remaining);
remaining = remaining.saturating_sub(notes_height);
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
let mut answer_title_height = 0;
if remaining > 0 {
answer_title_height = 1;
remaining = remaining.saturating_sub(1);
}
let mut notes_title_height = 0;
if remaining > 0 {
notes_title_height = 1;
remaining = remaining.saturating_sub(1);
// 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
} else {
DESIRED_SPACERS_WHEN_NOTES_HIDDEN
};
let required_extra = footer_pref
.saturating_add(1) // progress line
.saturating_add(desired_spacers);
if remaining < required_extra {
let deficit = required_extra.saturating_sub(remaining);
let reducible = options_height.saturating_sub(min_options_height);
let reduce_by = deficit.min(reducible);
options_height = options_height.saturating_sub(reduce_by);
remaining = remaining.saturating_add(reduce_by);
}
let mut progress_height = 0;
@ -184,16 +196,58 @@ impl RequestUserInputOverlay {
remaining = remaining.saturating_sub(1);
}
// Expand the notes composer with any leftover rows.
if !notes_visible {
let mut spacer_after_options = 0;
if remaining > footer_pref {
spacer_after_options = 1;
remaining = remaining.saturating_sub(1);
}
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
let mut spacer_after_question = 0;
if remaining > 0 {
spacer_after_question = 1;
remaining = remaining.saturating_sub(1);
}
let grow_by = remaining.min(options.full.saturating_sub(options_height));
options_height = options_height.saturating_add(grow_by);
return (
question_height,
progress_height,
spacer_after_question,
options_height,
spacer_after_options,
0,
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;
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);
(
question_height,
progress_height,
answer_title_height,
spacer_after_question,
options_height,
spacer_after_options,
notes_title_height,
notes_height,
options_height,
footer_lines,
)
}
@ -203,7 +257,7 @@ 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, answer_title_height, notes_title_height, notes_height, options_height, footer_lines)
/// 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,
@ -211,7 +265,7 @@ impl RequestUserInputOverlay {
notes_pref_height: u16,
footer_pref: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16) {
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let required = 1u16.saturating_add(question_height);
if required > available_height {
self.layout_without_options_tight(available_height, question_height, question_lines)
@ -231,12 +285,12 @@ impl RequestUserInputOverlay {
available_height: u16,
question_height: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16) {
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let max_question_height = available_height.saturating_sub(1);
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)
(adjusted_question_height, 0, 0, 0, 0, 0, 0, 0)
}
/// Normal layout for no-options case: allocate space for notes, footer, and progress.
@ -246,7 +300,7 @@ impl RequestUserInputOverlay {
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
) -> (u16, u16, u16, u16, u16, u16, u16) {
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
let required = 1u16.saturating_add(question_height);
let mut remaining = available_height.saturating_sub(required);
let mut notes_height = notes_pref_height.min(remaining);
@ -268,8 +322,9 @@ impl RequestUserInputOverlay {
progress_height,
0,
0,
notes_height,
0,
0,
notes_height,
footer_lines,
)
}
@ -283,7 +338,6 @@ impl RequestUserInputOverlay {
Rect, // progress_area
Rect, // header_area
Rect, // question_area
Rect, // answer_title_area
Rect, // options_area
Rect, // notes_title_area
Rect, // notes_area
@ -311,14 +365,8 @@ impl RequestUserInputOverlay {
height: heights.question_height,
};
cursor_y = cursor_y.saturating_add(heights.question_height);
cursor_y = cursor_y.saturating_add(heights.spacer_after_question);
let answer_title_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.answer_title_height,
};
cursor_y = cursor_y.saturating_add(heights.answer_title_height);
let options_area = Rect {
x: area.x,
y: cursor_y,
@ -326,6 +374,7 @@ impl RequestUserInputOverlay {
height: heights.options_height,
};
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,
@ -345,7 +394,6 @@ impl RequestUserInputOverlay {
progress_area,
header_area,
question_area,
answer_title_area,
options_area,
notes_title_area,
notes_area,
@ -357,8 +405,34 @@ impl RequestUserInputOverlay {
struct LayoutHeights {
progress_height: u16,
question_height: u16,
answer_title_height: u16,
spacer_after_question: u16,
options_height: u16,
spacer_after_options: u16,
notes_title_height: u16,
notes_height: u16,
}
#[derive(Clone, Copy, Debug)]
struct OptionsLayoutArgs {
available_height: u16,
width: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
notes_visible: bool,
}
#[derive(Clone, Copy, Debug)]
struct OptionsNormalArgs {
available_height: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
notes_visible: bool,
}
#[derive(Clone, Copy, Debug)]
struct OptionsHeights {
preferred: u16,
full: u16,
}

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,58 @@
use crossterm::event::KeyCode;
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::key_hint;
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 = self.options_required_height(inner_width) as usize;
let notes_height = self.notes_input_height(inner_width) as usize;
let footer_height = if self.unanswered_count() > 0 { 2 } else { 1 };
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 self.has_options() {
height = height
.saturating_add(1) // answer title
.saturating_add(1); // notes title
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
@ -63,12 +80,19 @@ impl RequestUserInputOverlay {
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();
Line::from(format!("Question {idx}/{total}").dim())
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())
};
@ -76,8 +100,13 @@ impl RequestUserInputOverlay {
// 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 {
Line::from(header.bold())
if answered {
Line::from(header.bold())
} else {
Line::from(header.cyan().bold())
}
} else {
Line::from("No questions".dim())
};
@ -101,54 +130,45 @@ impl RequestUserInputOverlay {
);
}
if sections.answer_title_area.height > 0 {
let answer_label = "Answer";
let answer_title = if self.focus_is_options() || self.focus_is_notes_without_options() {
answer_label.cyan().bold()
} else {
answer_label.dim()
};
Paragraph::new(Line::from(answer_title)).render(sections.answer_title_area, buf);
}
// Build rows with selection markers for the shared selection renderer.
let option_rows = self.option_rows();
if self.has_options() {
let mut option_state = self
let mut options_ui_state = self
.current_answer()
.map(|answer| answer.option_state)
.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.
option_state
options_ui_state
.ensure_visible(option_rows.len(), sections.options_area.height as usize);
render_rows(
sections.options_area,
buf,
&option_rows,
&option_state,
&options_ui_state,
option_rows.len().max(1),
"No options",
);
}
}
if sections.notes_title_area.height > 0 {
if notes_visible && sections.notes_title_area.height > 0 {
let notes_label = if self.has_options()
&& self
.current_answer()
.is_some_and(|answer| answer.selected.is_some())
.is_some_and(|answer| answer.committed_option_idx.is_some())
{
if let Some(label) = self.current_option_label() {
format!("Notes for {label} (optional)")
format!("Notes for {label}")
} else {
"Notes (optional)".to_string()
"Notes".to_string()
}
} else {
"Notes (optional)".to_string()
"Notes".to_string()
};
let notes_title = if self.focus_is_notes() {
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()
@ -156,7 +176,7 @@ impl RequestUserInputOverlay {
Paragraph::new(Line::from(notes_title)).render(sections.notes_title_area, buf);
}
if sections.notes_area.height > 0 {
if notes_visible && sections.notes_area.height > 0 {
self.render_notes_input(sections.notes_area, buf);
}
@ -164,69 +184,55 @@ impl RequestUserInputOverlay {
.notes_area
.y
.saturating_add(sections.notes_area.height);
if sections.footer_lines == 2 {
// Status line for unanswered count when any question is empty.
let warning = format!(
"Unanswered: {} | Will submit as skipped",
self.unanswered_count()
);
Paragraph::new(Line::from(warning.dim())).render(
Rect {
x: content_area.x,
y: footer_y,
width: content_area.width,
height: 1,
},
buf,
);
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 hint_y = footer_y.saturating_add(sections.footer_lines.saturating_sub(1));
// Footer hints (selection index + navigation keys).
let mut hint_spans = Vec::new();
if self.has_options() {
let options_len = self.options_len();
let option_index = self.selected_option_index().map_or(0, |idx| idx + 1);
hint_spans.extend(vec![
format!("Option {option_index} of {options_len}").into(),
" | ".into(),
]);
}
hint_spans.extend(vec![
key_hint::plain(KeyCode::Up).into(),
"/".into(),
key_hint::plain(KeyCode::Down).into(),
" scroll | ".into(),
key_hint::plain(KeyCode::Enter).into(),
" next question | ".into(),
]);
if self.question_count() > 1 {
hint_spans.extend(vec![
key_hint::plain(KeyCode::PageUp).into(),
" prev | ".into(),
key_hint::plain(KeyCode::PageDown).into(),
" next | ".into(),
]);
}
hint_spans.extend(vec![
key_hint::plain(KeyCode::Esc).into(),
" interrupt".into(),
]);
Paragraph::new(Line::from(hint_spans).dim()).render(
Rect {
x: content_area.x,
y: hint_y,
width: content_area.width,
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,
},
buf,
);
};
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;
@ -246,16 +252,118 @@ impl RequestUserInputOverlay {
}
self.composer.render(area, buf);
}
fn focus_is_options(&self) -> bool {
matches!(self.focus, super::Focus::Options)
}
fn focus_is_notes(&self) -> bool {
matches!(self.focus, super::Focus::Notes)
}
fn focus_is_notes_without_options(&self) -> bool {
!self.has_options() && self.focus_is_notes()
}
}
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)
}

View file

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

View file

@ -2,12 +2,12 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Goal
Share details.
Type your answer (optional)
Unanswered: 1 | Will submit as skipped
↑/↓ scroll | enter next question | esc interrupt
Question 1/1 (1 unanswered)
Goal
Share details.
Type your answer (optional)
Enter: submit answer | Esc: interrupt

View file

@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
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.
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Ctrl+n next | Esc: interrupt

View file

@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 2/2 (2 unanswered)
Goal
Share details.
Type your answer (optional)
Enter: submit all answers | Ctrl+n next | Esc: interrupt

View file

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

View file

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

View file

@ -2,13 +2,14 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Next Step
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.
Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt
Question 1/1
Next Step
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.
Option 4 of 5 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt

View file

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

View file

@ -2,20 +2,13 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Next Step
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.
Option 1 of 3 | ↑/↓ scroll | enter next question
Question 1/1
Next Step
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.
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt