tui: fix wrapping in trust_directory (#5007)
Refactor trust_directory to use ColumnRenderable & friends, thus correcting wrapping behavior at small widths. Also introduce RowRenderable with fixed-width rows. - fixed wrapping in trust_directory - changed selector cursor to match other list item selections - allow y/n to work as well as 1/2 - fixed key_hint to be standard before: <img width="661" height="550" alt="Screenshot 2025-10-09 at 9 50 36 AM" src="https://github.com/user-attachments/assets/e01627aa-bee4-4e25-8eca-5575c43f05bf" /> after: <img width="661" height="550" alt="Screenshot 2025-10-09 at 9 51 31 AM" src="https://github.com/user-attachments/assets/cb816cbd-7609-4c83-b62f-b4dba392d79a" />
This commit is contained in:
@@ -104,9 +104,9 @@ impl ApprovalOverlay {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let header = Box::new(ColumnRenderable::new([
|
let header = Box::new(ColumnRenderable::with([
|
||||||
Box::new(Line::from(title.bold())),
|
Line::from(title.bold()).into(),
|
||||||
Box::new(Line::from("")),
|
Line::from("").into(),
|
||||||
header,
|
header,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
|
|||||||
header.push(DiffSummary::new(changes, cwd).into());
|
header.push(DiffSummary::new(changes, cwd).into());
|
||||||
Self {
|
Self {
|
||||||
variant: ApprovalVariant::ApplyPatch { id },
|
variant: ApprovalVariant::ApplyPatch { id },
|
||||||
header: Box::new(ColumnRenderable::new(header)),
|
header: Box::new(ColumnRenderable::with(header)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ impl ListSelectionView {
|
|||||||
if params.title.is_some() || params.subtitle.is_some() {
|
if params.title.is_some() || params.subtitle.is_some() {
|
||||||
let title = params.title.map(|title| Line::from(title.bold()));
|
let title = params.title.map(|title| Line::from(title.bold()));
|
||||||
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
|
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
|
||||||
header = Box::new(ColumnRenderable::new([
|
header = Box::new(ColumnRenderable::with([
|
||||||
header,
|
header,
|
||||||
Box::new(title),
|
Box::new(title),
|
||||||
Box::new(subtitle),
|
Box::new(subtitle),
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ expression: term.backend().vt100().screen().contents()
|
|||||||
|
|
||||||
|
|
||||||
› Summarize recent commits
|
› Summarize recent commits
|
||||||
|
|
||||||
100% context left
|
100% context left
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ impl From<DiffSummary> for Box<dyn Renderable> {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Box::new(ColumnRenderable::new(rows))
|
Box::new(ColumnRenderable::with(rows))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/onboarding/trust_directory.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
> You are running Codex in /workspace/project
|
||||||
|
|
||||||
|
Since this folder is version controlled, you may wish to allow Codex
|
||||||
|
to work in this folder without asking for approval.
|
||||||
|
|
||||||
|
› 1. Yes, allow Codex to work in this folder without asking for
|
||||||
|
approval
|
||||||
|
2. No, ask me to approve edits and commands
|
||||||
|
|
||||||
|
Press enter to continue
|
||||||
@@ -7,19 +7,25 @@ use crossterm::event::KeyEvent;
|
|||||||
use crossterm::event::KeyEventKind;
|
use crossterm::event::KeyEventKind;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::prelude::Widget;
|
use ratatui::style::Style;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Styled as _;
|
||||||
use ratatui::style::Modifier;
|
|
||||||
use ratatui::style::Stylize;
|
use ratatui::style::Stylize;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
use ratatui::widgets::Wrap;
|
use ratatui::widgets::Wrap;
|
||||||
|
|
||||||
|
use crate::key_hint;
|
||||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||||
|
use crate::render::Insets;
|
||||||
|
use crate::render::renderable::ColumnRenderable;
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
|
use crate::render::renderable::RenderableExt as _;
|
||||||
|
use crate::render::renderable::RowRenderable;
|
||||||
|
|
||||||
use super::onboarding_screen::StepState;
|
use super::onboarding_screen::StepState;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
pub(crate) struct TrustDirectoryWidget {
|
pub(crate) struct TrustDirectoryWidget {
|
||||||
pub codex_home: PathBuf,
|
pub codex_home: PathBuf,
|
||||||
@@ -38,76 +44,106 @@ pub enum TrustDirectorySelection {
|
|||||||
|
|
||||||
impl WidgetRef for &TrustDirectoryWidget {
|
impl WidgetRef for &TrustDirectoryWidget {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let mut lines: Vec<Line> = vec![
|
let mut column = ColumnRenderable::new();
|
||||||
Line::from(vec![
|
|
||||||
"> ".into(),
|
|
||||||
"You are running Codex in ".bold(),
|
|
||||||
self.cwd.to_string_lossy().to_string().into(),
|
|
||||||
]),
|
|
||||||
"".into(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if self.is_git_repo {
|
column.push(Line::from(vec![
|
||||||
lines.push(
|
"> ".into(),
|
||||||
" Since this folder is version controlled, you may wish to allow Codex".into(),
|
"You are running Codex in ".bold(),
|
||||||
);
|
self.cwd.to_string_lossy().to_string().into(),
|
||||||
lines.push(" to work in this folder without asking for approval.".into());
|
]));
|
||||||
|
column.push("");
|
||||||
|
|
||||||
|
let guidance = if self.is_git_repo {
|
||||||
|
"Since this folder is version controlled, you may wish to allow Codex to work in this folder without asking for approval."
|
||||||
} else {
|
} else {
|
||||||
lines.push(
|
"Since this folder is not version controlled, we recommend requiring approval of all edits and commands."
|
||||||
" Since this folder is not version controlled, we recommend requiring".into(),
|
};
|
||||||
);
|
|
||||||
lines.push(" approval of all edits and commands.".into());
|
|
||||||
}
|
|
||||||
lines.push("".into());
|
|
||||||
|
|
||||||
let create_option =
|
column.push(
|
||||||
|idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> {
|
Paragraph::new(guidance.to_string())
|
||||||
let is_selected = self.highlighted == option;
|
.wrap(Wrap { trim: true })
|
||||||
if is_selected {
|
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||||
Line::from(format!("> {}. {text}", idx + 1)).cyan()
|
);
|
||||||
} else {
|
column.push("");
|
||||||
Line::from(format!(" {}. {}", idx + 1, text))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
let mut options: Vec<(&str, TrustDirectorySelection)> = Vec::new();
|
||||||
if self.is_git_repo {
|
if self.is_git_repo {
|
||||||
lines.push(create_option(
|
options.push((
|
||||||
0,
|
|
||||||
TrustDirectorySelection::Trust,
|
|
||||||
"Yes, allow Codex to work in this folder without asking for approval",
|
"Yes, allow Codex to work in this folder without asking for approval",
|
||||||
|
TrustDirectorySelection::Trust,
|
||||||
));
|
));
|
||||||
lines.push(create_option(
|
options.push((
|
||||||
1,
|
|
||||||
TrustDirectorySelection::DontTrust,
|
|
||||||
"No, ask me to approve edits and commands",
|
"No, ask me to approve edits and commands",
|
||||||
|
TrustDirectorySelection::DontTrust,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
lines.push(create_option(
|
options.push((
|
||||||
0,
|
|
||||||
TrustDirectorySelection::Trust,
|
|
||||||
"Allow Codex to work in this folder without asking for approval",
|
"Allow Codex to work in this folder without asking for approval",
|
||||||
|
TrustDirectorySelection::Trust,
|
||||||
));
|
));
|
||||||
lines.push(create_option(
|
options.push((
|
||||||
1,
|
|
||||||
TrustDirectorySelection::DontTrust,
|
|
||||||
"Require approval of edits and commands",
|
"Require approval of edits and commands",
|
||||||
|
TrustDirectorySelection::DontTrust,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
lines.push("".into());
|
|
||||||
if let Some(error) = &self.error {
|
|
||||||
lines.push(Line::from(format!(" {error}")).fg(Color::Red));
|
|
||||||
lines.push("".into());
|
|
||||||
}
|
|
||||||
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
|
|
||||||
// But leaving this for a future cleanup.
|
|
||||||
lines.push(Line::from(" Press Enter to continue").add_modifier(Modifier::DIM));
|
|
||||||
|
|
||||||
Paragraph::new(lines)
|
for (idx, (text, selection)) in options.iter().enumerate() {
|
||||||
.wrap(Wrap { trim: false })
|
column.push(new_option_row(
|
||||||
.render(area, buf);
|
idx,
|
||||||
|
text.to_string(),
|
||||||
|
self.highlighted == *selection,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
column.push("");
|
||||||
|
|
||||||
|
if let Some(error) = &self.error {
|
||||||
|
column.push(
|
||||||
|
Paragraph::new(error.to_string())
|
||||||
|
.red()
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||||
|
);
|
||||||
|
column.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
column.push(
|
||||||
|
Line::from(vec![
|
||||||
|
"Press ".dim(),
|
||||||
|
key_hint::plain(KeyCode::Enter).into(),
|
||||||
|
" to continue".dim(),
|
||||||
|
])
|
||||||
|
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
column.render(area, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn new_option_row(index: usize, label: String, is_selected: bool) -> Box<dyn Renderable> {
|
||||||
|
let prefix = if is_selected {
|
||||||
|
format!("› {}. ", index + 1)
|
||||||
|
} else {
|
||||||
|
format!(" {}. ", index + 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut style = Style::default();
|
||||||
|
if is_selected {
|
||||||
|
style = style.cyan();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut row = RowRenderable::new();
|
||||||
|
row.push(prefix.width() as u16, prefix.set_style(style));
|
||||||
|
row.push(
|
||||||
|
u16::MAX,
|
||||||
|
Paragraph::new(label)
|
||||||
|
.style(style)
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
row.into()
|
||||||
|
}
|
||||||
|
|
||||||
impl KeyboardHandler for TrustDirectoryWidget {
|
impl KeyboardHandler for TrustDirectoryWidget {
|
||||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||||
if key_event.kind == KeyEventKind::Release {
|
if key_event.kind == KeyEventKind::Release {
|
||||||
@@ -121,8 +157,8 @@ impl KeyboardHandler for TrustDirectoryWidget {
|
|||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
self.highlighted = TrustDirectorySelection::DontTrust;
|
self.highlighted = TrustDirectorySelection::DontTrust;
|
||||||
}
|
}
|
||||||
KeyCode::Char('1') => self.handle_trust(),
|
KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(),
|
||||||
KeyCode::Char('2') => self.handle_dont_trust(),
|
KeyCode::Char('2') | KeyCode::Char('n') => self.handle_dont_trust(),
|
||||||
KeyCode::Enter => match self.highlighted {
|
KeyCode::Enter => match self.highlighted {
|
||||||
TrustDirectorySelection::Trust => self.handle_trust(),
|
TrustDirectorySelection::Trust => self.handle_trust(),
|
||||||
TrustDirectorySelection::DontTrust => self.handle_dont_trust(),
|
TrustDirectorySelection::DontTrust => self.handle_dont_trust(),
|
||||||
@@ -161,12 +197,16 @@ impl TrustDirectoryWidget {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use crate::test_backend::VT100Backend;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use crossterm::event::KeyEventKind;
|
use crossterm::event::KeyEventKind;
|
||||||
use crossterm::event::KeyModifiers;
|
use crossterm::event::KeyModifiers;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -191,4 +231,23 @@ mod tests {
|
|||||||
widget.handle_key_event(press);
|
widget.handle_key_event(press);
|
||||||
assert_eq!(widget.selection, Some(TrustDirectorySelection::DontTrust));
|
assert_eq!(widget.selection, Some(TrustDirectorySelection::DontTrust));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_snapshot_for_git_repo() {
|
||||||
|
let widget = TrustDirectoryWidget {
|
||||||
|
codex_home: PathBuf::from("."),
|
||||||
|
cwd: PathBuf::from("/workspace/project"),
|
||||||
|
is_git_repo: true,
|
||||||
|
selection: None,
|
||||||
|
highlighted: TrustDirectorySelection::Trust,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal");
|
||||||
|
terminal
|
||||||
|
.draw(|f| (&widget).render_ref(f.area(), f.buffer_mut()))
|
||||||
|
.expect("draw");
|
||||||
|
|
||||||
|
insta::assert_snapshot!(terminal.backend());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ pub mod renderable;
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct Insets {
|
pub struct Insets {
|
||||||
pub left: u16,
|
left: u16,
|
||||||
pub top: u16,
|
top: u16,
|
||||||
pub right: u16,
|
right: u16,
|
||||||
pub bottom: u16,
|
bottom: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Insets {
|
impl Insets {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
|
||||||
@@ -45,6 +46,15 @@ impl Renderable for String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> Renderable for Span<'a> {
|
||||||
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
self.render_ref(area, buf);
|
||||||
|
}
|
||||||
|
fn desired_height(&self, _width: u16) -> u16 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'a> Renderable for Line<'a> {
|
impl<'a> Renderable for Line<'a> {
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
WidgetRef::render_ref(self, area, buf);
|
WidgetRef::render_ref(self, area, buf);
|
||||||
@@ -114,11 +124,64 @@ impl Renderable for ColumnRenderable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ColumnRenderable {
|
impl ColumnRenderable {
|
||||||
pub fn new(children: impl IntoIterator<Item = Box<dyn Renderable>>) -> Self {
|
pub fn new() -> Self {
|
||||||
|
Self::with(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with(children: impl IntoIterator<Item = Box<dyn Renderable>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
children: children.into_iter().collect(),
|
children: children.into_iter().collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, child: impl Into<Box<dyn Renderable>>) {
|
||||||
|
self.children.push(child.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RowRenderable {
|
||||||
|
children: Vec<(u16, Box<dyn Renderable>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for RowRenderable {
|
||||||
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let mut x = area.x;
|
||||||
|
for (width, child) in &self.children {
|
||||||
|
let available_width = area.width.saturating_sub(x - area.x);
|
||||||
|
let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height);
|
||||||
|
if child_area.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
child.render(child_area, buf);
|
||||||
|
x = x.saturating_add(*width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
|
let mut max_height = 0;
|
||||||
|
let mut width_remaining = width;
|
||||||
|
for (child_width, child) in &self.children {
|
||||||
|
let w = (*child_width).min(width_remaining);
|
||||||
|
if w == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let height = child.desired_height(w);
|
||||||
|
if height > max_height {
|
||||||
|
max_height = height;
|
||||||
|
}
|
||||||
|
width_remaining = width_remaining.saturating_sub(w);
|
||||||
|
}
|
||||||
|
max_height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RowRenderable {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { children: vec![] }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, width: u16, child: impl Into<Box<dyn Renderable>>) {
|
||||||
|
self.children.push((width, child.into()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct InsetRenderable {
|
pub struct InsetRenderable {
|
||||||
@@ -146,3 +209,16 @@ impl InsetRenderable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait RenderableExt {
|
||||||
|
fn inset(self, insets: Insets) -> Box<dyn Renderable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Into<Box<dyn Renderable>>> RenderableExt for R {
|
||||||
|
fn inset(self, insets: Insets) -> Box<dyn Renderable> {
|
||||||
|
Box::new(InsetRenderable {
|
||||||
|
child: self.into(),
|
||||||
|
insets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ impl Backend for VT100Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn size(&self) -> io::Result<Size> {
|
fn size(&self) -> io::Result<Size> {
|
||||||
Ok(self.vt100().screen().size().into())
|
let (rows, cols) = self.vt100().screen().size();
|
||||||
|
Ok(Size::new(cols, rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn window_size(&mut self) -> io::Result<WindowSize> {
|
fn window_size(&mut self) -> io::Result<WindowSize> {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ impl<'a> RtOptions<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
|
pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
|
||||||
where
|
where
|
||||||
O: Into<RtOptions<'a>>,
|
O: Into<RtOptions<'a>>,
|
||||||
|
|||||||
Reference in New Issue
Block a user