tui/src/chatwidget/transcript.rs
151 lines
//! Transcript and active-cell bookkeeping for `ChatWidget`.use super::HistoryCell;use super::MAX_AGENT_COPY_HISTORY;#[derive(Debug)]pub(super) struct AgentTurnMarkdown { pub(super) user_turn_count: usize, pub(super) markdown: String,}#[derive(Default)]pub(super) struct TranscriptState { pub(super) active_cell: Option<Box<dyn HistoryCell>>, /// Monotonic-ish counter used to invalidate transcript overlay caching. pub(super) active_cell_revision: u64, /// Raw markdown of the most recently completed agent response that /// survived any local thread rollback. pub(super) last_agent_markdown: Option<String>, /// Copyable agent responses keyed by the number of visible user turns at /// the time the response completed. pub(super) agent_turn_markdowns: Vec<AgentTurnMarkdown>, /// Number of user turns currently reflected in the visible transcript. pub(super) visible_user_turn_count: usize, /// True when rollback discarded the requested copy source because it was /// older than the retained copy history. pub(super) copy_history_evicted_by_rollback: bool, /// Raw markdown of the most recently completed proposed plan. pub(super) latest_proposed_plan_markdown: Option<String>, /// Whether this turn already produced a copyable response. pub(super) saw_copy_source_this_turn: bool, /// Whether the next streamed assistant content should be preceded by a final message separator. pub(super) needs_final_message_separator: bool, /// Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). pub(super) had_work_activity: bool, /// Whether the current turn emitted a plan update. pub(super) saw_plan_update_this_turn: bool, /// Whether the current turn emitted a proposed plan item that has not been superseded by a /// later steer. pub(super) saw_plan_item_this_turn: bool, /// Latest `update_plan` checklist task counts for terminal-title rendering. pub(super) last_plan_progress: Option<(usize, usize)>, /// Incremental buffer for streamed plan content. pub(super) plan_delta_buffer: String, /// True while a plan item is streaming. pub(super) plan_item_active: bool,}impl TranscriptState { pub(super) fn new(active_cell: Option<Box<dyn HistoryCell>>) -> Self { Self { active_cell, ..Self::default() } } pub(super) fn bump_active_cell_revision(&mut self) { // Wrapping avoids overflow; wraparound would require 2^64 bumps and at // worst causes a one-time cache-key collision. self.active_cell_revision = self.active_cell_revision.wrapping_add(1); } pub(super) fn record_agent_markdown(&mut self, markdown: String) { match self.agent_turn_markdowns.last_mut() { Some(entry) if entry.user_turn_count == self.visible_user_turn_count => { entry.markdown = markdown.clone(); } _ => { self.agent_turn_markdowns.push(AgentTurnMarkdown { user_turn_count: self.visible_user_turn_count, markdown: markdown.clone(), }); if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { self.agent_turn_markdowns.remove(0); } } } self.last_agent_markdown = Some(markdown); self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = true; } pub(super) fn record_visible_user_turn(&mut self) { self.visible_user_turn_count = self.visible_user_turn_count.saturating_add(1); } pub(super) fn reset_copy_history(&mut self) { self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); self.visible_user_turn_count = 0; self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = false; } pub(super) fn truncate_copy_history_to_user_turn_count(&mut self, user_turn_count: usize) { self.visible_user_turn_count = user_turn_count; let had_copy_history = !self.agent_turn_markdowns.is_empty(); self.agent_turn_markdowns .retain(|entry| entry.user_turn_count <= user_turn_count); self.last_agent_markdown = self .agent_turn_markdowns .last() .map(|entry| entry.markdown.clone()); self.copy_history_evicted_by_rollback = had_copy_history && self.last_agent_markdown.is_none(); self.saw_copy_source_this_turn = false; } pub(super) fn reset_turn_flags(&mut self) { self.saw_copy_source_this_turn = false; self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; self.had_work_activity = false; self.latest_proposed_plan_markdown = None; self.plan_delta_buffer.clear(); self.plan_item_active = false; }}#[cfg(test)]mod tests { use pretty_assertions::assert_eq; use super::*; #[test] fn active_cell_revision_wraps() { let mut state = TranscriptState { active_cell_revision: u64::MAX, ..TranscriptState::default() }; state.bump_active_cell_revision(); assert_eq!(state.active_cell_revision, 0); } #[test] fn copy_history_tracks_latest_visible_turn() { let mut state = TranscriptState::default(); state.record_visible_user_turn(); state.record_agent_markdown("first".to_string()); state.record_visible_user_turn(); state.record_agent_markdown("second".to_string()); state.truncate_copy_history_to_user_turn_count(/*user_turn_count*/ 1); assert_eq!(state.last_agent_markdown.as_deref(), Some("first")); assert!(!state.copy_history_evicted_by_rollback); }}