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:
Jeremy Rose
2025-10-09 10:39:45 -07:00
committed by GitHub
parent bf82353f45
commit 95b41dd7f1
10 changed files with 220 additions and 68 deletions

View File

@@ -104,9 +104,9 @@ impl ApprovalOverlay {
),
};
let header = Box::new(ColumnRenderable::new([
Box::new(Line::from(title.bold())),
Box::new(Line::from("")),
let header = Box::new(ColumnRenderable::with([
Line::from(title.bold()).into(),
Line::from("").into(),
header,
]));
@@ -323,7 +323,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
header.push(DiffSummary::new(changes, cwd).into());
Self {
variant: ApprovalVariant::ApplyPatch { id },
header: Box::new(ColumnRenderable::new(header)),
header: Box::new(ColumnRenderable::with(header)),
}
}
}

View File

@@ -87,7 +87,7 @@ impl ListSelectionView {
if params.title.is_some() || params.subtitle.is_some() {
let title = params.title.map(|title| Line::from(title.bold()));
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
header = Box::new(ColumnRenderable::new([
header = Box::new(ColumnRenderable::with([
header,
Box::new(title),
Box::new(subtitle),

View File

@@ -13,4 +13,5 @@ expression: term.backend().vt100().screen().contents()
Summarize recent commits
100% context left

View File

@@ -72,7 +72,7 @@ impl From<DiffSummary> for Box<dyn Renderable> {
)));
}
Box::new(ColumnRenderable::new(rows))
Box::new(ColumnRenderable::with(rows))
}
}

View File

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

View File

@@ -7,19 +7,25 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled as _;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::key_hint;
use crate::onboarding::onboarding_screen::KeyboardHandler;
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 unicode_width::UnicodeWidthStr;
pub(crate) struct TrustDirectoryWidget {
pub codex_home: PathBuf,
@@ -38,76 +44,106 @@ pub enum TrustDirectorySelection {
impl WidgetRef for &TrustDirectoryWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
"> ".into(),
"You are running Codex in ".bold(),
self.cwd.to_string_lossy().to_string().into(),
]),
"".into(),
];
let mut column = ColumnRenderable::new();
if self.is_git_repo {
lines.push(
" Since this folder is version controlled, you may wish to allow Codex".into(),
);
lines.push(" to work in this folder without asking for approval.".into());
column.push(Line::from(vec![
"> ".into(),
"You are running Codex in ".bold(),
self.cwd.to_string_lossy().to_string().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 {
lines.push(
" Since this folder is not version controlled, we recommend requiring".into(),
);
lines.push(" approval of all edits and commands.".into());
}
lines.push("".into());
"Since this folder is not version controlled, we recommend requiring approval of all edits and commands."
};
let create_option =
|idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> {
let is_selected = self.highlighted == option;
if is_selected {
Line::from(format!("> {}. {text}", idx + 1)).cyan()
} else {
Line::from(format!(" {}. {}", idx + 1, text))
}
};
column.push(
Paragraph::new(guidance.to_string())
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push("");
let mut options: Vec<(&str, TrustDirectorySelection)> = Vec::new();
if self.is_git_repo {
lines.push(create_option(
0,
TrustDirectorySelection::Trust,
options.push((
"Yes, allow Codex to work in this folder without asking for approval",
TrustDirectorySelection::Trust,
));
lines.push(create_option(
1,
TrustDirectorySelection::DontTrust,
options.push((
"No, ask me to approve edits and commands",
TrustDirectorySelection::DontTrust,
));
} else {
lines.push(create_option(
0,
TrustDirectorySelection::Trust,
options.push((
"Allow Codex to work in this folder without asking for approval",
TrustDirectorySelection::Trust,
));
lines.push(create_option(
1,
TrustDirectorySelection::DontTrust,
options.push((
"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)
.wrap(Wrap { trim: false })
.render(area, buf);
for (idx, (text, selection)) in options.iter().enumerate() {
column.push(new_option_row(
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 {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
@@ -121,8 +157,8 @@ impl KeyboardHandler for TrustDirectoryWidget {
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted = TrustDirectorySelection::DontTrust;
}
KeyCode::Char('1') => self.handle_trust(),
KeyCode::Char('2') => self.handle_dont_trust(),
KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(),
KeyCode::Char('2') | KeyCode::Char('n') => self.handle_dont_trust(),
KeyCode::Enter => match self.highlighted {
TrustDirectorySelection::Trust => self.handle_trust(),
TrustDirectorySelection::DontTrust => self.handle_dont_trust(),
@@ -161,12 +197,16 @@ impl TrustDirectoryWidget {
#[cfg(test)]
mod tests {
use crate::test_backend::VT100Backend;
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use std::path::PathBuf;
#[test]
@@ -191,4 +231,23 @@ mod tests {
widget.handle_key_event(press);
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());
}
}

View File

@@ -6,10 +6,10 @@ pub mod renderable;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Insets {
pub left: u16,
pub top: u16,
pub right: u16,
pub bottom: u16,
left: u16,
top: u16,
right: u16,
bottom: u16,
}
impl Insets {

View File

@@ -3,6 +3,7 @@ use std::sync::Arc;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
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> {
fn render(&self, area: Rect, buf: &mut Buffer) {
WidgetRef::render_ref(self, area, buf);
@@ -114,11 +124,64 @@ impl Renderable for 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 {
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 {
@@ -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,
})
}
}

View File

@@ -91,7 +91,8 @@ impl Backend for VT100Backend {
}
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> {

View File

@@ -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>>
where
O: Into<RtOptions<'a>>,