Codex Handbook
tui/src/exec_cell/model.rs 176 lines
//! Data model for grouped exec-call history cells in the TUI transcript.//!//! An `ExecCell` can represent either a single command or an "exploring" group of related read///! list/search commands. The chat widget relies on stable `call_id` matching to route progress and//! end events into the right cell, and it treats "call id not found" as a real signal (for//! example, an orphan end that should render as a separate history entry).use std::time::Duration;use std::time::Instant;use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;use codex_protocol::parse_command::ParsedCommand;#[derive(Clone, Debug, Default)]pub(crate) struct CommandOutput {    pub(crate) exit_code: i32,    /// The aggregated stderr + stdout interleaved.    pub(crate) aggregated_output: String,    /// The formatted output of the command, as seen by the model.    pub(crate) formatted_output: String,}#[derive(Debug, Clone)]pub(crate) struct ExecCall {    pub(crate) call_id: String,    pub(crate) command: Vec<String>,    pub(crate) parsed: Vec<ParsedCommand>,    pub(crate) output: Option<CommandOutput>,    pub(crate) source: ExecCommandSource,    pub(crate) start_time: Option<Instant>,    pub(crate) duration: Option<Duration>,    pub(crate) interaction_input: Option<String>,}#[derive(Debug)]pub(crate) struct ExecCell {    pub(crate) calls: Vec<ExecCall>,    animations_enabled: bool,}impl ExecCell {    pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self {        Self {            calls: vec![call],            animations_enabled,        }    }    pub(crate) fn with_added_call(        &self,        call_id: String,        command: Vec<String>,        parsed: Vec<ParsedCommand>,        source: ExecCommandSource,        interaction_input: Option<String>,    ) -> Option<Self> {        let call = ExecCall {            call_id,            command,            parsed,            output: None,            source,            start_time: Some(Instant::now()),            duration: None,            interaction_input,        };        if self.is_exploring_cell() && Self::is_exploring_call(&call) {            Some(Self {                calls: [self.calls.clone(), vec![call]].concat(),                animations_enabled: self.animations_enabled,            })        } else {            None        }    }    /// Marks the most recently matching call as finished and returns whether a call was found.    ///    /// Callers should treat `false` as a routing mismatch rather than silently ignoring it. The    /// chat widget uses that signal to avoid attaching an orphan `exec_end` event to an unrelated    /// active exploring cell, which would incorrectly collapse two transcript entries together.    pub(crate) fn complete_call(        &mut self,        call_id: &str,        output: CommandOutput,        duration: Duration,    ) -> bool {        let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) else {            return false;        };        call.output = Some(output);        call.duration = Some(duration);        call.start_time = None;        true    }    pub(crate) fn should_flush(&self) -> bool {        !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some())    }    pub(crate) fn mark_failed(&mut self) {        for call in self.calls.iter_mut() {            if call.output.is_none() {                let elapsed = call                    .start_time                    .map(|st| st.elapsed())                    .unwrap_or_else(|| Duration::from_millis(0));                call.start_time = None;                call.duration = Some(elapsed);                call.output = Some(CommandOutput {                    exit_code: 1,                    formatted_output: String::new(),                    aggregated_output: String::new(),                });            }        }    }    pub(crate) fn is_exploring_cell(&self) -> bool {        self.calls.iter().all(Self::is_exploring_call)    }    pub(crate) fn is_active(&self) -> bool {        self.calls.iter().any(|c| c.output.is_none())    }    pub(crate) fn active_start_time(&self) -> Option<Instant> {        self.calls            .iter()            .find(|c| c.output.is_none())            .and_then(|c| c.start_time)    }    pub(crate) fn animations_enabled(&self) -> bool {        self.animations_enabled    }    pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {        self.calls.iter()    }    pub(crate) fn append_output(&mut self, call_id: &str, chunk: &str) -> bool {        if chunk.is_empty() {            return false;        }        let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) else {            return false;        };        let output = call.output.get_or_insert_with(CommandOutput::default);        output.aggregated_output.push_str(chunk);        true    }    pub(super) fn is_exploring_call(call: &ExecCall) -> bool {        !matches!(call.source, ExecCommandSource::UserShell)            && !call.parsed.is_empty()            && call.parsed.iter().all(|p| {                matches!(                    p,                    ParsedCommand::Read { .. }                        | ParsedCommand::ListFiles { .. }                        | ParsedCommand::Search { .. }                )            })    }}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)    }}