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.
This commit is contained in:
iceweasel-oai
2026-01-07 16:44:28 -08:00
committed by GitHub
parent ef8b8ebc94
commit 357e4c902b
6 changed files with 226 additions and 26 deletions

View File

@@ -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<String>,
pub subtitle: Option<String>,
pub footer_note: Option<Line<'static>>,
pub footer_hint: Option<Line<'static>>,
pub items: Vec<SelectionItem>,
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<Line<'static>>,
footer_hint: Option<Line<'static>>,
items: Vec<SelectionItem>,
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::<AppEvent>();
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::<AppEvent>();

View File

@@ -26,6 +26,17 @@ pub(crate) struct GenericDisplayRow {
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
}
pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'a>> {
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()))

View File

@@ -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

View File

@@ -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<String>,
pub subtitle: Option<String>,
pub footer_note: Option<Line<'static>>,
pub footer_hint: Option<Line<'static>>,
pub items: Vec<SelectionItem>,
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<Line<'static>>,
footer_hint: Option<Line<'static>>,
items: Vec<SelectionItem>,
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::<AppEvent>();
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::<AppEvent>();

View File

@@ -24,6 +24,17 @@ pub(crate) struct GenericDisplayRow {
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
}
pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'a>> {
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()))

View File

@@ -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