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([
|
||||
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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -13,4 +13,5 @@ expression: term.backend().vt100().screen().contents()
|
||||
|
||||
|
||||
› Summarize recent commits
|
||||
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
Reference in New Issue
Block a user