diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 7ab2b79a57..24db62a396 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -94,8 +94,14 @@ impl ApprovalOverlay { ); }; let (options, title) = match &state.variant { - ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()), - ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()), + ApprovalVariant::Exec { .. } => ( + exec_options(), + "Would you like to run the following command?".to_string(), + ), + ApprovalVariant::ApplyPatch { .. } => ( + patch_options(), + "Would you like to apply these changes?".to_string(), + ), }; let items = options @@ -110,9 +116,14 @@ impl ApprovalOverlay { }) .collect(); + let footer_hint = match &state.variant { + ApprovalVariant::Exec { .. } => "Press Enter to continue".to_string(), + ApprovalVariant::ApplyPatch { .. } => "Press Enter to continue".to_string(), + }; + let params = SelectionViewParams { title, - footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()), + footer_hint: Some(footer_hint), items, header: state.header.clone(), ..Default::default() @@ -281,9 +292,8 @@ impl From for ApprovalRequestState { } let command_snippet = exec_snippet(&command); if !command_snippet.is_empty() { - header.push(HeaderLine::Text { - text: format!("Command: {command_snippet}"), - italic: false, + header.push(HeaderLine::Command { + command: command_snippet, }); header.push(HeaderLine::Spacer); } @@ -529,7 +539,7 @@ mod tests { assert!( rendered .iter() - .any(|line| line.contains("Command: echo hello world")), + .any(|line| line.contains("$ echo hello world")), "expected header to include command snippet, got {rendered:?}" ); } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index c493958272..b2f9606049 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -8,6 +8,7 @@ use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::render_rows; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; +use crate::ui_consts::LIVE_PREFIX_COLS; use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; @@ -95,7 +96,7 @@ impl CommandPopup { use super::selection_popup_common::measure_rows_height; let rows = self.rows_from_matches(self.filtered()); - measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width, LIVE_PREFIX_COLS) } /// Compute fuzzy-filtered matches over built-in commands and user prompts, @@ -212,6 +213,7 @@ impl WidgetRef for CommandPopup { MAX_POPUP_ROWS, "no matches", false, + LIVE_PREFIX_COLS, ); } } diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index e017b50410..0842efc8ea 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -7,6 +7,7 @@ use super::popup_consts::MAX_POPUP_ROWS; use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::render_rows; +use crate::ui_consts::LIVE_PREFIX_COLS; /// Visual state for the file-search popup. pub(crate) struct FileSearchPopup { @@ -146,6 +147,7 @@ impl WidgetRef for &FileSearchPopup { MAX_POPUP_ROWS, empty_message, false, + LIVE_PREFIX_COLS, ); } } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index b20d7476a7..a27a813dd3 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -11,6 +11,7 @@ use ratatui::widgets::Widget; use textwrap::wrap; use crate::app_event_sender::AppEventSender; +use crate::render::border::draw_history_border; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -26,6 +27,7 @@ pub(crate) type SelectionAction = Box; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum HeaderLine { Text { text: String, italic: bool }, + Command { command: String }, Spacer, } @@ -66,15 +68,6 @@ pub(crate) struct ListSelectionView { } impl ListSelectionView { - fn dim_prefix_span() -> Span<'static> { - "▌ ".dim() - } - - fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) { - let para = Paragraph::new(Line::from(Self::dim_prefix_span())); - para.render(area, buf); - } - pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { let mut s = Self { title: params.title, @@ -171,7 +164,7 @@ impl ListSelectionView { .filter_map(|(visible_idx, actual_idx)| { self.items.get(*actual_idx).map(|item| { let is_selected = self.state.selected_idx == Some(visible_idx); - let prefix = if is_selected { '>' } else { ' ' }; + let prefix = if is_selected { '›' } else { ' ' }; let name = item.name.as_str(); let name_with_marker = if item.is_current { format!("{name} (current)") @@ -236,8 +229,7 @@ impl ListSelectionView { if self.header.is_empty() || width == 0 { return Vec::new(); } - let prefix_width = Self::dim_prefix_span().width() as u16; - let available = width.saturating_sub(prefix_width).max(1) as usize; + let available = width.max(1) as usize; let mut lines = Vec::new(); for entry in &self.header { match entry { @@ -256,6 +248,22 @@ impl ListSelectionView { lines.push(vec![span]); } } + HeaderLine::Command { command } => { + if command.is_empty() { + lines.push(Vec::new()); + continue; + } + let prompt_width = 2usize; + let content_width = available.saturating_sub(prompt_width).max(1); + let parts = wrap(command, content_width); + for (idx, part) in parts.into_iter().enumerate() { + let mut spans = Vec::new(); + let prefix = if idx == 0 { "$ " } else { " " }; + spans.push(Span::from(prefix).dim()); + spans.push(Span::from(part.into_owned())); + lines.push(spans); + } + } } } lines @@ -264,6 +272,28 @@ impl ListSelectionView { fn header_height(&self, width: u16) -> u16 { self.header_spans_for_width(width).len() as u16 } + + fn push_line( + buf: &mut Buffer, + inner: Rect, + cursor_y: &mut u16, + inner_bottom: u16, + line: Line<'static>, + ) { + if *cursor_y >= inner_bottom { + return; + } + Paragraph::new(line).render( + Rect { + x: inner.x, + y: *cursor_y, + width: inner.width, + height: 1, + }, + buf, + ); + *cursor_y = (*cursor_y).saturating_add(1); + } } impl BottomPaneView for ListSelectionView { @@ -318,155 +348,161 @@ impl BottomPaneView for ListSelectionView { } fn desired_height(&self, width: u16) -> u16 { - // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. - // Build the same display rows used by the renderer so wrapping math matches. + let inner_width = width.saturating_sub(4); + if inner_width == 0 { + return 3; + } let rows = self.build_rows(); + let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, inner_width, 0); - let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width); - - // +1 for the title row, +1 for a spacer line beneath the header, - // +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing) - let mut height = self.header_height(width); - height = height.saturating_add(rows_height + 2); + let mut height = self.header_height(inner_width); + height = height.saturating_add(1); // title if self.is_searchable { height = height.saturating_add(1); } if self.subtitle.is_some() { - // +1 for subtitle (the spacer is accounted for above) height = height.saturating_add(1); } + height = height.saturating_add(1); // spacer between metadata and rows + height = height.saturating_add(rows_height); if self.footer_hint.is_some() { height = height.saturating_add(2); } - height + height = height.saturating_add(2); // top + bottom border + height.max(3) } fn render(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { + if area.height < 3 || area.width < 4 { return; } - let mut next_y = area.y; - let header_spans = self.header_spans_for_width(area.width); - for spans in header_spans.into_iter() { - if next_y >= area.y + area.height { - return; - } - let row = Rect { - x: area.x, - y: next_y, - width: area.width, - height: 1, - }; - let mut prefixed: Vec> = vec![Self::dim_prefix_span()]; - if spans.is_empty() { - prefixed.push(String::new().into()); - } else { - prefixed.extend(spans); - } - Paragraph::new(Line::from(prefixed)).render(row, buf); - next_y = next_y.saturating_add(1); - } - - if next_y >= area.y + area.height { + let Some(inner) = draw_history_border(buf, area) else { return; - } - - let title_area = Rect { - x: area.x, - y: next_y, - width: area.width, - height: 1, }; - Paragraph::new(Line::from(vec![ - Self::dim_prefix_span(), - self.title.clone().bold(), - ])) - .render(title_area, buf); - next_y = next_y.saturating_add(1); + if inner.width == 0 || inner.height == 0 { + return; + } - if self.is_searchable && next_y < area.y + area.height { - let search_area = Rect { - x: area.x, - y: next_y, - width: area.width, - height: 1, + let mut cursor_y = inner.y; + let inner_bottom = inner.y.saturating_add(inner.height); + + for spans in self.header_spans_for_width(inner.width) { + if cursor_y >= inner_bottom { + break; + } + let line = if spans.is_empty() { + Line::from(String::new()) + } else { + Line::from(spans) }; + Self::push_line(buf, inner, &mut cursor_y, inner_bottom, line); + } + + if cursor_y >= inner_bottom { + return; + } + + Self::push_line( + buf, + inner, + &mut cursor_y, + inner_bottom, + Line::from(self.title.clone().bold()), + ); + + if cursor_y >= inner_bottom { + return; + } + + if self.is_searchable { let query_span: Span<'static> = if self.search_query.is_empty() { self.search_placeholder .as_ref() .map(|placeholder| placeholder.clone().dim()) - .unwrap_or_else(|| "".into()) + .unwrap_or_else(|| String::new().into()) } else { self.search_query.clone().into() }; - Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span])) - .render(search_area, buf); - next_y = next_y.saturating_add(1); - } - - if let Some(sub) = &self.subtitle { - if next_y >= area.y + area.height { - return; - } - let subtitle_area = Rect { - x: area.x, - y: next_y, - width: area.width, - height: 1, - }; - Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()])) - .render(subtitle_area, buf); - next_y = next_y.saturating_add(1); - } - - if next_y >= area.y + area.height { - return; - } - let spacer_area = Rect { - x: area.x, - y: next_y, - width: area.width, - height: 1, - }; - Self::render_dim_prefix_line(spacer_area, buf); - next_y = next_y.saturating_add(1); - - let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 }; - if next_y >= area.y + area.height { - return; - } - let rows_area = Rect { - x: area.x, - y: next_y, - width: area.width, - height: area - .height - .saturating_sub(next_y.saturating_sub(area.y)) - .saturating_sub(footer_reserved), - }; - - let rows = self.build_rows(); - if rows_area.height > 0 { - render_rows( - rows_area, + Self::push_line( buf, - &rows, - &self.state, - MAX_POPUP_ROWS, - "no matches", - true, + inner, + &mut cursor_y, + inner_bottom, + Line::from(vec![query_span]), ); } + if cursor_y >= inner_bottom { + return; + } + + if let Some(sub) = &self.subtitle { + Self::push_line( + buf, + inner, + &mut cursor_y, + inner_bottom, + Line::from(sub.clone().dim()), + ); + } + + let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 }; + let mut rows_height = inner_bottom + .saturating_sub(cursor_y) + .saturating_sub(footer_reserved); + + let rows = self.build_rows(); + if !rows.is_empty() && rows_height > 0 { + let estimated_rows = + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, inner.width, 0); + + let mut rows_start = cursor_y; + if rows_height > estimated_rows && rows_height > 1 { + Self::push_line( + buf, + inner, + &mut cursor_y, + inner_bottom, + Line::from(String::new()), + ); + rows_start = cursor_y; + rows_height = rows_height.saturating_sub(1); + } + + if rows_height > 0 { + let rows_area = Rect { + x: inner.x, + y: rows_start, + width: inner.width, + height: rows_height, + }; + render_rows( + rows_area, + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no matches", + false, + 0, + ); + } + } + if let Some(hint) = &self.footer_hint { - let footer_area = Rect { - x: area.x, - y: area.y + area.height - 1, - width: area.width, - height: 1, - }; - Paragraph::new(hint.clone().dim()).render(footer_area, buf); + if inner.height > 0 && inner_bottom > 0 { + let footer_y = inner_bottom.saturating_sub(1); + Paragraph::new(hint.clone().dim()).render( + Rect { + x: inner.x, + y: footer_y, + width: inner.width, + height: 1, + }, + buf, + ); + } } } } @@ -579,6 +615,6 @@ mod tests { view.set_search_query("filters".to_string()); let lines = render_lines(&view); - assert!(lines.contains("▌ filters")); + assert!(lines.contains("filters")); } } diff --git a/codex-rs/tui/src/bottom_pane/scroll_state.rs b/codex-rs/tui/src/bottom_pane/scroll_state.rs index a9728d1a0d..b3b6ba12ab 100644 --- a/codex-rs/tui/src/bottom_pane/scroll_state.rs +++ b/codex-rs/tui/src/bottom_pane/scroll_state.rs @@ -69,6 +69,12 @@ impl ScrollState { self.scroll_top = 0; return; } + + if self.scroll_top >= len { + let clamp = visible_rows.min(len); + self.scroll_top = len.saturating_sub(clamp); + } + if let Some(sel) = self.selected_idx { if sel < self.scroll_top { self.scroll_top = sel; @@ -79,7 +85,7 @@ impl ScrollState { } } } else { - self.scroll_top = 0; + self.selected_idx = Some(self.scroll_top.min(len - 1)); } } } diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index f3b238016a..bbcec881bd 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -12,8 +12,10 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use unicode_width::UnicodeWidthChar; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; + use super::scroll_state::ScrollState; -use crate::ui_consts::LIVE_PREFIX_COLS; /// A generic representation of a display row for selection popups. pub(crate) struct GenericDisplayRow { @@ -118,13 +120,13 @@ pub(crate) fn render_rows( max_results: usize, empty_message: &str, include_border: bool, + prefix_cols: u16, ) { if include_border { use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; - // Always draw a dim left border to match other popups. let block = Block::default() .borders(Borders::LEFT) .border_type(BorderType::QuadrantOutside) @@ -132,9 +134,6 @@ pub(crate) fn render_rows( block.render(area, buf); } - // Content renders to the right of the border with the same live prefix - // padding used by the composer so the popup aligns with the input text. - let prefix_cols = LIVE_PREFIX_COLS; let content_area = Rect { x: area.x.saturating_add(prefix_cols), y: area.y, @@ -142,11 +141,13 @@ pub(crate) fn render_rows( height: area.height, }; - // Clear the padding column(s) so stale characters never peek between the - // border and the popup contents. - let padding_cols = prefix_cols.saturating_sub(1); + let padding_cols = prefix_cols.saturating_sub(if include_border { 1 } else { 0 }); if padding_cols > 0 { - let pad_start = area.x.saturating_add(1); + let pad_start = if include_border { + area.x.saturating_add(1) + } else { + area.x + }; let pad_end = pad_start .saturating_add(padding_cols) .min(area.x.saturating_add(area.width)); @@ -160,45 +161,89 @@ pub(crate) fn render_rows( } } - if rows_all.is_empty() { - if content_area.height > 0 { - let para = Paragraph::new(Line::from(empty_message.dim().italic())); - para.render( - Rect { - x: content_area.x, - y: content_area.y, - width: content_area.width, - height: 1, - }, - buf, - ); - } + if content_area.width == 0 || content_area.height == 0 { return; } - // Determine which logical rows (items) are visible given the selection and - // the max_results clamp. Scrolling is still item-based for simplicity. - let max_rows_from_area = content_area.height as usize; - let visible_items = max_results - .min(rows_all.len()) - .min(max_rows_from_area.max(1)); - - let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); - if let Some(sel) = state.selected_idx { - if sel < start_idx { - start_idx = sel; - } else if visible_items > 0 { - let bottom = start_idx + visible_items - 1; - if sel > bottom { - start_idx = sel + 1 - visible_items; - } - } + if rows_all.is_empty() { + let para = Paragraph::new(Line::from(empty_message.dim().italic())); + para.render( + Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: 1, + }, + buf, + ); + return; } - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width); + let max_rows_from_area = content_area.height as usize; + let max_items = max_results.min(rows_all.len()); + + let sel = state + .selected_idx + .unwrap_or(0) + .min(rows_all.len().saturating_sub(1)); + + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if start_idx > sel { + start_idx = sel; + } + + let (visible_items, desc_col) = loop { + let candidate_count = max_items + .min(rows_all.len().saturating_sub(start_idx)) + .max(1); + + let desc_col_candidate = + compute_desc_col(rows_all, start_idx, candidate_count, content_area.width); + + let mut used_lines = 0usize; + let mut temp_visible = 0usize; + for idx in start_idx..(start_idx + candidate_count) { + let full_line = build_full_line(&rows_all[idx], desc_col_candidate); + let options = RtOptions::new(content_area.width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(desc_col_candidate))); + let line_count = word_wrap_line(&full_line, options).len(); + + if temp_visible > 0 && used_lines + line_count > max_rows_from_area { + break; + } + + if used_lines + line_count > max_rows_from_area && temp_visible == 0 { + temp_visible = 1; + break; + } + + used_lines = used_lines.saturating_add(line_count); + temp_visible += 1; + + if used_lines >= max_rows_from_area { + break; + } + } + + if temp_visible == 0 { + temp_visible = 1; + } + + let end_idx = start_idx + temp_visible - 1; + if sel <= end_idx || start_idx == sel { + let desc = compute_desc_col(rows_all, start_idx, temp_visible, content_area.width); + break (temp_visible, desc); + } + + if start_idx >= rows_all.len().saturating_sub(1) { + let desc = compute_desc_col(rows_all, start_idx, temp_visible, content_area.width); + break (temp_visible, desc); + } + + start_idx += 1; + }; - // Render items, wrapping descriptions and aligning wrapped lines under the - // shared description column. Stop when we run out of vertical space. let mut cur_y = content_area.y; for (i, row) in rows_all .iter() @@ -210,44 +255,24 @@ pub(crate) fn render_rows( break; } - let GenericDisplayRow { - name, - match_indices, - is_current: _is_current, - description, - } = row; - - let full_line = build_full_line( - &GenericDisplayRow { - name: name.clone(), - match_indices: match_indices.clone(), - is_current: *_is_current, - description: description.clone(), - }, - desc_col, - ); - - // Wrap with subsequent indent aligned to the description column. - use crate::wrapping::RtOptions; - use crate::wrapping::word_wrap_line; + let full_line = build_full_line(row, desc_col); let options = RtOptions::new(content_area.width as usize) .initial_indent(Line::from("")) .subsequent_indent(Line::from(" ".repeat(desc_col))); let wrapped = word_wrap_line(&full_line, options); - // Render the wrapped lines. for mut line in wrapped { if cur_y >= content_area.y + content_area.height { break; } if Some(i) == state.selected_idx { - // Match previous behavior: cyan + bold for the selected row. line.style = Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD); + } else if row.is_current { + line.style = Style::default().add_modifier(Modifier::ITALIC); } - let para = Paragraph::new(line); - para.render( + Paragraph::new(line).render( Rect { x: content_area.x, y: cur_y, @@ -260,7 +285,6 @@ pub(crate) fn render_rows( } } } - /// Compute the number of terminal rows required to render up to `max_results` /// items from `rows_all` given the current scroll/selection state and the /// available `width`. Accounts for description wrapping and alignment so the @@ -270,14 +294,15 @@ pub(crate) fn measure_rows_height( state: &ScrollState, max_results: usize, width: u16, + prefix_cols: u16, ) -> u16 { if rows_all.is_empty() { - return 1; // placeholder "no matches" line + return 1; } - let content_width = width.saturating_sub(1).max(1); - + let content_width = width.saturating_sub(prefix_cols).max(1); let visible_items = max_results.min(rows_all.len()); + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); if let Some(sel) = state.selected_idx { if sel < start_idx { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap index ced53b7ce6..a21285ff73 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -1,11 +1,16 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 581 expression: render_lines(&view) --- -▌ Select Approval Mode -▌ Switch between Codex approval presets -▌ -▌ > 1. Read Only (current) Codex can read files -▌ 2. Full Access Codex can edit files - -Press Enter to confirm or Esc to go back +╭──────────────────────────────────────────────╮ +│ Select Approval Mode │ +│ Switch between Codex approval presets │ +│ │ +│ › 1. Read Only (current) Codex can read │ +│ files │ +│ 2. Full Access Codex can edit │ +│ files │ +│ │ +│ Press Enter to confirm or Esc to go back │ +╰──────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap index b9858a4307..6c4eb4f22b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -1,10 +1,15 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 572 expression: render_lines(&view) --- -▌ Select Approval Mode -▌ -▌ > 1. Read Only (current) Codex can read files -▌ 2. Full Access Codex can edit files - -Press Enter to confirm or Esc to go back +╭──────────────────────────────────────────────╮ +│ Select Approval Mode │ +│ │ +│ › 1. Read Only (current) Codex can read │ +│ files │ +│ 2. Full Access Codex can edit │ +│ files │ +│ │ +│ Press Enter to confirm or Esc to go back │ +╰──────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index 304adf547e..9c35b8aca9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -1,18 +1,21 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1200 expression: terminal.backend() --- " " -"▌ this is a test reason such as one that would be produced by the model " -"▌ " -"▌ Command: echo hello world " -"▌ " -"▌ Allow command? " -"▌ " -"▌ > 1. Approve and run now (Y) Run this command one time " -"▌ 2. Always approve this session (A) Automatically approve this command for " -"▌ the rest of the session " -"▌ 3. Cancel (N) Do not run the command " -" " -"Press Enter to confirm or Esc to cancel " +"╭──────────────────────────────────────────────────────────────────────────────╮" +"│ this is a test reason such as one that would be produced by the model │" +"│ │" +"│ $ echo hello world │" +"│ │" +"│ Would you like to run the following command? │" +"│ │" +"│ › 1. Approve and run now (Y) Run this command one time │" +"│ 2. Always approve this session (A) Automatically approve this command for │" +"│ the rest of the session │" +"│ 3. Cancel (N) Do not run the command │" +"│ │" +"│ Press Enter to continue │" +"╰──────────────────────────────────────────────────────────────────────────────╯" " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 239681acb0..ffd2b64363 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -1,16 +1,19 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1227 expression: terminal.backend() --- " " -"▌ Command: echo hello world " -"▌ " -"▌ Allow command? " -"▌ " -"▌ > 1. Approve and run now (Y) Run this command one time " -"▌ 2. Always approve this session (A) Automatically approve this command for " -"▌ the rest of the session " -"▌ 3. Cancel (N) Do not run the command " -" " -"Press Enter to confirm or Esc to cancel " +"╭──────────────────────────────────────────────────────────────────────────────╮" +"│ $ echo hello world │" +"│ │" +"│ Would you like to run the following command? │" +"│ │" +"│ › 1. Approve and run now (Y) Run this command one time │" +"│ 2. Always approve this session (A) Automatically approve this command for │" +"│ the rest of the session │" +"│ 3. Cancel (N) Do not run the command │" +"│ │" +"│ Press Enter to continue │" +"╰──────────────────────────────────────────────────────────────────────────────╯" " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 2ee14a57f2..6e6aebdd2a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -1,16 +1,19 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1262 expression: terminal.backend() --- " " -"▌ The model wants to apply changes " -"▌ " -"▌ Grant write access to /tmp for the remainder of this session. " -"▌ " -"▌ Apply changes? " -"▌ " -"▌ > 1. Approve (Y) Apply the proposed changes " -"▌ 2. Cancel (N) Do not apply the changes " -" " -"Press Enter to confirm or Esc to cancel " +"╭──────────────────────────────────────────────────────────────────────────────╮" +"│ The model wants to apply changes │" +"│ │" +"│ Grant write access to /tmp for the remainder of this session. │" +"│ │" +"│ Would you like to apply these changes? │" +"│ │" +"│ › 1. Approve (Y) Apply the proposed changes │" +"│ 2. Cancel (N) Do not apply the changes │" +"│ │" +"│ Press Enter to continue │" +"╰──────────────────────────────────────────────────────────────────────────────╯" " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index e762896fd7..9da9986a82 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,18 +1,21 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1429 expression: terminal.backend() --- " " -"▌ this is a test reason such as one that would be produced by the model " -"▌ " -"▌ Command: echo 'hello world' " -"▌ " -"▌ Allow command? " -"▌ " -"▌ > 1. Approve and run now (Y) Run this command one time " -"▌ 2. Always approve this session (A) Automatically approve this command for " -"▌ the rest of the session " -"▌ 3. Cancel (N) Do not run the command " -" " -"Press Enter to confirm or Esc to cancel " +"╭──────────────────────────────────────────────────────────────────────────────╮" +"│ this is a test reason such as one that would be produced by the model │" +"│ │" +"│ $ echo 'hello world' │" +"│ │" +"│ Would you like to run the following command? │" +"│ │" +"│ › 1. Approve and run now (Y) Run this command one time │" +"│ 2. Always approve this session (A) Automatically approve this command for │" +"│ the rest of the session │" +"│ 3. Cancel (N) Do not run the command │" +"│ │" +"│ Press Enter to continue │" +"╰──────────────────────────────────────────────────────────────────────────────╯" " " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f3799ad336..c1ab55921e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -933,18 +933,31 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); (chat).render_ref(area, &mut buf); - let mut row = String::new(); - // Row 0 is the top spacer for the bottom pane; row 1 contains the header line - let y = 1u16.min(height.saturating_sub(1)); - for x in 0..area.width { - let s = buf[(x, y)].symbol(); - if s.is_empty() { - row.push(' '); - } else { - row.push_str(s); + + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + let s = buf[(x, y)].symbol(); + if s.is_empty() { + row.push(' '); + } else { + row.push_str(s); + } + } + if row.chars().any(|c| { + !c.is_whitespace() + && c != '╭' + && c != '╮' + && c != '╯' + && c != '╰' + && c != '─' + && c != '│' + }) { + return row; } } - row + + String::new() } #[test] @@ -1764,14 +1777,14 @@ fn apply_patch_untrusted_shows_approval_modal() { for x in 0..area.width { row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } - if row.contains("Apply changes?") { + if row.contains("Would you like to apply these changes?") { contains_title = true; break; } } assert!( contains_title, - "expected approval modal to be visible with title 'Apply changes?'" + "expected approval modal to be visible with title 'Would you like to apply these changes?'" ); } diff --git a/codex-rs/tui/src/render/border.rs b/codex-rs/tui/src/render/border.rs new file mode 100644 index 0000000000..32a0882cd0 --- /dev/null +++ b/codex-rs/tui/src/render/border.rs @@ -0,0 +1,82 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::style::Style; + +/// Draw the standard Codex rounded border into `buf` and return the interior +/// rectangle where content should render. The border mirrors the appearance of +/// `history_cell::with_border`, including one column of padding on each side. +pub(crate) fn draw_history_border(buf: &mut Buffer, area: Rect) -> Option { + if area.width < 4 || area.height < 3 { + return None; + } + + let dim_style = Style::default().add_modifier(Modifier::DIM); + + let left = area.x; + let right = area.x + area.width - 1; + let top = area.y; + let bottom = area.y + area.height - 1; + + if let Some(cell) = buf.cell_mut((left, top)) { + cell.set_symbol("╭"); + cell.set_style(dim_style); + } + for x in left + 1..right { + if let Some(cell) = buf.cell_mut((x, top)) { + cell.set_symbol("─"); + cell.set_style(dim_style); + } + } + if let Some(cell) = buf.cell_mut((right, top)) { + cell.set_symbol("╮"); + cell.set_style(dim_style); + } + + if let Some(cell) = buf.cell_mut((left, bottom)) { + cell.set_symbol("╰"); + cell.set_style(dim_style); + } + for x in left + 1..right { + if let Some(cell) = buf.cell_mut((x, bottom)) { + cell.set_symbol("─"); + cell.set_style(dim_style); + } + } + if let Some(cell) = buf.cell_mut((right, bottom)) { + cell.set_symbol("╯"); + cell.set_style(dim_style); + } + + for y in top + 1..bottom { + if let Some(cell) = buf.cell_mut((left, y)) { + cell.set_symbol("│"); + cell.set_style(dim_style); + } + if let Some(cell) = buf.cell_mut((left + 1, y)) { + cell.set_symbol(" "); + cell.set_style(dim_style); + } + for x in left + 2..right - 1 { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_symbol(" "); + cell.set_style(Style::default()); + } + } + if let Some(cell) = buf.cell_mut((right - 1, y)) { + cell.set_symbol(" "); + cell.set_style(dim_style); + } + if let Some(cell) = buf.cell_mut((right, y)) { + cell.set_symbol("│"); + cell.set_style(dim_style); + } + } + + Some(Rect { + x: area.x + 2, + y: area.y + 1, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(2), + }) +} diff --git a/codex-rs/tui/src/render/mod.rs b/codex-rs/tui/src/render/mod.rs index e457423f4e..78c0b4fab0 100644 --- a/codex-rs/tui/src/render/mod.rs +++ b/codex-rs/tui/src/render/mod.rs @@ -1,2 +1,3 @@ +pub mod border; pub mod highlight; pub mod line_utils;