feat: better UI for unified_exec (#6515)

<img width="376" height="132" alt="Screenshot 2025-11-12 at 17 36 22"
src="https://github.com/user-attachments/assets/ce693f0d-5ca0-462e-b170-c20811dcc8d5"
/>
This commit is contained in:
jif-oai
2025-11-14 16:31:12 +01:00
committed by GitHub
parent 4788fb179a
commit 63c8c01f40
17 changed files with 563 additions and 129 deletions
+32 -13
View File
@@ -23,6 +23,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::ListCustomPromptsResponseEvent;
use codex_core::protocol::McpListToolsResponseEvent;
@@ -129,7 +130,7 @@ const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
struct RunningCommand {
command: Vec<String>,
parsed_cmd: Vec<ParsedCommand>,
is_user_shell_command: bool,
source: ExecCommandSource,
}
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
@@ -724,6 +725,9 @@ impl ChatWidget {
fn on_background_event(&mut self, message: String) {
debug!("BackgroundEvent: {message}");
self.bottom_pane.ensure_status_indicator();
self.bottom_pane.set_interrupt_hint_visible(true);
self.set_status_header(message);
}
fn on_undo_started(&mut self, event: UndoStartedEvent) {
@@ -833,10 +837,16 @@ impl ChatWidget {
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
let running = self.running_commands.remove(&ev.call_id);
let (command, parsed, is_user_shell_command) = match running {
Some(rc) => (rc.command, rc.parsed_cmd, rc.is_user_shell_command),
None => (vec![ev.call_id.clone()], Vec::new(), false),
let (command, parsed, source) = match running {
Some(rc) => (rc.command, rc.parsed_cmd, rc.source),
None => (
vec![ev.call_id.clone()],
Vec::new(),
ExecCommandSource::Agent,
),
};
let is_unified_exec_interaction =
matches!(source, ExecCommandSource::UnifiedExecInteraction);
let needs_new = self
.active_cell
@@ -849,7 +859,8 @@ impl ChatWidget {
ev.call_id.clone(),
command,
parsed,
is_user_shell_command,
source,
None,
)));
}
@@ -858,15 +869,20 @@ impl ChatWidget {
.as_mut()
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
{
cell.complete_call(
&ev.call_id,
let output = if is_unified_exec_interaction {
CommandOutput {
exit_code: ev.exit_code,
formatted_output: String::new(),
aggregated_output: String::new(),
}
} else {
CommandOutput {
exit_code: ev.exit_code,
formatted_output: ev.formatted_output.clone(),
aggregated_output: ev.aggregated_output.clone(),
},
ev.duration,
);
}
};
cell.complete_call(&ev.call_id, output, ev.duration);
if cell.should_flush() {
self.flush_active_cell();
}
@@ -928,9 +944,10 @@ impl ChatWidget {
RunningCommand {
command: ev.command.clone(),
parsed_cmd: ev.parsed_cmd.clone(),
is_user_shell_command: ev.is_user_shell_command,
source: ev.source,
},
);
let interaction_input = ev.interaction_input.clone();
if let Some(cell) = self
.active_cell
.as_mut()
@@ -939,7 +956,8 @@ impl ChatWidget {
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd.clone(),
ev.is_user_shell_command,
ev.source,
interaction_input.clone(),
)
{
*cell = new_exec;
@@ -950,7 +968,8 @@ impl ChatWidget {
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd,
ev.is_user_shell_command,
ev.source,
interaction_input,
)));
}
+66 -4
View File
@@ -18,11 +18,13 @@ use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
@@ -660,7 +662,12 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
}
// --- Small helpers to tersely drive exec begin/end and snapshot active cell ---
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
fn begin_exec_with_source(
chat: &mut ChatWidget,
call_id: &str,
raw_cmd: &str,
source: ExecCommandSource,
) {
// Build the full command vec and parse it using core's parser,
// then convert to protocol variants for the event payload.
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
@@ -672,11 +679,16 @@ fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
command,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd,
is_user_shell_command: false,
source,
interaction_input: None,
}),
});
}
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent);
}
fn end_exec(chat: &mut ChatWidget, call_id: &str, stdout: &str, stderr: &str, exit_code: i32) {
let aggregated = if stderr.is_empty() {
stdout.to_string()
@@ -933,6 +945,38 @@ fn exec_history_cell_shows_working_then_failed() {
assert!(blob.to_lowercase().contains("bloop"), "expected error text");
}
#[test]
fn exec_history_shows_unified_exec_startup_commands() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
begin_exec_with_source(
&mut chat,
"call-startup",
"echo unified exec startup",
ExecCommandSource::UnifiedExecStartup,
);
assert!(
drain_insert_history(&mut rx).is_empty(),
"exec begin should not flush until completion"
);
end_exec(
&mut chat,
"call-startup",
"echo unified exec startup\n",
"",
0,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
let blob = lines_to_single_string(&cells[0]);
assert!(
blob.contains("• Ran echo unified exec startup"),
"expected startup command to render: {blob:?}"
);
}
/// Selecting the custom prompt option from the review popup sends
/// OpenReviewCustomPrompt to the app event channel.
#[test]
@@ -1744,7 +1788,8 @@ async fn binary_size_transcript_snapshot() {
command: e.command,
cwd: e.cwd,
parsed_cmd,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
interaction_input: e.interaction_input.clone(),
}),
}
}
@@ -2164,6 +2209,22 @@ fn status_widget_active_snapshot() {
assert_snapshot!("status_widget_active", terminal.backend());
}
#[test]
fn background_event_updates_status_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
chat.handle_codex_event(Event {
id: "bg-1".into(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: "Waiting for `vim`".to_string(),
}),
});
assert!(chat.bottom_pane.status_indicator_visible());
assert_eq!(chat.current_status_header, "Waiting for `vim`");
assert!(drain_insert_history(&mut rx).is_empty());
}
#[test]
fn apply_patch_events_emit_history_cells() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
@@ -2815,7 +2876,8 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
path: "diff_render.rs".into(),
},
],
is_user_shell_command: false,
source: ExecCommandSource::Agent,
interaction_input: None,
}),
});
chat.handle_codex_event(Event {
+17 -3
View File
@@ -1,6 +1,7 @@
use std::time::Duration;
use std::time::Instant;
use codex_core::protocol::ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
#[derive(Clone, Debug, Default)]
@@ -18,9 +19,10 @@ pub(crate) struct ExecCall {
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>,
pub(crate) is_user_shell_command: bool,
pub(crate) source: ExecCommandSource,
pub(crate) start_time: Option<Instant>,
pub(crate) duration: Option<Duration>,
pub(crate) interaction_input: Option<String>,
}
#[derive(Debug)]
@@ -38,16 +40,18 @@ impl ExecCell {
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
is_user_shell_command: bool,
source: ExecCommandSource,
interaction_input: Option<String>,
) -> Option<Self> {
let call = ExecCall {
call_id,
command,
parsed,
output: None,
is_user_shell_command,
source,
start_time: Some(Instant::now()),
duration: None,
interaction_input,
};
if self.is_exploring_cell() && Self::is_exploring_call(&call) {
Some(Self {
@@ -124,3 +128,13 @@ impl ExecCell {
})
}
}
impl ExecCall {
pub(crate) fn is_user_shell_command(&self) -> bool {
matches!(self.source, ExecCommandSource::UserShell)
}
pub(crate) fn is_unified_exec_interaction(&self) -> bool {
matches!(self.source, ExecCommandSource::UnifiedExecInteraction)
}
}
+59 -15
View File
@@ -14,6 +14,7 @@ use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration;
use codex_core::protocol::ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
use itertools::Itertools;
use ratatui::prelude::*;
@@ -24,6 +25,7 @@ use unicode_width::UnicodeWidthStr;
pub(crate) const TOOL_CALL_MAX_LINES: usize = 5;
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
const MAX_INTERACTION_PREVIEW_CHARS: usize = 80;
pub(crate) struct OutputLinesParams {
pub(crate) line_limit: usize,
@@ -36,19 +38,47 @@ pub(crate) fn new_active_exec_command(
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
is_user_shell_command: bool,
source: ExecCommandSource,
interaction_input: Option<String>,
) -> ExecCell {
ExecCell::new(ExecCall {
call_id,
command,
parsed,
output: None,
is_user_shell_command,
source,
start_time: Some(Instant::now()),
duration: None,
interaction_input,
})
}
fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String {
let command_display = command.join(" ");
match input {
Some(data) if !data.is_empty() => {
let preview = summarize_interaction_input(data);
format!("Interacted with `{command_display}`, sent `{preview}`")
}
_ => format!("Waited for `{command_display}`"),
}
}
fn summarize_interaction_input(input: &str) -> String {
let single_line = input.replace('\n', "\\n");
let sanitized = single_line.replace('`', "\\`");
if sanitized.chars().count() <= MAX_INTERACTION_PREVIEW_CHARS {
return sanitized;
}
let mut preview = String::new();
for ch in sanitized.chars().take(MAX_INTERACTION_PREVIEW_CHARS) {
preview.push(ch);
}
preview.push_str("...");
preview
}
#[derive(Clone)]
pub(crate) struct OutputLines {
pub(crate) lines: Vec<Line<'static>>,
@@ -181,7 +211,9 @@ impl HistoryCell for ExecCell {
lines.extend(cmd_display);
if let Some(output) = call.output.as_ref() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
if !call.is_unified_exec_interaction() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
}
let duration = call
.duration
.map(format_duration)
@@ -317,19 +349,29 @@ impl ExecCell {
Some(false) => "".red().bold(),
None => spinner(call.start_time),
};
let title = if self.is_active() {
let is_interaction = call.is_unified_exec_interaction();
let title = if is_interaction {
""
} else if self.is_active() {
"Running"
} else if call.is_user_shell_command {
} else if call.is_user_shell_command() {
"You ran"
} else {
"Ran"
};
let mut header_line =
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]);
let mut header_line = if is_interaction {
Line::from(vec![bullet.clone(), " ".into()])
} else {
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()])
};
let header_prefix_width = header_line.width();
let cmd_display = strip_bash_lc_and_escape(&call.command);
let cmd_display = if call.is_unified_exec_interaction() {
format_unified_exec_interaction(&call.command, call.interaction_input.as_deref())
} else {
strip_bash_lc_and_escape(&call.command)
};
let highlighted_lines = highlight_bash_to_lines(&cmd_display);
let continuation_wrap_width = layout.command_continuation.wrap_width(width);
@@ -373,7 +415,7 @@ impl ExecCell {
}
if let Some(output) = call.output.as_ref() {
let line_limit = if call.is_user_shell_command {
let line_limit = if call.is_user_shell_command() {
USER_SHELL_TOOL_CALL_MAX_LINES
} else {
TOOL_CALL_MAX_LINES
@@ -387,18 +429,20 @@ impl ExecCell {
include_prefix: false,
},
);
let display_limit = if call.is_user_shell_command {
let display_limit = if call.is_user_shell_command() {
USER_SHELL_TOOL_CALL_MAX_LINES
} else {
layout.output_max_lines
};
if raw_output.lines.is_empty() {
lines.extend(prefix_lines(
vec![Line::from("(no output)".dim())],
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
if !call.is_unified_exec_interaction() {
lines.extend(prefix_lines(
vec![Line::from("(no output)".dim())],
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
}
} else {
let trimmed_output = Self::truncate_lines_middle(
&raw_output.lines,
+25 -12
View File
@@ -1478,6 +1478,7 @@ mod tests {
use serde_json::json;
use std::collections::HashMap;
use codex_core::protocol::ExecCommandSource;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
@@ -1878,9 +1879,10 @@ mod tests {
},
],
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
// Mark call complete so markers are ✓
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -1901,9 +1903,10 @@ mod tests {
cmd: "rg shimmer_spans".into(),
}],
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
// Call 1: Search only
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
@@ -1917,7 +1920,8 @@ mod tests {
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
}],
false,
ExecCommandSource::Agent,
None,
)
.unwrap();
cell.complete_call("c2", CommandOutput::default(), Duration::from_millis(1));
@@ -1931,7 +1935,8 @@ mod tests {
cmd: "cat status_indicator_widget.rs".into(),
path: "status_indicator_widget.rs".into(),
}],
false,
ExecCommandSource::Agent,
None,
)
.unwrap();
cell.complete_call("c3", CommandOutput::default(), Duration::from_millis(1));
@@ -1964,9 +1969,10 @@ mod tests {
},
],
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
@@ -1984,9 +1990,10 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
// Mark call complete so it renders as "Ran"
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -2006,9 +2013,10 @@ mod tests {
command: vec!["echo".into(), "ok".into()],
parsed: Vec::new(),
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
// Wide enough that it fits inline
@@ -2026,9 +2034,10 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), long],
parsed: Vec::new(),
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(24);
@@ -2045,9 +2054,10 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
@@ -2065,9 +2075,10 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(28);
@@ -2085,9 +2096,10 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
parsed: Vec::new(),
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let stderr: String = (1..=10)
.map(|n| n.to_string())
@@ -2131,9 +2143,10 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
parsed: Vec::new(),
output: None,
is_user_shell_command: false,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();
+3 -1
View File
@@ -579,6 +579,7 @@ fn render_offset_content(
#[cfg(test)]
mod tests {
use super::*;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ReviewDecision;
use insta::assert_snapshot;
use std::collections::HashMap;
@@ -719,7 +720,8 @@ mod tests {
"exec-1".into(),
vec!["bash".into(), "-lc".into(), "ls".into()],
vec![ParsedCommand::Unknown { cmd: "ls".into() }],
false,
ExecCommandSource::Agent,
None,
);
exec_cell.complete_call(
"exec-1",