Skip to content

TUI: request_user_input options render vertically when option label is long (description column collapses to 1) #11093

@0xdeafbeef

Description

@0xdeafbeef

What version of Codex CLI is running?

codex-cli 0.98.0

What subscription do you have?

doesn't matter

Which model were you using?

No response

What platform is your computer?

No response

What terminal emulator and version are you using (if applicable)?

No response

What issue are you seeing?

Image

Summary

In the request_user_input overlay, if an option label (left column) is very long, the shared selection-row renderer computes the description start column so far to the right that only 1 terminal cell remains for the description. With wrapping enabled, that forces the description to wrap one character per line, producing the vertical right-edge text seen in the screenshot.

Where This Happens (commit b3de6c7)

request_user_input builds GenericDisplayRow where the option label is name and the option description is description:

The overlay renders option rows through the shared selection renderer (render_rows):

Shared selection-row renderer computes desc_col as min(max_name_width + 2, content_width - 1), which can leave only 1 cell for description:

  • fn compute_desc_col(
    rows_all: &[GenericDisplayRow],
    start_idx: usize,
    visible_items: usize,
    content_width: u16,
    col_width_mode: ColumnWidthMode,
    ) -> usize {
    if content_width <= 1 {
    return 0;
    }
    let max_desc_col = content_width.saturating_sub(1) as usize;
    match col_width_mode {
    ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR)
    / FIXED_LEFT_COLUMN_DENOMINATOR)
    .clamp(1, max_desc_col),
    ColumnWidthMode::AutoVisible | ColumnWidthMode::AutoAllRows => {
    let max_name_width = match col_width_mode {
    ColumnWidthMode::AutoVisible => rows_all
    .iter()
    .enumerate()
    .skip(start_idx)
    .take(visible_items)
    .map(|(_, row)| {
    let mut spans: Vec<Span> = vec![row.name.clone().into()];
    if row.disabled_reason.is_some() {
    spans.push(" (disabled)".dim());
    }
    Line::from(spans).width()
    })
    .max()
    .unwrap_or(0),
    ColumnWidthMode::AutoAllRows => rows_all
    .iter()
    .map(|row| {
    let mut spans: Vec<Span> = vec![row.name.clone().into()];
    if row.disabled_reason.is_some() {
    spans.push(" (disabled)".dim());
    }
    Line::from(spans).width()
    })
    .max()
    .unwrap_or(0),
    ColumnWidthMode::Fixed => 0,
    };
    max_name_width.saturating_add(2).min(max_desc_col)
    }
    }
    }
  • max_name_width.saturating_add(2).min(max_desc_col)

When a description exists, wrapping indents continuation lines to desc_col:

  • /// Determine how many spaces to indent wrapped lines for a row.
    fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize {
    let max_indent = max_width.saturating_sub(1) as usize;
    let indent = row.wrap_indent.unwrap_or_else(|| {
    if row.description.is_some() || row.disabled_reason.is_some() {
    desc_col
    } else {
    0
    }
    });
    indent.min(max_indent)
    }
  • // Wrap with subsequent indent aligned to the description column.
    use crate::wrapping::RtOptions;
    use crate::wrapping::word_wrap_line;
    let continuation_indent = wrap_indent(row, desc_col, area.width);
    let options = RtOptions::new(area.width as usize)
    .initial_indent(Line::from(""))
    .subsequent_indent(Line::from(" ".repeat(continuation_indent)));
    let wrapped = word_wrap_line(&full_line, options);

word_wrap_line clamps the available width after indent to at least 1 cell:

  • // Wrap the remainder using subsequent indent width and map back to original indices.
    let base = first_line_range.end;
    let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
    let base = base + skip_leading_spaces;
    let subsequent_width_available = opts
    .width
    .saturating_sub(rt_opts.subsequent_indent.width())
    .max(1);
    let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
    for r in &remaining_wrapped {

Default wrapping options enable break_words: true, so a 1-cell width becomes 1-char-per-line output:

  • #[allow(dead_code)]
    impl<'a> RtOptions<'a> {
    pub fn new(width: usize) -> Self {
    RtOptions {
    width,
    line_ending: textwrap::LineEnding::LF,
    initial_indent: Line::default(),
    subsequent_indent: Line::default(),
    break_words: true,
    word_separator: textwrap::WordSeparator::new(),
    wrap_algorithm: textwrap::WrapAlgorithm::FirstFit,
    word_splitter: textwrap::WordSplitter::HyphenSplitter,
    }

Repro (Minimal Condition)

Let W = content_width passed to compute_desc_col.

If the widest visible row.name has display width >= W - 3, then:

  • desc_col becomes W - 1.
  • Remaining description width becomes W - desc_col = 1.
  • Description wraps as 1 character per line at the far right edge.

This is easy to hit with long option labels (especially when the menu surface inset reduces available width).

Expected

The description area should keep a reasonable minimum width (or fall back to a single-column layout, render description on the next line, or truncate), but not collapse to 1 terminal cell.

Actual

Description wraps vertically (one character per line), making the UI unreadable.

Notes On Tests

Existing request_user_input snapshots use relatively short labels and do not cover this edge case.

What steps can reproduce the bug?

What is the expected behavior?

No response

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    TUIIssues related to the terminal user interface: text input, menus and dialogs, and terminal displaybugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions