tui: tweaks to dialog display (#4622)

- prefix command approval reasons with "Reason:"
- show keyboard shortcuts for some ListSelectionItems
- remove "description" lines for approval options, and make the labels
more verbose
- add a spacer line in diff display after the path

and some other minor refactors that go along with the above.

<img width="859" height="508" alt="Screenshot 2025-10-02 at 1 24 50 PM"
src="https://github.com/user-attachments/assets/4fa7ecaf-3d3a-406a-bb4d-23e30ce3e5cf"
/>
This commit is contained in:
Jeremy Rose
2025-10-02 14:41:29 -07:00
committed by GitHub
parent 62cc8a4b8d
commit 25a2e15ec5
13 changed files with 159 additions and 150 deletions

View File

@@ -12,6 +12,7 @@ use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -94,8 +95,14 @@ impl ApprovalOverlay {
header: Box<dyn Renderable>,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
ApprovalVariant::Exec { .. } => (
exec_options(),
"Would you like to run the following command?".to_string(),
),
ApprovalVariant::ApplyPatch { .. } => (
patch_options(),
"Would you like to make the following edits?".to_string(),
),
};
let header = Box::new(ColumnRenderable::new([
@@ -108,11 +115,9 @@ impl ApprovalOverlay {
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
description: Some(opt.description.clone()),
is_current: false,
actions: Vec::new(),
display_shortcut: opt.display_shortcut,
dismiss_on_select: false,
search_value: None,
..Default::default()
})
.collect();
@@ -197,28 +202,18 @@ impl ApprovalOverlay {
false
}
}
KeyEvent {
kind: KeyEventKind::Press,
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
let lower = c.to_ascii_lowercase();
match self
e => {
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
.position(|opt| opt.shortcuts().any(|s| s.is_press(*e)))
{
Some(idx) => {
self.apply_selection(idx);
true
}
None => false,
self.apply_selection(idx);
true
} else {
false
}
}
_ => false,
}
}
}
@@ -299,7 +294,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(reason.italic().into());
header.push(Line::from(vec!["Reason: ".into(), reason.italic()]));
header.push(Line::from(""));
}
let full_cmd = strip_bash_lc_and_escape(&command);
@@ -347,31 +342,38 @@ enum ApprovalVariant {
#[derive(Clone)]
struct ApprovalOption {
label: String,
description: String,
decision: ReviewDecision,
shortcut: Option<char>,
display_shortcut: Option<KeyBinding>,
additional_shortcuts: Vec<KeyBinding>,
}
impl ApprovalOption {
fn shortcuts(&self) -> impl Iterator<Item = KeyBinding> + '_ {
self.display_shortcut
.into_iter()
.chain(self.additional_shortcuts.iter().copied())
}
}
fn exec_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve and run now".to_string(),
description: "Run this command one time".to_string(),
label: "Yes, proceed".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
},
ApprovalOption {
label: "Always approve this session".to_string(),
description: "Automatically approve this command for the rest of the session"
.to_string(),
label: "Yes, and don't ask again for this command".to_string(),
decision: ReviewDecision::ApprovedForSession,
shortcut: Some('a'),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "Cancel".to_string(),
description: "Do not run the command".to_string(),
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
}
@@ -379,16 +381,16 @@ fn exec_options() -> Vec<ApprovalOption> {
fn patch_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve".to_string(),
description: "Apply the proposed changes".to_string(),
label: "Yes, proceed".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
},
ApprovalOption {
label: "Cancel".to_string(),
description: "Do not apply the changes".to_string(),
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
}

View File

@@ -173,6 +173,7 @@ impl CommandPopup {
name,
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
display_shortcut: None,
description: Some(description),
}
})

View File

@@ -130,6 +130,7 @@ impl WidgetRef for &FileSearchPopup {
.as_ref()
.map(|v| v.iter().map(|&i| i as usize).collect()),
is_current: false,
display_shortcut: None,
description: None,
})
.collect()

View File

@@ -1,6 +1,7 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use itertools::Itertools as _;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
@@ -13,6 +14,7 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use crate::app_event_sender::AppEventSender;
use crate::key_hint::KeyBinding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
@@ -31,8 +33,10 @@ use super::selection_popup_common::render_rows;
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
#[derive(Default)]
pub(crate) struct SelectionItem {
pub name: String,
pub display_shortcut: Option<KeyBinding>,
pub description: Option<String>,
pub is_current: bool,
pub actions: Vec<SelectionAction>,
@@ -135,18 +139,10 @@ impl ListSelectionView {
self.filtered_indices = self
.items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
let matches = if let Some(search_value) = &item.search_value {
search_value.to_lowercase().contains(&query_lower)
} else {
let mut matches = item.name.to_lowercase().contains(&query_lower);
if !matches && let Some(desc) = &item.description {
matches = desc.to_lowercase().contains(&query_lower);
}
matches
};
matches.then_some(idx)
.positions(|item| {
item.search_value
.as_ref()
.is_some_and(|v| v.to_lowercase().contains(&query_lower))
})
.collect();
} else {
@@ -200,6 +196,7 @@ impl ListSelectionView {
};
GenericDisplayRow {
name: display_name,
display_shortcut: item.display_shortcut,
match_indices: None,
is_current: item.is_current,
description: item.description.clone(),
@@ -329,7 +326,8 @@ impl Renderable for ListSelectionView {
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
let mut height = self.header.desired_height(width);
// Subtract 4 for the padding on the left and right of the header.
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
if self.is_searchable {
height = height.saturating_add(1);
@@ -355,7 +353,10 @@ impl Renderable for ListSelectionView {
.style(user_message_style(terminal_palette::default_bg()))
.render(content_area, buf);
let header_height = self.header.desired_height(content_area.width);
let header_height = self
.header
// Subtract 4 for the padding on the left and right of the header.
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_height =
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, content_area.width);
@@ -438,17 +439,15 @@ mod tests {
name: "Read Only".to_string(),
description: Some("Codex can read files".to_string()),
is_current: true,
actions: vec![],
dismiss_on_select: true,
search_value: None,
..Default::default()
},
SelectionItem {
name: "Full Access".to_string(),
description: Some("Codex can edit files".to_string()),
is_current: false,
actions: vec![],
dismiss_on_select: true,
search_value: None,
..Default::default()
},
];
ListSelectionView::new(
@@ -510,9 +509,8 @@ mod tests {
name: "Read Only".to_string(),
description: Some("Codex can read files".to_string()),
is_current: false,
actions: vec![],
dismiss_on_select: true,
search_value: None,
..Default::default()
}];
let mut view = ListSelectionView::new(
SelectionViewParams {

View File

@@ -9,11 +9,14 @@ use ratatui::text::Span;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar;
use crate::key_hint::KeyBinding;
use super::scroll_state::ScrollState;
/// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow {
pub name: String,
pub display_shortcut: Option<KeyBinding>,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub is_current: bool,
pub description: Option<String>, // optional grey text after the name
@@ -92,6 +95,10 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
let this_name_width = Line::from(name_spans.clone()).width();
let mut full_spans: Vec<Span> = name_spans;
if let Some(display_shortcut) = row.display_shortcut {
full_spans.push(" ".into());
full_spans.push(display_shortcut.into());
}
if let Some(desc) = row.description.as_ref() {
let gap = desc_col.saturating_sub(this_name_width);
if gap > 0 {
@@ -155,6 +162,7 @@ pub(crate) fn render_rows(
let GenericDisplayRow {
name,
match_indices,
display_shortcut,
is_current: _is_current,
description,
} = row;
@@ -163,6 +171,7 @@ pub(crate) fn render_rows(
&GenericDisplayRow {
name: name.clone(),
match_indices: match_indices.clone(),
display_shortcut: *display_shortcut,
is_current: *_is_current,
description: description.clone(),
},

View File

@@ -1630,7 +1630,7 @@ impl ChatWidget {
is_current,
actions,
dismiss_on_select: true,
search_value: None,
..Default::default()
});
}
@@ -1749,7 +1749,7 @@ impl ChatWidget {
is_current: is_current_model && choice.stored == highlight_choice,
actions,
dismiss_on_select: true,
search_value: None,
..Default::default()
});
}
@@ -1793,7 +1793,7 @@ impl ChatWidget {
is_current,
actions,
dismiss_on_select: true,
search_value: None,
..Default::default()
});
}
@@ -1917,7 +1917,6 @@ impl ChatWidget {
items.push(SelectionItem {
name: "Review against a base branch".to_string(),
description: Some("(PR Style)".into()),
is_current: false,
actions: vec![Box::new({
let cwd = self.config.cwd.clone();
move |tx| {
@@ -1925,13 +1924,11 @@ impl ChatWidget {
}
})],
dismiss_on_select: false,
search_value: None,
..Default::default()
});
items.push(SelectionItem {
name: "Review uncommitted changes".to_string(),
description: None,
is_current: false,
actions: vec![Box::new(
move |tx: &AppEventSender| {
tx.send(AppEvent::CodexOp(Op::Review {
@@ -1943,14 +1940,12 @@ impl ChatWidget {
},
)],
dismiss_on_select: true,
search_value: None,
..Default::default()
});
// New: Review a specific commit (opens commit picker)
items.push(SelectionItem {
name: "Review a commit".to_string(),
description: None,
is_current: false,
actions: vec![Box::new({
let cwd = self.config.cwd.clone();
move |tx| {
@@ -1958,18 +1953,16 @@ impl ChatWidget {
}
})],
dismiss_on_select: false,
search_value: None,
..Default::default()
});
items.push(SelectionItem {
name: "Custom review instructions".to_string(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReviewCustomPrompt);
})],
dismiss_on_select: false,
search_value: None,
..Default::default()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
@@ -1991,8 +1984,6 @@ impl ChatWidget {
let branch = option.clone();
items.push(SelectionItem {
name: format!("{current_branch} -> {branch}"),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
@@ -2005,6 +1996,7 @@ impl ChatWidget {
})],
dismiss_on_select: true,
search_value: Some(option),
..Default::default()
});
}
@@ -2030,8 +2022,6 @@ impl ChatWidget {
items.push(SelectionItem {
name: subject.clone(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
let hint = format!("commit {short}");
let prompt = format!(
@@ -2046,6 +2036,7 @@ impl ChatWidget {
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
@@ -2255,8 +2246,6 @@ pub(crate) fn show_review_commit_picker_with_entries(
items.push(SelectionItem {
name: subject.clone(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
let hint = format!("commit {short}");
let prompt = format!(
@@ -2271,6 +2260,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}

View File

@@ -2,15 +2,15 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
Allow command?
Would you like to run the following command?
this is a test reason such as one that would be produced by the model
Reason: this is a test reason such as one that would be produced by the
model
$ echo hello world
1. Approve and run now Run this command one time
2. Always approve this session Automatically approve this command for the
rest of the session
3. Cancel Do not run the command
1. Yes, proceed
2. Yes, and don't ask again for this command
3. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -1,17 +1,13 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
expression: terminal.backend().vt100().screen().contents()
---
" "
" "
" Allow command? "
" "
" $ echo hello world "
" "
" 1. Approve and run now Run this command one time "
" 2. Always approve this session Automatically approve this command for the "
" rest of the session "
" 3. Cancel Do not run the command "
" "
" Press enter to confirm or esc to cancel "
" "
Would you like to run the following command?
$ echo hello world
1. Yes, proceed
2. Yes, and don't ask again for this command
3. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -1,19 +1,17 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
expression: terminal.backend().vt100().screen().contents()
---
" "
" "
" Apply changes? "
" "
" README.md (+2 -0) "
" 1 +hello "
" 2 +world "
" "
" The model wants to apply changes "
" "
" 1. Approve Apply the proposed changes "
" 2. Cancel Do not apply the changes "
" "
" Press enter to confirm or esc to cancel "
" "
Would you like to make the following edits?
README.md (+2 -0)
1 +hello
2 +world
The model wants to apply changes
1. Yes, proceed
2. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -7,16 +7,16 @@ Buffer {
content: [
" ",
" ",
" Allow command? ",
" Would you like to run the following command? ",
" ",
" this is a test reason such as one that would be produced by the model ",
" Reason: this is a test reason such as one that would be produced by the ",
" model ",
" ",
" $ echo hello world ",
" ",
" 1. Approve and run now Run this command one time ",
" 2. Always approve this session Automatically approve this command for the ",
" rest of the session ",
" 3. Cancel Do not run the command ",
" 1. Yes, proceed ",
" 2. Yes, and don't ask again for this command ",
" 3. No, and tell Codex what to do differently esc ",
" ",
" Press enter to confirm or esc to cancel ",
" ",
@@ -24,18 +24,15 @@ Buffer {
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 71, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 8, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 34, y: 8, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD | DIM,
x: 59, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 76, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 53, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 56, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 17, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 50, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]

View File

@@ -4,16 +4,16 @@ expression: terminal.backend()
---
" "
" "
" Allow command? "
" Would you like to run the following command? "
" "
" this is a test reason such as one that would be produced by the model "
" Reason: this is a test reason such as one that would be produced by the "
" model "
" "
" $ echo 'hello world' "
" "
" 1. Approve and run now Run this command one time "
" 2. Always approve this session Automatically approve this command for the "
" rest of the session "
" 3. Cancel Do not run the command "
" 1. Yes, proceed "
" 2. Yes, and don't ask again for this command "
" 3. No, and tell Codex what to do differently esc "
" "
" Press enter to confirm or esc to cancel "
" "

View File

@@ -1236,6 +1236,14 @@ fn approval_modal_exec_snapshot() {
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw approval modal");
assert!(
terminal
.backend()
.vt100()
.screen()
.contents()
.contains("echo hello world")
);
assert_snapshot!(
"approval_modal_exec",
terminal.backend().vt100().screen().contents()
@@ -1261,12 +1269,16 @@ fn approval_modal_exec_without_reason_snapshot() {
});
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw approval modal (no reason)");
assert_snapshot!("approval_modal_exec_no_reason", terminal.backend());
assert_snapshot!(
"approval_modal_exec_no_reason",
terminal.backend().vt100().screen().contents()
);
}
// Snapshot test: patch approval modal
@@ -1296,12 +1308,16 @@ fn approval_modal_patch_snapshot() {
// Render at the widget's desired height and snapshot.
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw patch approval modal");
assert_snapshot!("approval_modal_patch", terminal.backend());
assert_snapshot!(
"approval_modal_patch",
terminal.backend().vt100().screen().contents()
);
}
#[test]
@@ -1831,14 +1847,14 @@ fn apply_patch_untrusted_shows_approval_modal() {
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("Apply changes?") {
if row.contains("Would you like to make the following edits?") {
contains_title = true;
break;
}
}
assert!(
contains_title,
"expected approval modal to be visible with title 'Apply changes?'"
"expected approval modal to be visible with title 'Would you like to make the following edits?'"
);
}

View File

@@ -64,6 +64,7 @@ impl From<DiffSummary> for Box<dyn Renderable> {
path.push_span(" ");
path.extend(render_line_count_summary(row.added, row.removed));
rows.push(Box::new(path));
rows.push(Box::new(RtLine::from("")));
rows.push(Box::new(row.change));
}