mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
8fe0ecb045
## Why `chatwidget.rs` is still carrying too many unrelated responsibilities in one file. #22269 started a five-phase cleanup to move coherent behavior domains into focused modules while keeping `chatwidget.rs` as the composition layer. #22407 completed phase 2 by extracting input and submission flow. This PR is phase 3. It keeps moving high-churn event handling out of the central widget by extracting protocol, replay, streaming, and tool lifecycle handling without changing the visible behavior those flows already provide. This is once again just a mechanical movement of existing functions. No functional changes. ## What Changed - Added focused modules for protocol request dispatch, replay rendering, assistant/plan/reasoning streaming, turn runtime bookkeeping, hook lifecycle handling, command lifecycle handling, tool lifecycle rendering, and interactive tool request prompts. - Kept active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior in the same flows, just moved into smaller files. - Added module header comments to the new files so the ownership boundaries are explicit. - Left `codex-rs/tui/src/chatwidget.rs` as the registration and orchestration surface for these extracted behaviors. ## Cleanup Phases The five-phase cleanup plan from #22269 is: 1. Phase 1: mechanical helper and state moves. Completed in #22269. 2. Phase 2: extract input and submission flow, including queued user messages, shell prompt submission, pending steer restoration, and thread input snapshot/restore behavior. Completed in #22407. 3. Phase 3: extract protocol, replay, streaming, and tool lifecycle handling, while preserving active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior. This PR. 4. Phase 4: extract settings, popups, and status surfaces, including model/reasoning/collaboration/personality popups, permission prompts, rate-limit UI, and connectors helpers. 5. Phase 5: clean up the remaining constructor and orchestration code once the larger behavior domains have moved out, leaving `chatwidget.rs` as the composition layer.
455 lines
17 KiB
Rust
455 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;
|
|
}
|
|
self.flush_answer_stream_with_separator();
|
|
let command_display = self
|
|
.unified_exec_processes
|
|
.iter()
|
|
.find(|process| process.key == process_id)
|
|
.map(|process| process.command_display.clone());
|
|
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) {
|
|
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();
|
|
}
|
|
}
|
|
}
|