mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
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:
@@ -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>();
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
@@ -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>();
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user