Codex Handbook
tui/src/app_backtrack.rs 1003 lines
//! Backtracking and transcript overlay event routing.//!//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also//! mediates a key rendering boundary for the transcript overlay.//!//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI//! state diverging from the agent if a rollback fails or targets a different thread.//!//! Backtrack operates as a small state machine://! - The first `Esc` in the main view "primes" the feature and captures a base thread id.//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message when//!   there is a rewind target.//! - `Enter` requests a rollback from core and records a `pending_rollback` guard.//! - On rollback completion, we either finish an in-flight backtrack request or queue a//!   rollback trim so it runs in event order with transcript inserts.//!//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live//! tail derived from the current in-flight `ChatWidget.active_cell`.//!//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects//! both committed history and in-flight activity without changing flush or coalescing behavior.use std::any::TypeId;use std::path::PathBuf;use std::sync::Arc;use crate::app::App;use crate::app_command::AppCommand;use crate::app_event::AppEvent;use crate::chatwidget::UserMessage;#[cfg(test)]use crate::history_cell::AgentMessageCell;use crate::history_cell::SessionInfoCell;use crate::history_cell::UserHistoryCell;use crate::pager_overlay::Overlay;use crate::tui;use crate::tui::TuiEvent;use codex_protocol::ThreadId;use codex_protocol::user_input::TextElement;use color_eyre::eyre::Result;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyEventKind;const NO_PREVIOUS_MESSAGE_TO_EDIT: &str = "No previous message to edit.";pub(crate) const SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE: &str =    "Editing previous prompts is unavailable in side conversations.";/// Aggregates all backtrack-related state used by the App.#[derive(Default)]pub(crate) struct BacktrackState {    /// True when Esc has primed backtrack mode in the main view.    pub(crate) primed: bool,    /// Session id of the base thread to rollback.    ///    /// If the current thread changes, backtrack selections become invalid and must be ignored.    pub(crate) base_id: Option<ThreadId>,    /// Index of the currently highlighted user message.    ///    /// This is an index into the filtered "user messages since the last session start" view,    /// not an index into `transcript_cells`. `usize::MAX` indicates "no selection".    pub(crate) nth_user_message: usize,    /// True when the transcript overlay is showing a backtrack preview.    pub(crate) overlay_preview_active: bool,    /// Pending rollback request awaiting confirmation from core.    ///    /// This acts as a guardrail: once we request a rollback, we block additional backtrack    /// submissions until core responds with either a success or failure event.    pub(crate) pending_rollback: Option<PendingBacktrackRollback>,}/// A user-visible backtrack choice that can be confirmed into a rollback request.#[derive(Debug, Clone)]pub(crate) struct BacktrackSelection {    /// The selected user message, counted from the most recent session start.    ///    /// This value is used both to compute the rollback depth and to trim the local transcript    /// after core confirms the rollback.    pub(crate) nth_user_message: usize,    /// Composer prefill derived from the selected user message.    ///    /// This is applied immediately on selection confirmation; if the rollback fails, the prefill    /// remains as a convenience so the user can retry or edit.    pub(crate) prefill: String,    /// Text elements associated with the selected user message.    pub(crate) text_elements: Vec<TextElement>,    /// Local image paths associated with the selected user message.    pub(crate) local_image_paths: Vec<PathBuf>,    /// Remote image URLs associated with the selected user message.    pub(crate) remote_image_urls: Vec<String>,}/// An in-flight rollback requested from core.////// We keep enough information to apply the corresponding local trim only if the response targets/// the same active thread we issued the request for.#[derive(Debug, Clone)]pub(crate) struct PendingBacktrackRollback {    pub(crate) selection: BacktrackSelection,    pub(crate) thread_id: Option<ThreadId>,}impl App {    /// Route overlay events while the transcript overlay is active.    ///    /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter    /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the    /// overlay.    pub(crate) async fn handle_backtrack_overlay_event(        &mut self,        tui: &mut tui::Tui,        event: TuiEvent,    ) -> Result<bool> {        if self.backtrack.overlay_preview_active {            match event {                TuiEvent::Key(KeyEvent {                    code: KeyCode::Esc,                    kind: KeyEventKind::Press | KeyEventKind::Repeat,                    ..                }) => {                    self.overlay_step_backtrack(tui, event)?;                    Ok(true)                }                TuiEvent::Key(KeyEvent {                    code: KeyCode::Left,                    kind: KeyEventKind::Press | KeyEventKind::Repeat,                    ..                }) => {                    self.overlay_step_backtrack(tui, event)?;                    Ok(true)                }                TuiEvent::Key(KeyEvent {                    code: KeyCode::Right,                    kind: KeyEventKind::Press | KeyEventKind::Repeat,                    ..                }) => {                    self.overlay_step_backtrack_forward(tui, event)?;                    Ok(true)                }                TuiEvent::Key(KeyEvent {                    code: KeyCode::Enter,                    kind: KeyEventKind::Press,                    ..                }) => {                    self.overlay_confirm_backtrack(tui);                    Ok(true)                }                _ => {                    self.overlay_forward_event(tui, event)?;                    Ok(true)                }            }        } else if let TuiEvent::Key(KeyEvent {            code: KeyCode::Esc,            kind: KeyEventKind::Press | KeyEventKind::Repeat,            ..        }) = event        {            // First Esc in transcript overlay: begin backtrack preview at latest user message.            self.begin_overlay_backtrack_preview(tui);            Ok(true)        } else {            // Not in backtrack mode: forward events to the overlay widget.            self.overlay_forward_event(tui, event)?;            Ok(true)        }    }    /// Handle global Esc presses for backtracking when no overlay is present.    pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) {        if !self.chat_widget.composer_is_empty() {            return;        }        if !self.backtrack.primed {            self.prime_backtrack();        } else if self.overlay.is_none() {            self.open_backtrack_preview(tui);        } else if self.backtrack.overlay_preview_active {            self.step_backtrack_and_highlight(tui);        }    }    /// Stage a backtrack and request thread history from the agent.    ///    /// We send the rollback request immediately, but we only mutate the transcript after core    /// confirms success so the UI cannot get ahead of the actual thread state.    ///    /// The composer prefill is applied immediately as a UX convenience; it does not imply that    /// core has accepted the rollback.    pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) {        if self.chat_widget.side_conversation_active() {            self.reset_backtrack_state();            self.chat_widget                .add_error_message(SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE.to_string());            return;        }        let user_total = user_count(&self.transcript_cells);        if user_total == 0 {            return;        }        if self.backtrack.pending_rollback.is_some() {            self.chat_widget                .add_error_message("Backtrack rollback already in progress.".to_string());            return;        }        let num_turns = user_total.saturating_sub(selection.nth_user_message);        let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX);        if num_turns == 0 {            return;        }        let prefill = selection.prefill.clone();        let text_elements = selection.text_elements.clone();        let local_image_paths = selection.local_image_paths.clone();        let remote_image_urls = selection.remote_image_urls.clone();        let has_remote_image_urls = !remote_image_urls.is_empty();        self.backtrack.pending_rollback = Some(PendingBacktrackRollback {            selection,            thread_id: self.chat_widget.thread_id(),        });        self.chat_widget            .submit_op(AppCommand::thread_rollback(num_turns));        self.chat_widget.set_remote_image_urls(remote_image_urls);        if !prefill.is_empty()            || !text_elements.is_empty()            || !local_image_paths.is_empty()            || has_remote_image_urls        {            self.chat_widget                .set_composer_text(prefill, text_elements, local_image_paths);        }    }    pub(crate) fn apply_cancelled_turn_edit(&mut self, prompt: UserMessage) {        let user_total = user_count(&self.transcript_cells);        let selection = BacktrackSelection {            nth_user_message: user_total.saturating_sub(1),            prefill: prompt.text.clone(),            text_elements: prompt.text_elements.clone(),            local_image_paths: prompt                .local_images                .iter()                .map(|image| image.path.clone())                .collect(),            remote_image_urls: prompt.remote_image_urls.clone(),        };        if user_total == 0 {            if self.backtrack.pending_rollback.is_some() {                self.chat_widget                    .add_error_message("Backtrack rollback already in progress.".to_string());                return;            }            self.backtrack.pending_rollback = Some(PendingBacktrackRollback {                selection,                thread_id: self.chat_widget.thread_id(),            });            self.chat_widget                .submit_op(AppCommand::thread_rollback(/*num_turns*/ 1));            self.chat_widget.restore_user_message_to_composer(prompt);            return;        }        self.apply_backtrack_rollback(selection);        self.chat_widget.restore_user_message_to_composer(prompt);    }    /// Open transcript overlay (enters alternate screen and shows full transcript).    pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {        let _ = tui.enter_alt_screen();        self.overlay = Some(Overlay::new_transcript(            self.transcript_cells.clone(),            self.keymap.pager.clone(),        ));        tui.frame_requester().schedule_frame();    }    /// Close transcript overlay and restore normal UI.    pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {        let _ = tui.leave_alt_screen();        let was_backtrack = self.backtrack.overlay_preview_active;        if !self.deferred_history_lines.is_empty() {            let lines = std::mem::take(&mut self.deferred_history_lines);            tui.insert_history_hyperlink_lines_with_wrap_policy(                lines,                self.history_line_wrap_policy(),            );        }        self.overlay = None;        self.backtrack.overlay_preview_active = false;        tui.frame_requester().schedule_frame();        if was_backtrack {            // Ensure backtrack state is fully reset when overlay closes (e.g. via 'q').            self.reset_backtrack_state();        }    }    /// Initialize backtrack state and show composer hint.    fn prime_backtrack(&mut self) {        self.backtrack.primed = true;        self.backtrack.nth_user_message = usize::MAX;        self.backtrack.base_id = self.chat_widget.thread_id();        if has_backtrack_target(&self.transcript_cells) {            self.chat_widget.show_esc_backtrack_hint();        }    }    /// Open overlay and begin backtrack preview flow (first step + highlight).    fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) {        if !has_backtrack_target(&self.transcript_cells) {            self.reset_backtrack_state();            self.chat_widget                .add_info_message(NO_PREVIOUS_MESSAGE_TO_EDIT.to_string(), /*hint*/ None);            tui.frame_requester().schedule_frame();            return;        }        self.open_transcript_overlay(tui);        self.backtrack.overlay_preview_active = true;        // Composer is hidden by overlay; clear its hint.        self.chat_widget.clear_esc_backtrack_hint();        self.step_backtrack_and_highlight(tui);    }    /// When overlay is already open, begin preview mode and select latest user message.    fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) {        if !has_backtrack_target(&self.transcript_cells) {            self.close_transcript_overlay(tui);            self.chat_widget                .add_info_message(NO_PREVIOUS_MESSAGE_TO_EDIT.to_string(), /*hint*/ None);            tui.frame_requester().schedule_frame();            return;        }        self.backtrack.primed = true;        self.backtrack.base_id = self.chat_widget.thread_id();        self.backtrack.overlay_preview_active = true;        let count = user_count(&self.transcript_cells);        if let Some(last) = count.checked_sub(1) {            self.apply_backtrack_selection_internal(last);        }        tui.frame_requester().schedule_frame();    }    /// Step selection to the next older user message and update overlay.    fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {        let count = user_count(&self.transcript_cells);        if count == 0 {            return;        }        let last_index = count.saturating_sub(1);        let next_selection = if self.backtrack.nth_user_message == usize::MAX {            last_index        } else if self.backtrack.nth_user_message == 0 {            0        } else {            self.backtrack                .nth_user_message                .saturating_sub(1)                .min(last_index)        };        self.apply_backtrack_selection_internal(next_selection);        tui.frame_requester().schedule_frame();    }    /// Step selection to the next newer user message and update overlay.    fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {        let count = user_count(&self.transcript_cells);        if count == 0 {            return;        }        let last_index = count.saturating_sub(1);        let next_selection = if self.backtrack.nth_user_message == usize::MAX {            last_index        } else {            self.backtrack                .nth_user_message                .saturating_add(1)                .min(last_index)        };        self.apply_backtrack_selection_internal(next_selection);        tui.frame_requester().schedule_frame();    }    /// Apply a computed backtrack selection to the overlay and internal counter.    fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) {        if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {            self.backtrack.nth_user_message = nth_user_message;            if let Some(Overlay::Transcript(t)) = &mut self.overlay {                t.set_highlight_cell(Some(cell_idx));            }        } else {            self.backtrack.nth_user_message = usize::MAX;            if let Some(Overlay::Transcript(t)) = &mut self.overlay {                t.set_highlight_cell(/*cell*/ None);            }        }    }    /// Forwards an event to the overlay and closes it if done.    ///    /// The transcript overlay draw path is special because the overlay should match the main    /// viewport while the active cell is still streaming or mutating.    ///    /// `TranscriptOverlay` owns committed transcript cells, while `ChatWidget` owns the current    /// in-flight active cell (often a coalesced exec/tool group). During draws we append that    /// in-flight cell as a cached, render-only live tail so `Ctrl+T` does not appear to "lose" tool    /// calls until a later flush boundary.    ///    /// This logic lives here (instead of inside the overlay widget) because `ChatWidget` is the    /// source of truth for the active cell and its cache invalidation key, and because `App` owns    /// overlay lifecycle and frame scheduling for animations.    fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {        if matches!(&event, TuiEvent::Draw | TuiEvent::Resize)            && let Some(Overlay::Transcript(t)) = &mut self.overlay        {            let active_key = self.chat_widget.active_cell_transcript_key();            let chat_widget = &self.chat_widget;            tui.draw(u16::MAX, |frame| {                let width = frame.area().width.max(1);                t.sync_live_tail(width, active_key, |w| {                    chat_widget.active_cell_transcript_hyperlink_lines(w)                });                t.render(frame.area(), frame.buffer);            })?;            let close_overlay = t.is_done();            if !close_overlay                && active_key.is_some_and(|key| key.animation_tick.is_some())                && t.is_scrolled_to_bottom()            {                tui.frame_requester()                    .schedule_frame_in(std::time::Duration::from_millis(50));            }            if close_overlay {                self.close_transcript_overlay(tui);                tui.frame_requester().schedule_frame();            }            return Ok(());        }        if let Some(overlay) = &mut self.overlay {            overlay.handle_event(tui, event)?;            if overlay.is_done() {                self.close_transcript_overlay(tui);                tui.frame_requester().schedule_frame();            }        }        Ok(())    }    /// Handle Enter in overlay backtrack preview: confirm selection and reset state.    fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) {        let nth_user_message = self.backtrack.nth_user_message;        let selection = self.backtrack_selection(nth_user_message);        self.close_transcript_overlay(tui);        if let Some(selection) = selection {            self.apply_backtrack_rollback(selection);            tui.frame_requester().schedule_frame();        }    }    /// Handle Esc in overlay backtrack preview: step selection if armed, else forward.    fn overlay_step_backtrack(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {        if self.backtrack.base_id.is_some() {            self.step_backtrack_and_highlight(tui);        } else {            self.overlay_forward_event(tui, event)?;        }        Ok(())    }    /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward.    fn overlay_step_backtrack_forward(        &mut self,        tui: &mut tui::Tui,        event: TuiEvent,    ) -> Result<()> {        if self.backtrack.base_id.is_some() {            self.step_forward_backtrack_and_highlight(tui);        } else {            self.overlay_forward_event(tui, event)?;        }        Ok(())    }    /// Confirm a primed backtrack from the main view (no overlay visible).    /// Computes the prefill from the selected user message for rollback.    pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option<BacktrackSelection> {        let selection = self.backtrack_selection(self.backtrack.nth_user_message);        self.reset_backtrack_state();        selection    }    /// Clear all backtrack-related state and composer hints.    pub(crate) fn reset_backtrack_state(&mut self) {        self.backtrack.primed = false;        self.backtrack.base_id = None;        self.backtrack.nth_user_message = usize::MAX;        // In case a hint is somehow still visible (e.g., race with overlay open/close).        self.chat_widget.clear_esc_backtrack_hint();    }    pub(crate) fn apply_backtrack_selection(        &mut self,        tui: &mut tui::Tui,        selection: BacktrackSelection,    ) {        self.apply_backtrack_rollback(selection);        tui.frame_requester().schedule_frame();    }    pub(crate) fn handle_backtrack_rollback_succeeded(&mut self, num_turns: u32) {        if self.backtrack.pending_rollback.is_some() {            self.finish_pending_backtrack();        } else {            self.app_event_tx                .send(AppEvent::ApplyThreadRollback { num_turns });        }    }    pub(crate) fn handle_backtrack_rollback_failed(&mut self) {        self.backtrack.pending_rollback = None;    }    /// Apply rollback semantics for a confirmed rollback where this TUI does    /// not have an in-flight backtrack request (`pending_rollback` is `None`).    ///    /// Returns `true` when local transcript state changed.    pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool {        if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) {            return false;        }        self.chat_widget.clear_pending_token_activity_refreshes();        self.chat_widget.clear_pending_rate_limit_reset_hint();        self.chat_widget            .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));        self.sync_overlay_after_transcript_trim();        self.backtrack_render_pending = true;        true    }    /// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh.    ///    /// We ignore events that do not correspond to the currently active thread to avoid applying    /// stale updates after a session switch.    fn finish_pending_backtrack(&mut self) {        let Some(pending) = self.backtrack.pending_rollback.take() else {            return;        };        if pending.thread_id != self.chat_widget.thread_id() {            // Ignore rollbacks targeting a prior thread.            return;        }        if trim_transcript_cells_to_nth_user(            &mut self.transcript_cells,            pending.selection.nth_user_message,        ) {            self.chat_widget.clear_pending_token_activity_refreshes();            self.chat_widget.clear_pending_rate_limit_reset_hint();            self.chat_widget                .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));            self.sync_overlay_after_transcript_trim();            self.backtrack_render_pending = true;        }    }    fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {        let base_id = self.backtrack.base_id?;        if self.chat_widget.thread_id() != Some(base_id) {            return None;        }        let (prefill, text_elements, local_image_paths, remote_image_urls) =            nth_user_position(&self.transcript_cells, nth_user_message)                .and_then(|idx| self.transcript_cells.get(idx))                .and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())                .map(|cell| {                    (                        cell.message.clone(),                        cell.text_elements.clone(),                        cell.local_image_paths.clone(),                        cell.remote_image_urls.clone(),                    )                })                .unwrap_or_else(|| (String::new(), Vec::new(), Vec::new(), Vec::new()));        Some(BacktrackSelection {            nth_user_message,            prefill,            text_elements,            local_image_paths,            remote_image_urls,        })    }    /// Keep transcript-related UI state aligned after `transcript_cells` was trimmed.    ///    /// This does three things:    /// 1. If transcript overlay is open, replace its committed cells so removed turns disappear.    /// 2. If backtrack preview is active, clamp/recompute the highlighted user selection.    /// 3. Drop deferred transcript lines buffered while overlay was open to avoid flushing lines    ///    for cells that were just removed by the trim.    fn sync_overlay_after_transcript_trim(&mut self) {        if let Some(Overlay::Transcript(t)) = &mut self.overlay {            t.replace_cells(self.transcript_cells.clone());        }        if self.backtrack.overlay_preview_active {            let total_users = user_count(&self.transcript_cells);            let next_selection = if total_users == 0 {                usize::MAX            } else {                self.backtrack                    .nth_user_message                    .min(total_users.saturating_sub(1))            };            self.apply_backtrack_selection_internal(next_selection);        }        // While overlay is open, we buffer rendered history lines and flush them on close.        // If rollback trimmed cells meanwhile, those buffered lines can reference removed turns.        self.deferred_history_lines.clear();    }}fn trim_transcript_cells_to_nth_user(    transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,    nth_user_message: usize,) -> bool {    if nth_user_message == usize::MAX {        return false;    }    if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) {        let original_len = transcript_cells.len();        transcript_cells.truncate(cut_idx);        return transcript_cells.len() != original_len;    }    false}pub(crate) fn trim_transcript_cells_drop_last_n_user_turns(    transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,    num_turns: u32,) -> bool {    if num_turns == 0 {        return false;    }    let user_positions: Vec<usize> = user_positions_iter(transcript_cells).collect();    let Some(&first_user_idx) = user_positions.first() else {        return false;    };    let turns_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);    let cut_idx = if turns_from_end >= user_positions.len() {        first_user_idx    } else {        user_positions[user_positions.len() - turns_from_end]    };    let original_len = transcript_cells.len();    transcript_cells.truncate(cut_idx);    transcript_cells.len() != original_len}pub(crate) fn user_count(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) -> usize {    user_positions_iter(cells).count()}fn has_backtrack_target(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) -> bool {    user_count(cells) > 0}fn nth_user_position(    cells: &[Arc<dyn crate::history_cell::HistoryCell>],    nth: usize,) -> Option<usize> {    user_positions_iter(cells)        .enumerate()        .find_map(|(i, idx)| (i == nth).then_some(idx))}fn user_positions_iter(    cells: &[Arc<dyn crate::history_cell::HistoryCell>],) -> impl Iterator<Item = usize> + '_ {    let session_start_type = TypeId::of::<SessionInfoCell>();    let user_type = TypeId::of::<UserHistoryCell>();    let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();    let start = cells        .iter()        .rposition(|cell| type_of(cell) == session_start_type)        .map_or(0, |idx| idx + 1);    cells        .iter()        .enumerate()        .skip(start)        .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx))}#[cfg(test)]fn agent_group_count(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) -> usize {    agent_group_positions_iter(cells).count()}#[cfg(test)]fn agent_group_positions_iter(    cells: &[Arc<dyn crate::history_cell::HistoryCell>],) -> impl Iterator<Item = usize> + '_ {    let session_start_type = TypeId::of::<SessionInfoCell>();    let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();    let start = cells        .iter()        .rposition(|cell| type_of(cell) == session_start_type)        .map_or(0, |idx| idx + 1);    cells        .iter()        .enumerate()        .skip(start)        .filter_map(move |(idx, cell)| {            let is_agent = cell.as_any().downcast_ref::<AgentMessageCell>().is_some();            let is_copy_source_group = is_agent && !cell.is_stream_continuation();            is_copy_source_group.then_some(idx)        })}#[cfg(test)]mod tests {    use super::*;    use crate::history_cell::AgentMessageCell;    use crate::history_cell::HistoryCell;    use pretty_assertions::assert_eq;    use ratatui::prelude::Line;    use std::sync::Arc;    fn render_lines(lines: &[Line<'static>]) -> Vec<String> {        lines            .iter()            .map(|line| {                line.spans                    .iter()                    .map(|span| span.content.as_ref())                    .collect::<String>()            })            .collect()    }    #[test]    fn trim_transcript_for_first_user_drops_user_and_newer_cells() {        let mut cells: Vec<Arc<dyn HistoryCell>> = vec![            Arc::new(UserHistoryCell {                message: "first user".to_string(),                text_elements: Vec::new(),                local_image_paths: Vec::new(),                remote_image_urls: Vec::new(),            }) as Arc<dyn HistoryCell>,            Arc::new(AgentMessageCell::new(                vec![Line::from("assistant")],                /*is_first_line*/ true,            )) as Arc<dyn HistoryCell>,        ];        trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 0);        assert!(cells.is_empty());    }    #[test]    fn trim_transcript_preserves_cells_before_selected_user() {        let mut cells: Vec<Arc<dyn HistoryCell>> = vec![            Arc::new(AgentMessageCell::new(                vec![Line::from("intro")],                /*is_first_line*/ true,            )) as Arc<dyn HistoryCell>,            Arc::new(UserHistoryCell {                message: "first".to_string(),                text_elements: Vec::new(),                local_image_paths: Vec::new(),                remote_image_urls: Vec::new(),            }) as Arc<dyn HistoryCell>,            Arc::new(AgentMessageCell::new(                vec![Line::from("after")],                /*is_first_line*/ false,            )) as Arc<dyn HistoryCell>,        ];        trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 0);        assert_eq!(cells.len(), 1);        let agent = cells[0]            .as_any()            .downcast_ref::<AgentMessageCell>()            .expect("agent cell");        let agent_lines = agent.display_lines(u16::MAX);        assert_eq!(agent_lines.len(), 1);        let intro_text: String = agent_lines[0]            .spans            .iter()            .map(|span| span.content.as_ref())            .collect();        assert_eq!(intro_text, "• intro");    }    #[test]    fn trim_transcript_for_later_user_keeps_prior_history() {        let mut cells: Vec<Arc<dyn HistoryCell>> = vec![            Arc::new(AgentMessageCell::new(                vec![Line::from("intro")],                /*is_first_line*/ true,            )) as Arc<dyn HistoryCell>,            Arc::new(UserHistoryCell {                message: "first".to_string(),                text_elements: Vec::new(),                local_image_paths: Vec::new(),                remote_image_urls: Vec::new(),            }) as Arc<dyn HistoryCell>,            Arc::new(AgentMessageCell::new(                vec![Line::from("between")],                /*is_first_line*/ false,            )) as Arc<dyn HistoryCell>,            Arc::new(UserHistoryCell {                message: "second".to_string(),                text_elements: Vec::new(),                local_image_paths: Vec::new(),                remote_image_urls: Vec::new(),            }) as Arc<dyn HistoryCell>,            Arc::new(AgentMessageCell::new(                vec![Line::from("tail")],                /*is_first_line*/ false,            )) as Arc<dyn HistoryCell>,        ];        trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 1);        assert_eq!(cells.len(), 3);        let agent_intro = cells[0]            .as_any()            .downcast_ref::<AgentMessageCell>()            .expect("intro agent");        let intro_lines = agent_intro.display_lines(u16::MAX);        let intro_text: String = intro_lines[0]            .spans            .iter()            .map(|span| span.content.as_ref())            .collect();        assert_eq!(intro_text, "• intro");        let user_first = cells[1]            .as_any()            .downcast_ref::<UserHistoryCell>()            .expect("first user");        assert_eq!(user_first.message, "first");        let agent_between = cells[2]            .as_any()            .downcast_ref::<AgentMessageCell>()            .expect("between agent");        let between_lines = agent_between.display_lines(u16::MAX);        let between_text: String = between_lines[0]            .spans            .iter()            .map(|span| span.content.as_ref())            .collect();        assert_eq!(between_text, "  between");    }    #[test]    fn trim_drop_last_n_user_turns_applies_rollback_semantics() {        let mut cells: Vec<Arc<dyn HistoryCell>> = vec![            Arc::new(UserHistoryCell {                message: "first".to_string(),                text_elements: Vec::new(),                local_image_paths: Vec::new(),                remote_image_urls: Vec::new(),            }) as Arc<dyn HistoryCell>,            Arc::new(AgentMessageCell::new(                vec![Line::from("after first")],                /*is_first_line*/ false,            )) as Arc<dyn HistoryCell>,            Arc::new(UserHistoryCell {                message: "second".to_string(),                text_elements: Vec::new(),                local_image_paths: Vec::new(),                remote_image_urls: Vec::new(),            }) as Arc<dyn HistoryCell>,            Arc::new(AgentMessageCell::new(                vec![Line::from("after second")],                /*is_first_line*/ false,            )) as Arc<dyn HistoryCell>,        ];        let changed =            trim_transcript_cells_drop_last_n_user_turns(&mut cells, /*num_turns*/ 1);        assert!(changed);        assert_eq!(cells.len(), 2);        let first_user = cells[0]            .as_any()            .downcast_ref::<UserHistoryCell>()            .expect("first user");        assert_eq!(first_user.message, "first");    }    #[test]    fn trim_drop_last_n_user_turns_allows_overflow() {        let mut cells: Vec<Arc<dyn HistoryCell>> = vec![            Arc::new(AgentMessageCell::new(                vec![Line::from("intro")],                /*is_first_line*/ true,            )) as Arc<dyn HistoryCell>,            Arc::new(UserHistoryCell {                message: "first".to_string(),                text_elements: Vec::new(),                local_image_paths: Vec::new(),                remote_image_urls: Vec::new(),            }) as Arc<dyn HistoryCell>,            Arc::new(AgentMessageCell::new(                vec![Line::from("after")],                /*is_first_line*/ false,            )) as Arc<dyn HistoryCell>,        ];        let changed = trim_transcript_cells_drop_last_n_user_turns(&mut cells, u32::MAX);        assert!(changed);        assert_eq!(cells.len(), 1);        let intro = cells[0]            .as_any()            .downcast_ref::<AgentMessageCell>()            .expect("intro agent");        let intro_lines = intro.display_lines(u16::MAX);        let intro_text: String = intro_lines[0]            .spans            .iter()            .map(|span| span.content.as_ref())            .collect();        assert_eq!(intro_text, "• intro");    }    #[test]    fn agent_group_count_ignores_context_compacted_marker() {        let cells: Vec<Arc<dyn HistoryCell>> = vec![            Arc::new(AgentMessageCell::new(                vec![Line::from("first")],                /*is_first_line*/ true,            )) as Arc<dyn HistoryCell>,            Arc::new(crate::history_cell::new_info_event(                "Context compacted".to_string(),                /*hint*/ None,            )) as Arc<dyn HistoryCell>,            Arc::new(AgentMessageCell::new(                vec![Line::from("second")],                /*is_first_line*/ true,            )) as Arc<dyn HistoryCell>,        ];        assert_eq!(agent_group_count(&cells), 2);    }    #[test]    fn backtrack_target_requires_user_message() {        let mut cells: Vec<Arc<dyn HistoryCell>> = vec![            Arc::new(AgentMessageCell::new(                vec![Line::from("assistant")],                /*is_first_line*/ true,            )) as Arc<dyn HistoryCell>,            Arc::new(crate::history_cell::new_info_event(                "Context compacted".to_string(),                /*hint*/ None,            )) as Arc<dyn HistoryCell>,        ];        assert!(!has_backtrack_target(&cells));        cells.push(Arc::new(UserHistoryCell {            message: "hello".to_string(),            text_elements: Vec::new(),            local_image_paths: Vec::new(),            remote_image_urls: Vec::new(),        }) as Arc<dyn HistoryCell>);        assert!(has_backtrack_target(&cells));    }    #[test]    fn backtrack_unavailable_info_message_snapshot() {        let cell = crate::history_cell::new_info_event(            NO_PREVIOUS_MESSAGE_TO_EDIT.to_string(),            /*hint*/ None,        );        let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");        insta::assert_snapshot!(rendered);    }}