Files
codex/codex-rs/tui/src/chatwidget/command_lifecycle.rs
T
Felipe CouryandGitHub c0ea566bb5 feat(tui): restore output-free cancelled prompts (#25316)
## TL;DR

When you press Esc or Ctrl+C after sending a prompt but before any
output was rendering, it restores the last composer and the message.

## Summary

Cancelling a prompt immediately after submission should behave like
returning to edit that prompt, not like discarding the user's draft.
Today, pressing `Esc` or `Ctrl+C` before Codex responds leaves the
submitted prompt in the transcript and returns an empty composer,
forcing the user to recall or retype it.

When an interrupted turn has not produced substantive visible output,
restore its submitted prompt directly into the composer and roll back
that latest turn. This also covers the first prompt in a fresh thread,
before the TUI has retained a local user-history cell. The restored
draft keeps its text, image attachments, and active collaboration mode
so it can be edited and resubmitted in place.

Restoration is intentionally suppressed once the turn has produced
user-visible activity such as assistant output, tool work, hooks, or
patches. A transient thinking status does not make the prompt
ineligible. Rollback also rebuilds terminal scrollback from the retained
transcript cells so repeated cancellations and terminal resizes do not
duplicate history.

## How to Test

1. Start the TUI with `cargo run -p codex-cli --bin codex`.
2. In a fresh thread, submit the first prompt and press `Esc` before
Codex emits substantive output. Confirm that the prompt returns to the
composer for editing and its submitted transcript row is removed.
3. Repeat with `Ctrl+C`, then repeat after at least one completed turn.
Confirm the same behavior.
4. Submit a prompt, wait for assistant output or tool activity, then
cancel. Confirm that the transcript remains intact and the prompt is not
restored into the composer.
5. Cancel several output-free prompts and resize the terminal between
attempts. Confirm that the startup banner, tip, and transcript history
do not duplicate in scrollback.

Targeted tests:
- `just test -p codex-tui cancelled_turn_edit_restores_prompt`
- `just test -p codex-tui
output_free_interrupted_turn_requests_prompt_restore`
- `just test -p codex-tui
visible_output_prevents_cancelled_turn_prompt_restore`
- `just test -p codex-tui
thinking_status_keeps_cancelled_turn_prompt_restore_eligible`
- `just test -p codex-tui
patch_activity_prevents_cancelled_turn_prompt_restore`

The full `just test -p codex-tui` run completed with `2746` passing
tests and two unrelated existing guardian feature-flag failures. `just
argument-comment-lint` remains blocked locally by the existing Bazel
LLVM `compiler-rt` sanitizer-header glob failure; the touched Rust diff
was manually audited for positional literal comments.
2026-06-01 11:49:14 -03:00

460 lines
17 KiB
Rust

//! Command execution lifecycle handlers for `ChatWidget`.
//!
//! This module owns command start/output/completion rendering, including active
//! exec-cell grouping and unified exec wait state.
use super::*;
impl ChatWidget {
pub(super) fn flush_unified_exec_wait_streak(&mut self) {
let Some(wait) = self.unified_exec_wait_streak.take() else {
return;
};
self.transcript.needs_final_message_separator = true;
let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new());
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
self.restore_reasoning_status_header();
}
pub(super) fn on_command_execution_started(&mut self, item: ThreadItem) {
let ThreadItem::CommandExecution {
id,
command,
process_id,
source,
command_actions,
..
} = &item
else {
return;
};
let (_command, parsed_cmd) = command_execution_command_and_parsed(command, command_actions);
self.flush_answer_stream_with_separator();
if is_unified_exec_source(*source) {
if *source == ExecCommandSource::UnifiedExecStartup {
self.track_unified_exec_process_begin(id, process_id.as_deref(), command);
}
if !self.bottom_pane.is_task_running() {
return;
}
// Unified exec may be parsed as Unknown; keep the working indicator visible regardless.
self.bottom_pane.ensure_status_indicator();
if !is_standard_tool_call(&parsed_cmd) {
return;
}
}
let item2 = item.clone();
self.defer_or_handle(
|q| q.push_item_started(item),
|s| s.handle_command_execution_started_now(item2),
);
}
pub(super) fn on_exec_command_output_delta(&mut self, call_id: &str, delta: &str) {
self.track_unified_exec_output_chunk(call_id, delta.as_bytes());
if !self.bottom_pane.is_task_running() {
return;
}
let Some(cell) = self
.transcript
.active_cell
.as_mut()
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
else {
return;
};
if cell.append_output(call_id, delta) {
self.bump_active_cell_revision();
self.request_redraw();
}
}
pub(super) fn on_terminal_interaction(&mut self, process_id: String, stdin: String) {
if !self.bottom_pane.is_task_running() {
return;
}
let command_display = self
.unified_exec_processes
.iter()
.find(|process| process.key == process_id)
.map(|process| process.command_display.clone());
if stdin.is_empty() && command_display.is_none() {
return;
}
self.flush_answer_stream_with_separator();
if stdin.is_empty() {
// Empty stdin means we are polling for background output.
// Surface this in the status indicator (single "waiting" surface) instead of
// the transcript. Keep the header short so the interrupt hint remains visible.
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ true);
self.status_state.terminal_title_status_kind =
TerminalTitleStatusKind::WaitingForBackgroundTerminal;
self.set_status(
"Waiting for background terminal".to_string(),
command_display.clone(),
StatusDetailsCapitalization::Preserve,
/*details_max_lines*/ 1,
);
match &mut self.unified_exec_wait_streak {
Some(wait) if wait.process_id == process_id => {
wait.update_command_display(command_display);
}
Some(_) => {
self.flush_unified_exec_wait_streak();
self.unified_exec_wait_streak =
Some(UnifiedExecWaitStreak::new(process_id, command_display));
}
None => {
self.unified_exec_wait_streak =
Some(UnifiedExecWaitStreak::new(process_id, command_display));
}
}
self.request_redraw();
} else {
if self
.unified_exec_wait_streak
.as_ref()
.is_some_and(|wait| wait.process_id == process_id)
{
self.flush_unified_exec_wait_streak();
}
self.add_to_history(history_cell::new_unified_exec_interaction(
command_display,
stdin,
));
}
}
pub(super) fn on_command_execution_completed(&mut self, item: ThreadItem) {
let ThreadItem::CommandExecution {
id,
process_id,
source,
..
} = &item
else {
return;
};
if is_unified_exec_source(*source) {
if let Some(process_id) = process_id.as_deref()
&& self
.unified_exec_wait_streak
.as_ref()
.is_some_and(|wait| wait.process_id == process_id)
{
self.flush_unified_exec_wait_streak();
}
self.track_unified_exec_process_end(id, process_id.as_deref());
if !self.bottom_pane.is_task_running() {
return;
}
}
let item2 = item.clone();
self.defer_or_handle(
|q| q.push_item_completed(item),
|s| s.handle_command_execution_completed_now(item2),
);
}
pub(super) fn track_unified_exec_process_begin(
&mut self,
call_id: &str,
process_id: Option<&str>,
command: &str,
) {
let key = process_id.unwrap_or(call_id).to_string();
let command = split_command_string(command);
let command_display = strip_bash_lc_and_escape(&command);
if let Some(existing) = self
.unified_exec_processes
.iter_mut()
.find(|process| process.key == key)
{
existing.call_id = call_id.to_string();
existing.command_display = command_display;
existing.recent_chunks.clear();
} else {
self.unified_exec_processes.push(UnifiedExecProcessSummary {
key,
call_id: call_id.to_string(),
command_display,
recent_chunks: Vec::new(),
});
}
self.sync_unified_exec_footer();
}
pub(super) fn track_unified_exec_process_end(
&mut self,
call_id: &str,
process_id: Option<&str>,
) {
let key = process_id.unwrap_or(call_id);
let before = self.unified_exec_processes.len();
self.unified_exec_processes
.retain(|process| process.key != key);
if self.unified_exec_processes.len() != before {
self.sync_unified_exec_footer();
}
}
pub(super) fn sync_unified_exec_footer(&mut self) {
let processes = self
.unified_exec_processes
.iter()
.map(|process| process.command_display.clone())
.collect();
self.bottom_pane.set_unified_exec_processes(processes);
}
/// Record recent stdout/stderr lines for the unified exec footer.
pub(super) fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) {
let Some(process) = self
.unified_exec_processes
.iter_mut()
.find(|process| process.call_id == call_id)
else {
return;
};
let text = String::from_utf8_lossy(chunk);
for line in text
.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
{
process.recent_chunks.push(line.to_string());
}
const MAX_RECENT_CHUNKS: usize = 3;
if process.recent_chunks.len() > MAX_RECENT_CHUNKS {
let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS;
process.recent_chunks.drain(0..drop_count);
}
}
pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) {
self.record_visible_turn_activity();
let ThreadItem::CommandExecution {
id,
command,
source,
command_actions,
..
} = item
else {
return;
};
let (command, parsed_cmd) =
command_execution_command_and_parsed(&command, &command_actions);
// Ensure the status indicator is visible while the command runs.
self.bottom_pane.ensure_status_indicator();
let parsed_cmd = self.annotate_skill_reads_in_parsed_cmd(parsed_cmd);
self.running_commands.insert(
id.clone(),
RunningCommand {
command: command.clone(),
parsed_cmd: parsed_cmd.clone(),
source,
},
);
let is_wait_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction);
let command_display = command.join(" ");
let should_suppress_unified_wait = is_wait_interaction
&& self
.last_unified_wait
.as_ref()
.is_some_and(|wait| wait.is_duplicate(&command_display));
if is_wait_interaction {
self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display));
} else {
self.last_unified_wait = None;
}
if should_suppress_unified_wait {
self.suppressed_exec_calls.insert(id);
return;
}
if let Some(cell) = self
.transcript
.active_cell
.as_mut()
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
&& let Some(new_exec) = cell.with_added_call(
id.clone(),
command.clone(),
parsed_cmd.clone(),
source,
/*interaction_input*/ None,
)
{
*cell = new_exec;
self.bump_active_cell_revision();
} else {
self.flush_active_cell();
self.transcript.active_cell = Some(Box::new(new_active_exec_command(
id,
command,
parsed_cmd,
source,
/*interaction_input*/ None,
self.config.animations,
)));
self.bump_active_cell_revision();
}
self.request_redraw();
}
/// Finalizes an exec call while preserving the active exec cell grouping contract.
///
/// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an
/// end event for a call that was never materialized as the current active `ExecCell` (for
/// example, when another exploring group is still active). In that case we render the end as a
/// standalone history entry instead of replacing or flushing the unrelated active exploring
/// cell. If this method treated every unknown end as "complete the active cell", the UI could
/// merge unrelated commands and hide still-running exploring work.
pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItem) {
enum ExecEndTarget {
// Normal case: the active exec cell already tracks this call id.
ActiveTracked,
// We have an active exec group, but it does not contain this call id. Render the end
// as a standalone finalized history cell so the active group remains intact.
OrphanHistoryWhileActiveExec,
// No active exec cell can safely own this end; build a new cell from the end payload.
NewCell,
}
let ThreadItem::CommandExecution {
id,
command,
process_id: _,
source,
command_actions,
aggregated_output,
exit_code,
duration_ms,
..
} = item
else {
return;
};
let event_command = split_command_string(&command);
let event_parsed = command_actions
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect();
let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64);
let exit_code = exit_code.unwrap_or_default();
let aggregated_output = aggregated_output.unwrap_or_default();
let running = self.running_commands.remove(&id);
if self.suppressed_exec_calls.remove(&id) {
return;
}
let (command, parsed, source) = match running {
Some(rc) => (rc.command, rc.parsed_cmd, rc.source),
None => (event_command, event_parsed, source),
};
let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed);
let is_unified_exec_interaction =
matches!(source, ExecCommandSource::UnifiedExecInteraction);
let is_user_shell = source == ExecCommandSource::UserShell;
let end_target = match self.transcript.active_cell.as_ref() {
Some(cell) => match cell.as_any().downcast_ref::<ExecCell>() {
Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => {
ExecEndTarget::ActiveTracked
}
Some(exec_cell) if exec_cell.is_active() => {
ExecEndTarget::OrphanHistoryWhileActiveExec
}
Some(_) | None => ExecEndTarget::NewCell,
},
None => ExecEndTarget::NewCell,
};
// Unified exec interaction rows intentionally hide command output text in the exec cell and
// instead render the interaction-specific content elsewhere in the UI.
let output = if is_unified_exec_interaction {
CommandOutput {
exit_code,
formatted_output: String::new(),
aggregated_output: String::new(),
}
} else {
CommandOutput {
exit_code,
formatted_output: aggregated_output.clone(),
aggregated_output,
}
};
match end_target {
ExecEndTarget::ActiveTracked => {
if let Some(cell) = self
.transcript
.active_cell
.as_mut()
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
{
let completed = cell.complete_call(&id, output, duration);
debug_assert!(completed, "active exec cell should contain {id}");
if cell.should_flush() {
self.flush_active_cell();
} else {
self.bump_active_cell_revision();
self.request_redraw();
}
}
}
ExecEndTarget::OrphanHistoryWhileActiveExec => {
let mut orphan = new_active_exec_command(
id.clone(),
command,
parsed,
source,
/*interaction_input*/ None,
self.config.animations,
);
let completed = orphan.complete_call(&id, output, duration);
debug_assert!(completed, "new orphan exec cell should contain {id}");
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(orphan)));
self.request_redraw();
}
ExecEndTarget::NewCell => {
self.flush_active_cell();
let mut cell = new_active_exec_command(
id.clone(),
command,
parsed,
source,
/*interaction_input*/ None,
self.config.animations,
);
let completed = cell.complete_call(&id, output, duration);
debug_assert!(completed, "new exec cell should contain {id}");
if cell.should_flush() {
self.add_to_history(cell);
} else {
self.transcript.active_cell = Some(Box::new(cell));
self.bump_active_cell_revision();
self.request_redraw();
}
}
}
// Mark that actual work was done (command executed)
self.transcript.had_work_activity = true;
if is_user_shell {
self.maybe_send_next_queued_input();
}
}
}