-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Description
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?
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:
pub(super) fn option_rows(&self) -> Vec<GenericDisplayRow> { codex/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs
Lines 283 to 286 in b3de6c7
GenericDisplayRow { name: format!("{prefix} {number}. {label}"), description: Some(opt.description.clone()), ..Default::default()
The overlay renders option rows through the shared selection renderer (render_rows):
// Build rows with selection markers for 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:
codex/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Lines 200 to 249 in b3de6c7
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:
codex/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Lines 251 to 262 in b3de6c7
/// 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) } codex/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Lines 419 to 426 in b3de6c7
// 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:
codex/codex-rs/tui/src/wrapping.rs
Lines 206 to 215 in b3de6c7
// 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:
codex/codex-rs/tui/src/wrapping.rs
Lines 84 to 96 in b3de6c7
#[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_colbecomesW - 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