Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,8 @@ impl ChatWidget {

pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
self.flush_answer_stream_with_separator();
// Emit the proposed command into history (like proposed patches)
self.add_to_history(history_cell::new_proposed_command(&ev.command));

let request = ApprovalRequest::Exec {
id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ assertion_line: 728
expression: terminal.backend()
---
" "
"? Codex wants to run echo hello world "
" "
"Model wants to run a command "
"this is a test reason such as one that would be produced by the model "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"? Codex wants to run echo hello world "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&aborted_long)
---
✗ You canceled the request to run echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&aborted_multi)
---
✗ You canceled the request to run echo line1 ...

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&decision)
---
✔ You approved codex to run echo hello world this time

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_multi)
---
• Proposed Command
└ echo line1
echo line2
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed)
---
• Proposed Command
└ echo hello world
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ assertion_line: 921
expression: terminal.backend()
---
" "
"? Codex wants to run echo 'hello world' "
" "
"Codex wants to run a command "
"this is a test reason such as one that would be produced by the model "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
Expand Down
106 changes: 104 additions & 2 deletions codex-rs/tui/src/chatwidget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,104 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
s
}

// (removed experimental resize snapshot test)

#[test]
fn exec_approval_emits_proposed_command_and_decision_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();

// Trigger an exec approval request with a short, single-line command
let ev = ExecApprovalRequestEvent {
call_id: "call-short".into(),
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
};
chat.handle_codex_event(Event {
id: "sub-short".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});

// Snapshot the Proposed Command cell emitted into history
let proposed = drain_insert_history(&mut rx)
.pop()
.expect("expected proposed command cell");
assert_snapshot!(
"exec_approval_history_proposed_short",
lines_to_single_string(&proposed)
);

// Approve via keyboard and verify a concise decision history line is added
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let decision = drain_insert_history(&mut rx)
.pop()
.expect("expected decision cell in history");
assert_snapshot!(
"exec_approval_history_decision_approved_short",
lines_to_single_string(&decision)
);
}

#[test]
fn exec_approval_decision_truncates_multiline_and_long_commands() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();

// Multiline command: should render proposed command fully in history with prefixes
let ev_multi = ExecApprovalRequestEvent {
call_id: "call-multi".into(),
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
};
chat.handle_codex_event(Event {
id: "sub-multi".into(),
msg: EventMsg::ExecApprovalRequest(ev_multi),
});
let proposed_multi = drain_insert_history(&mut rx)
.pop()
.expect("expected proposed multiline command cell");
assert_snapshot!(
"exec_approval_history_proposed_multiline",
lines_to_single_string(&proposed_multi)
);

// Deny via keyboard; decision snippet should be single-line and elided with " ..."
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let aborted_multi = drain_insert_history(&mut rx)
.pop()
.expect("expected aborted decision cell (multiline)");
assert_snapshot!(
"exec_approval_history_decision_aborted_multiline",
lines_to_single_string(&aborted_multi)
);

// Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ...
let long = format!("echo {}", "a".repeat(200));
let ev_long = ExecApprovalRequestEvent {
call_id: "call-long".into(),
command: vec!["bash".into(), "-lc".into(), long.clone()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
};
chat.handle_codex_event(Event {
id: "sub-long".into(),
msg: EventMsg::ExecApprovalRequest(ev_long),
});
drain_insert_history(&mut rx); // proposed cell not needed for this assertion
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let aborted_long = drain_insert_history(&mut rx)
.pop()
.expect("expected aborted decision cell (long)");
assert_snapshot!(
"exec_approval_history_decision_aborted_long",
lines_to_single_string(&aborted_long)
);
}

// --- Small helpers to tersely drive exec begin/end and snapshot active cell ---
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
// Build the full command vec and parse it using core's parser,
Expand Down Expand Up @@ -714,7 +812,9 @@ fn approval_modal_exec_snapshot() {
call_id: "call-approve-cmd".into(),
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: Some("Model wants to run a command".into()),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
};
chat.handle_codex_event(Event {
id: "sub-approve".into(),
Expand Down Expand Up @@ -907,7 +1007,9 @@ fn status_widget_and_approval_modal_snapshot() {
call_id: "call-approve-exec".into(),
command: vec!["echo".into(), "hello world".into()],
cwd: std::path::PathBuf::from("/tmp"),
reason: Some("Codex wants to run a command".into()),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
};
chat.handle_codex_event(Event {
id: "sub-approve-exec".into(),
Expand Down
49 changes: 23 additions & 26 deletions codex-rs/tui/src/history_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::diff_render::create_diff_summary;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::render::line_utils::prefix_lines;
use crate::slash_command::SlashCommand;
use crate::text_formatting::format_and_truncate_tool_result;
use base64::Engine;
Expand Down Expand Up @@ -1140,33 +1141,9 @@ impl HistoryCell for PlanUpdateCell {
.into_iter()
.map(|s| s.to_string().set_style(step_style).into())
.collect();
prefix_lines(step_text, &box_str.into(), &" ".into())
prefix_lines(step_text, box_str.into(), " ".into())
};

fn prefix_lines(
lines: Vec<Line<'static>>,
initial_prefix: &Span<'static>,
subsequent_prefix: &Span<'static>,
) -> Vec<Line<'static>> {
lines
.into_iter()
.enumerate()
.map(|(i, l)| {
Line::from(
[
vec![if i == 0 {
initial_prefix.clone()
} else {
subsequent_prefix.clone()
}],
l.spans,
]
.concat(),
)
})
.collect()
}

let mut lines: Vec<Line<'static>> = vec![];
lines.push(vec!["• ".into(), "Updated Plan".bold()].into());

Expand All @@ -1187,7 +1164,7 @@ impl HistoryCell for PlanUpdateCell {
indented_lines.extend(render_step(status, step));
}
}
lines.extend(prefix_lines(indented_lines, &" └ ".into(), &" ".into()));
lines.extend(prefix_lines(indented_lines, " └ ".into(), " ".into()));

lines
}
Expand Down Expand Up @@ -1231,6 +1208,26 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
PlainHistoryCell { lines }
}

/// Create a new history cell for a proposed command approval.
/// Renders a header and the command preview similar to how proposed patches
/// show a header and summary.
pub(crate) fn new_proposed_command(command: &[String]) -> PlainHistoryCell {
let cmd = strip_bash_lc_and_escape(command);

let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec!["• ".into(), "Proposed Command".bold()]));

let cmd_lines: Vec<Line<'static>> = cmd
.lines()
.map(|part| Line::from(part.to_string()))
.collect();
let initial_prefix: Span<'static> = " └ ".dim();
let subsequent_prefix: Span<'static> = " ".into();
lines.extend(prefix_lines(cmd_lines, initial_prefix, subsequent_prefix));

PlainHistoryCell { lines }
}

pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,
Expand Down
23 changes: 23 additions & 0 deletions codex-rs/tui/src/render/line_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,26 @@ pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool {
.iter()
.all(|s| s.content.is_empty() || s.content.chars().all(|c| c == ' '))
}

/// Prefix each line with `initial_prefix` for the first line and
/// `subsequent_prefix` for following lines. Returns a new Vec of owned lines.
pub fn prefix_lines(
lines: Vec<Line<'static>>,
initial_prefix: Span<'static>,
subsequent_prefix: Span<'static>,
) -> Vec<Line<'static>> {
lines
.into_iter()
.enumerate()
.map(|(i, l)| {
let mut spans = Vec::with_capacity(l.spans.len() + 1);
spans.push(if i == 0 {
initial_prefix.clone()
} else {
subsequent_prefix.clone()
});
spans.extend(l.spans);
Line::from(spans)
})
.collect()
}
Loading
Loading