From 357e4c902b64f3593e560cfa625b05e64931576b Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 7 Jan 2026 16:44:28 -0800 Subject: [PATCH] add footer note to TUI (#8867) This will be used by the elevated sandbox NUX to give a hint on how to run the elevated sandbox when in the non-elevated mode. --- .../src/bottom_pane/list_selection_view.rs | 101 +++++++++++++++--- .../src/bottom_pane/selection_popup_common.rs | 11 ++ ...sts__list_selection_footer_note_wraps.snap | 14 +++ .../src/bottom_pane/list_selection_view.rs | 101 +++++++++++++++--- .../src/bottom_pane/selection_popup_common.rs | 11 ++ ...sts__list_selection_footer_note_wraps.snap | 14 +++ 6 files changed, 226 insertions(+), 26 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap 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 e387b6686..40787a9c2 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -13,6 +13,7 @@ use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; use crate::render::Insets; @@ -50,6 +51,7 @@ pub(crate) struct SelectionItem { pub(crate) struct SelectionViewParams { pub title: Option, pub subtitle: Option, + pub footer_note: Option>, pub footer_hint: Option>, pub items: Vec, pub is_searchable: bool, @@ -63,6 +65,7 @@ impl Default for SelectionViewParams { Self { title: None, subtitle: None, + footer_note: None, footer_hint: None, items: Vec::new(), is_searchable: false, @@ -74,6 +77,7 @@ impl Default for SelectionViewParams { } pub(crate) struct ListSelectionView { + footer_note: Option>, footer_hint: Option>, items: Vec, state: ScrollState, @@ -101,6 +105,7 @@ impl ListSelectionView { ])); } let mut s = Self { + footer_note: params.footer_note, footer_hint: params.footer_hint, items: params.items, state: ScrollState::new(), @@ -434,6 +439,11 @@ impl Renderable for ListSelectionView { if self.is_searchable { height = height.saturating_add(1); } + if let Some(note) = &self.footer_note { + let note_width = width.saturating_sub(2); + let note_lines = wrap_styled_line(note, note_width); + height = height.saturating_add(note_lines.len() as u16); + } if self.footer_hint.is_some() { height = height.saturating_add(1); } @@ -445,11 +455,15 @@ impl Renderable for ListSelectionView { return; } - let [content_area, footer_area] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), - ]) - .areas(area); + let note_width = area.width.saturating_sub(2); + let note_lines = self + .footer_note + .as_ref() + .map(|note| wrap_styled_line(note, note_width)); + let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); + let footer_rows = note_height + u16::from(self.footer_hint.is_some()); + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); Block::default() .style(user_message_style()) @@ -517,14 +531,43 @@ impl Renderable for ListSelectionView { ); } - if let Some(hint) = &self.footer_hint { - let hint_area = Rect { - x: footer_area.x + 2, - y: footer_area.y, - width: footer_area.width.saturating_sub(2), - height: footer_area.height, - }; - hint.clone().dim().render(hint_area, buf); + if footer_area.height > 0 { + let [note_area, hint_area] = Layout::vertical([ + Constraint::Length(note_height), + Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), + ]) + .areas(footer_area); + + if let Some(lines) = note_lines { + let note_area = Rect { + x: note_area.x + 2, + y: note_area.y, + width: note_area.width.saturating_sub(2), + height: note_area.height, + }; + for (idx, line) in lines.iter().enumerate() { + if idx as u16 >= note_area.height { + break; + } + let line_area = Rect { + x: note_area.x, + y: note_area.y + idx as u16, + width: note_area.width, + height: 1, + }; + line.clone().render(line_area, buf); + } + } + + if let Some(hint) = &self.footer_hint { + let hint_area = Rect { + x: hint_area.x + 2, + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + hint.clone().dim().render(hint_area, buf); + } } } } @@ -611,6 +654,38 @@ mod tests { assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } + #[test] + fn snapshot_footer_note_wraps() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }]; + let footer_note = Line::from(vec![ + "Note: ".dim(), + "Use /setup-elevated-sandbox".cyan(), + " to allow network access.".dim(), + ]); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_note: Some(footer_note), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_footer_note_wraps", + render_lines_with_width(&view, 40) + ); + } + #[test] fn renders_search_query_line_when_enabled() { let (tx_raw, _rx) = unbounded_channel::(); 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 48adef9b2..c1675b01a 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -26,6 +26,17 @@ pub(crate) struct GenericDisplayRow { pub wrap_indent: Option, // optional indent for wrapped lines } +pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + + let width = width.max(1) as usize; + let opts = RtOptions::new(width) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from("")); + word_wrap_line(line, opts) +} + fn line_width(line: &Line<'_>) -> usize { line.iter() .map(|span| UnicodeWidthStr::width(span.content.as_ref())) diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap new file mode 100644 index 000000000..ca571ae0e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 683 +expression: "render_lines_with_width(&view, 40)" +--- + + Select Approval Mode + +› 1. Read Only (current) Codex can + read files + + Note: Use /setup-elevated-sandbox to + allow network access. + Press enter to confirm or esc to go ba diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs index 46d6daac6..27c7dc423 100644 --- a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs @@ -13,6 +13,7 @@ use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; use crate::render::Insets; @@ -49,6 +50,7 @@ pub(crate) struct SelectionItem { pub(crate) struct SelectionViewParams { pub title: Option, pub subtitle: Option, + pub footer_note: Option>, pub footer_hint: Option>, pub items: Vec, pub is_searchable: bool, @@ -62,6 +64,7 @@ impl Default for SelectionViewParams { Self { title: None, subtitle: None, + footer_note: None, footer_hint: None, items: Vec::new(), is_searchable: false, @@ -73,6 +76,7 @@ impl Default for SelectionViewParams { } pub(crate) struct ListSelectionView { + footer_note: Option>, footer_hint: Option>, items: Vec, state: ScrollState, @@ -100,6 +104,7 @@ impl ListSelectionView { ])); } let mut s = Self { + footer_note: params.footer_note, footer_hint: params.footer_hint, items: params.items, state: ScrollState::new(), @@ -391,6 +396,11 @@ impl Renderable for ListSelectionView { if self.is_searchable { height = height.saturating_add(1); } + if let Some(note) = &self.footer_note { + let note_width = width.saturating_sub(2); + let note_lines = wrap_styled_line(note, note_width); + height = height.saturating_add(note_lines.len() as u16); + } if self.footer_hint.is_some() { height = height.saturating_add(1); } @@ -402,11 +412,15 @@ impl Renderable for ListSelectionView { return; } - let [content_area, footer_area] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), - ]) - .areas(area); + let note_width = area.width.saturating_sub(2); + let note_lines = self + .footer_note + .as_ref() + .map(|note| wrap_styled_line(note, note_width)); + let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); + let footer_rows = note_height + u16::from(self.footer_hint.is_some()); + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); Block::default() .style(user_message_style()) @@ -474,14 +488,43 @@ impl Renderable for ListSelectionView { ); } - if let Some(hint) = &self.footer_hint { - let hint_area = Rect { - x: footer_area.x + 2, - y: footer_area.y, - width: footer_area.width.saturating_sub(2), - height: footer_area.height, - }; - hint.clone().dim().render(hint_area, buf); + if footer_area.height > 0 { + let [note_area, hint_area] = Layout::vertical([ + Constraint::Length(note_height), + Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), + ]) + .areas(footer_area); + + if let Some(lines) = note_lines { + let note_area = Rect { + x: note_area.x + 2, + y: note_area.y, + width: note_area.width.saturating_sub(2), + height: note_area.height, + }; + for (idx, line) in lines.iter().enumerate() { + if idx as u16 >= note_area.height { + break; + } + let line_area = Rect { + x: note_area.x, + y: note_area.y + idx as u16, + width: note_area.width, + height: 1, + }; + line.clone().render(line_area, buf); + } + } + + if let Some(hint) = &self.footer_hint { + let hint_area = Rect { + x: hint_area.x + 2, + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + hint.clone().dim().render(hint_area, buf); + } } } } @@ -568,6 +611,38 @@ mod tests { assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } + #[test] + fn snapshot_footer_note_wraps() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }]; + let footer_note = Line::from(vec![ + "Note: ".dim(), + "Use /setup-elevated-sandbox".cyan(), + " to allow network access.".dim(), + ]); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_note: Some(footer_note), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_footer_note_wraps", + render_lines_with_width(&view, 40) + ); + } + #[test] fn renders_search_query_line_when_enabled() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs index 926cd4f30..b340e1888 100644 --- a/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs @@ -24,6 +24,17 @@ pub(crate) struct GenericDisplayRow { pub wrap_indent: Option, // optional indent for wrapped lines } +pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + + let width = width.max(1) as usize; + let opts = RtOptions::new(width) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from("")); + word_wrap_line(line, opts) +} + fn line_width(line: &Line<'_>) -> usize { line.iter() .map(|span| UnicodeWidthStr::width(span.content.as_ref())) diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap new file mode 100644 index 000000000..18d0eb248 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +assertion_line: 640 +expression: "render_lines_with_width(&view, 40)" +--- + + Select Approval Mode + +› 1. Read Only (current) Codex can + read files + + Note: Use /setup-elevated-sandbox to + allow network access. + Press enter to confirm or esc to go ba