tui: fix approval dialog for large commands (#3087)

#### Summary
- Emit a “Proposed Command” history cell when an ExecApprovalRequest
arrives (parity with proposed patches).
- Simplify the approval dialog: show only the reason/instructions; move
the command preview into history.
- Make approval/abort decision history concise:
  - Single line snippet; if multiline, show first line + " ...".
  - Truncate to 80 graphemes with ellipsis for very long commands.

#### Details
- History
- Add `new_proposed_command` to render a header and indented command
preview.
  - Use shared `prefix_lines` helper for first/subsequent line prefixes.
- Approval UI
- `UserApprovalWidget` no longer renders the command in the modal; shows
optional `reason` text only.
  - Decision history renders an inline, dimmed snippet per rules above.
- Tests (snapshot-based)
  - Proposed/decision flow for short command.
  - Proposed multi-line + aborted decision snippet with “ ...”.
  - Very long one-line command -> truncated snippet with “…”.
  - Updated existing exec approval snapshots and test reasons.

<img width="1053" height="704" alt="Screenshot 2025-09-03 at 11 57
35 AM"
src="https://github.com/user-attachments/assets/9ed4c316-9daf-4ac1-80ff-7ae1f481dd10"
/>

after approving:

<img width="1053" height="704" alt="Screenshot 2025-09-03 at 11 58
18 AM"
src="https://github.com/user-attachments/assets/a44e243f-eb9d-42ea-87f4-171b3fb481e7"
/>

rejection:

<img width="1053" height="207" alt="Screenshot 2025-09-03 at 11 58
45 AM"
src="https://github.com/user-attachments/assets/a022664b-ae0e-4b70-a388-509208707934"
/>

big command:


https://github.com/user-attachments/assets/2dd976e5-799f-4af7-9682-a046e66cc494
This commit is contained in:
Jeremy Rose
2025-09-04 16:54:53 -07:00
committed by GitHub
parent 907d3dd348
commit 742feaf40f
13 changed files with 230 additions and 116 deletions

View File

@@ -30,6 +30,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::text_formatting::truncate_text;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
@@ -110,45 +111,11 @@ pub(crate) struct UserApprovalWidget {
done: bool,
}
fn to_command_display<'a>(
first_line: Vec<Span<'a>>,
cmd: String,
last_line: Vec<Span<'a>>,
) -> Vec<Line<'a>> {
let command_lines: Vec<Span> = cmd.lines().map(|line| line.to_string().dim()).collect();
let mut lines: Vec<Line<'a>> = vec![];
let mut first_line = first_line.clone();
if command_lines.len() == 1 {
first_line.push(command_lines[0].clone());
first_line.extend(last_line);
} else {
for line in command_lines {
lines.push(vec![" ".into(), line].into());
}
let last_line = last_line.clone();
lines.push(Line::from(last_line));
}
lines.insert(0, Line::from(first_line));
lines
}
impl UserApprovalWidget {
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let confirmation_prompt = match &approval_request {
ApprovalRequest::Exec {
command, reason, ..
} => {
let cmd = strip_bash_lc_and_escape(command);
let mut contents: Vec<Line> = to_command_display(
vec!["? ".fg(Color::Cyan), "Codex wants to run ".bold()],
cmd,
vec![],
);
contents.push(Line::from(""));
ApprovalRequest::Exec { reason, .. } => {
let mut contents: Vec<Line> = vec![];
if let Some(reason) = reason {
contents.push(Line::from(reason.clone().italic()));
contents.push(Line::from(""));
@@ -258,62 +225,61 @@ impl UserApprovalWidget {
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
// TODO: move this rendering into history_cell.
let mut lines: Vec<Line<'static>> = vec![];
let full_cmd = strip_bash_lc_and_escape(command);
// Construct a concise, single-line summary of the command:
// - If multi-line, take the first line and append " ...".
// - Truncate to 80 graphemes.
let mut snippet = match full_cmd.split_once('\n') {
Some((first, _)) => format!("{first} ..."),
None => full_cmd.clone(),
};
// Enforce the 80 character length limit.
snippet = truncate_text(&snippet, 80);
// Result line based on decision.
let mut result_spans: Vec<Span<'static>> = Vec::new();
match decision {
ReviewDecision::Approved => {
lines.extend(to_command_display(
vec![
" ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
],
cmd,
vec![" this time".bold()],
));
result_spans.extend(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet.clone().dim(),
" this time".bold(),
]);
}
ReviewDecision::ApprovedForSession => {
lines.extend(to_command_display(
vec![
" ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
],
cmd,
vec![" every time this session".bold()],
));
result_spans.extend(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet.clone().dim(),
" every time this session".bold(),
]);
}
ReviewDecision::Denied => {
lines.extend(to_command_display(
vec![
" ".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
],
cmd,
vec![],
));
result_spans.extend(vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
snippet.clone().dim(),
]);
}
ReviewDecision::Abort => {
lines.extend(to_command_display(
vec![
" ".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
],
cmd,
vec![],
));
result_spans.extend(vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
snippet.clone().dim(),
]);
}
}
let mut lines: Vec<Line<'static>> = vec![Line::from(result_spans)];
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {