use crate::{
output::redact_sensitive_text,
tui::{PADDED_INLINE_SEPARATOR, split_once_inline_separator},
};
use ratatui::{
text::{Line, Span},
widgets::{Paragraph, Wrap},
};
use std::{
collections::{BTreeMap, HashMap, VecDeque},
ops::Index,
sync::Arc,
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const MAX_TRANSCRIPT_LINES: usize = 500;
#[derive(Debug, Clone)]
pub(crate) struct CachedTranscriptCard {
pub(crate) content_marker: u64,
pub(crate) card: Arc<crate::tui::transcript_cards::TranscriptCard>,
}
#[derive(Debug)]
pub(crate) struct CachedTranscriptVisualBlock {
pub(crate) content_marker: u64,
pub(crate) theme_revision: u64,
pub(crate) active: bool,
pub(crate) prepend_blank: bool,
pub(crate) subagent_card_rows: usize,
pub(crate) lines: Vec<crate::tui::transcript_cards::TranscriptVisualLine>,
pub(crate) line_rows: Vec<usize>,
pub(crate) rows: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct CachedTranscriptVisualLines {
pub(crate) generation: u64,
pub(crate) theme_revision: u64,
pub(crate) active: bool,
pub(crate) lines: Vec<crate::tui::transcript_cards::TranscriptVisualLine>,
pub(crate) rows: usize,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CachedTranscriptScrollbarRows {
pub(crate) rows: usize,
pub(crate) exact: bool,
}
#[derive(Debug, Default)]
pub(crate) struct CachedTranscriptHistoryIndex {
pub(crate) generation: u64,
pub(crate) source_start: Option<usize>,
pub(crate) source_len: usize,
pub(crate) theme_revision: u64,
pub(crate) active: bool,
pub(crate) subagent_card_rows: usize,
pub(crate) next_entry: usize,
pub(crate) blocks: Vec<Arc<CachedTranscriptVisualBlock>>,
pub(crate) cumulative_rows: Vec<usize>,
}
#[derive(Debug, Default)]
pub(crate) struct CachedStreamingTranscript {
pub(crate) entry_index: usize,
pub(crate) completed_prefix: String,
pub(crate) completed_lines: Vec<crate::rendering::DisplayLine>,
pub(crate) visual_lines: HashMap<u16, CachedStreamingVisualLines>,
}
#[derive(Debug)]
pub(crate) struct CachedStreamingVisualLines {
pub(crate) completed_prefix: String,
pub(crate) theme_revision: u64,
pub(crate) active: bool,
pub(crate) lines: Vec<crate::tui::transcript_cards::TranscriptVisualLine>,
}
pub(crate) const MAX_STREAMING_CACHE_BYTES: usize = 256 * 1024;
const MAX_TRANSCRIPT_WIDTH_CACHES: usize = 4;
#[derive(Debug, Default)]
pub(crate) struct TranscriptRenderCache {
#[cfg(test)]
pub(crate) visual_text: Option<String>,
pub(crate) visual_rows: HashMap<u16, usize>,
pub(crate) scrollbar_rows: HashMap<u16, CachedTranscriptScrollbarRows>,
pub(crate) cards: BTreeMap<usize, CachedTranscriptCard>,
pub(crate) visual_lines: HashMap<u16, CachedTranscriptVisualLines>,
pub(crate) visual_blocks: HashMap<u16, BTreeMap<usize, Arc<CachedTranscriptVisualBlock>>>,
pub(crate) history_indices: HashMap<u16, CachedTranscriptHistoryIndex>,
pub(crate) streaming: Option<CachedStreamingTranscript>,
visual_widths: VecDeque<u16>,
}
impl TranscriptRenderCache {
pub(crate) fn clear(&mut self) {
self.clear_visual_projection();
self.cards.clear();
self.streaming = None;
}
pub(crate) fn clear_visual_projection(&mut self) {
self.clear_visual_aggregates();
self.visual_blocks.clear();
self.visual_widths.clear();
if let Some(streaming) = &mut self.streaming {
streaming.visual_lines.clear();
}
}
pub(crate) fn clear_visual_aggregates(&mut self) {
#[cfg(test)]
{
self.visual_text = None;
}
self.visual_rows.clear();
self.scrollbar_rows.clear();
self.visual_lines.clear();
self.history_indices.clear();
}
pub(crate) fn touch_visual_width(&mut self, width: u16) {
let width = width.max(1);
if let Some(index) = self
.visual_widths
.iter()
.position(|cached| *cached == width)
{
let width = self.visual_widths.remove(index).expect("cached width");
self.visual_widths.push_back(width);
return;
}
if self.visual_widths.len() >= MAX_TRANSCRIPT_WIDTH_CACHES
&& let Some(evicted_width) = self.visual_widths.pop_front()
{
self.visual_rows.remove(&evicted_width);
self.scrollbar_rows.remove(&evicted_width);
self.visual_lines.remove(&evicted_width);
self.visual_blocks.remove(&evicted_width);
self.history_indices.remove(&evicted_width);
if let Some(streaming) = &mut self.streaming {
streaming.visual_lines.remove(&evicted_width);
}
}
self.visual_widths.push_back(width);
}
pub(crate) fn insert_visual_block(
&mut self,
width: u16,
entry_index: usize,
block: Arc<CachedTranscriptVisualBlock>,
) {
let width = width.max(1);
self.touch_visual_width(width);
self.visual_blocks
.entry(width)
.or_default()
.insert(entry_index, block);
}
pub(crate) fn evict_card(&mut self, entry_index: usize) {
self.evict_cards(std::iter::once(entry_index));
}
pub(crate) fn evict_cards(&mut self, entry_indices: impl IntoIterator<Item = usize>) {
let entry_indices = entry_indices.into_iter().collect::<Vec<_>>();
if entry_indices.is_empty() {
return;
}
for entry_index in entry_indices {
self.cards.remove(&entry_index);
for blocks in self.visual_blocks.values_mut() {
blocks.remove(&entry_index);
}
}
self.clear_visual_aggregates();
}
pub(crate) fn evict_front(&mut self, old_base: usize, drained: usize) {
self.evict_cards(old_base..old_base.saturating_add(drained));
if self
.streaming
.as_ref()
.is_some_and(|streaming| streaming.entry_index < old_base.saturating_add(drained))
{
self.streaming = None;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TranscriptEntryKind {
UserPrompt { automatic: bool },
Bash,
Assistant,
Thinking,
Tool,
Error,
Session,
Compaction,
Canceled,
Diagnostic { level: String },
HookDiagnostic,
ProviderContextInjection,
SubdirInstruction,
LegacyDiagnostic,
}
impl TranscriptEntryKind {
pub(crate) fn is_assistant(&self) -> bool {
matches!(self, Self::Assistant)
}
pub(crate) fn is_thinking(&self) -> bool {
matches!(self, Self::Thinking)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TranscriptToolIdentity {
Summary(crate::output::ToolDisplaySummary),
Named {
name: String,
status: Option<crate::output::ActivityStatus>,
},
}
impl TranscriptToolIdentity {
pub(crate) fn name(&self) -> &str {
match self {
Self::Summary(summary) => &summary.tool_name,
Self::Named { name, .. } => name,
}
}
pub(crate) fn status(&self) -> Option<crate::output::ActivityStatus> {
use crate::output::{ActivityStatus, ToolStatus};
match self {
Self::Named { status, .. } => *status,
Self::Summary(summary) => Some(match summary.status {
ToolStatus::Running => ActivityStatus::Running,
ToolStatus::Writing => ActivityStatus::Writing,
ToolStatus::Success => ActivityStatus::Success,
ToolStatus::Failure => ActivityStatus::Failed,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptSubagentSummary {
pub(crate) label: String,
pub(crate) task_id: String,
pub(crate) intent: String,
pub(crate) status: Option<crate::output::ActivityStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptToolData {
pub(crate) identity: TranscriptToolIdentity,
pub(crate) children: Vec<TranscriptSubagentSummary>,
}
fn legacy_tool_data(body: &str) -> TranscriptToolData {
use crate::output::ActivityStatus;
let text = body.trim_start();
let status = match text.chars().next() {
Some('✓') => Some(ActivityStatus::Success),
Some('✗') => Some(ActivityStatus::Failed),
Some('●' | '⟳' | '○') => Some(ActivityStatus::Running),
Some('⊘') => Some(ActivityStatus::Canceled),
_ => None,
};
let name = text
.trim_start_matches(['✓', '✗', '●', '⟳', '○', '⊘'])
.trim_start()
.split(['•', '·'])
.next()
.unwrap_or_default()
.split_whitespace()
.next()
.unwrap_or_default()
.to_string();
let children = body
.lines()
.filter_map(|line| {
let rest = line
.trim()
.strip_prefix("├─ ")
.or_else(|| line.trim().strip_prefix("└─ "))?;
let (label, status) = crate::tui::rsplit_once_inline_separator(rest)
.map(|(label, status)| {
(
label,
match status.trim() {
"queued" => Some(ActivityStatus::Queued),
"running" | "writing" => Some(ActivityStatus::Running),
"completed" | "success" => Some(ActivityStatus::Success),
"failed" | "failure" => Some(ActivityStatus::Failed),
"canceled" | "cancelled" => Some(ActivityStatus::Canceled),
_ => None,
},
)
})
.unwrap_or((rest, None));
Some(subagent_summary(label, status))
})
.collect();
TranscriptToolData {
identity: TranscriptToolIdentity::Named { name, status },
children,
}
}
pub(crate) fn subagent_summary(
label: &str,
status: Option<crate::output::ActivityStatus>,
) -> TranscriptSubagentSummary {
let label = crate::tui::normalize_inline_separators(&sanitize_preview(label));
let trimmed = label.trim();
let (task_id, rest) = if let Some(after_g) = trimmed.strip_prefix('g') {
let digits = after_g.bytes().take_while(u8::is_ascii_digit).count();
if digits > 0 {
(
format!("g{}", &after_g[..digits]),
after_g[digits..].trim_start_matches(['•', '·', ' ', '\t']),
)
} else {
(String::new(), trimmed)
}
} else {
(String::new(), trimmed)
};
let intent = if task_id.is_empty() {
trimmed.to_string()
} else {
rest.split(['•', '·'])
.map(str::trim)
.filter(|part| !part.is_empty() && !part.starts_with("depth"))
.collect::<Vec<_>>()
.join(" • ")
};
TranscriptSubagentSummary {
label,
task_id,
intent,
status,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptEntry {
kind: TranscriptEntryKind,
rendered: String,
body: String,
tool: Option<TranscriptToolData>,
}
impl TranscriptEntry {
fn from_parts(kind: TranscriptEntryKind, rendered: String, body: String) -> Self {
Self {
kind,
rendered,
body,
tool: None,
}
}
pub(crate) fn kind(&self) -> &TranscriptEntryKind {
&self.kind
}
pub(crate) fn rendered(&self) -> &str {
&self.rendered
}
pub(crate) fn body(&self) -> &str {
&self.body
}
pub(crate) fn tool_data(&self) -> Option<&TranscriptToolData> {
self.tool.as_ref()
}
pub(crate) fn user_prompt(text: &str) -> Self {
Self::prefixed(
TranscriptEntryKind::UserPrompt { automatic: false },
"you: ",
text,
)
}
pub(crate) fn automatic_user_prompt(text: &str) -> Self {
Self::prefixed(
TranscriptEntryKind::UserPrompt { automatic: true },
"you [automatic]: ",
text,
)
}
pub(crate) fn bash(command: &str) -> Self {
Self::prefixed(TranscriptEntryKind::Bash, "bash: ", command)
}
pub(crate) fn assistant(text: &str) -> Self {
Self::prefixed(TranscriptEntryKind::Assistant, "assistant: ", text)
}
pub(crate) fn thinking(text: &str) -> Self {
Self::prefixed(TranscriptEntryKind::Thinking, "thinking: ", text)
}
pub(crate) fn tool(body: String, data: TranscriptToolData) -> Self {
let mut entry = Self::prefixed(TranscriptEntryKind::Tool, "tool: ", &body);
entry.tool = Some(data);
entry
}
pub(crate) fn error(message: &str) -> Self {
let message = compact_error_message(&sanitize_preview(message));
Self::prefixed(TranscriptEntryKind::Error, "error: ", &message)
}
pub(crate) fn session(body: String) -> Self {
Self::prefixed(TranscriptEntryKind::Session, "session: ", &body)
}
pub(crate) fn compaction(body: String) -> Self {
Self::prefixed(TranscriptEntryKind::Compaction, "compact: ", &body)
}
pub(crate) fn canceled(prompt_preview: &str) -> Self {
let preview = sanitize_preview(prompt_preview);
let body = if preview.trim().is_empty() {
"prompt".to_string()
} else {
preview
};
Self::prefixed(TranscriptEntryKind::Canceled, "canceled: ", &body)
}
pub(crate) fn warning(message: &str) -> Self {
Self::prefixed(
TranscriptEntryKind::Diagnostic {
level: "warning".to_string(),
},
"warning: ",
message,
)
}
pub(crate) fn diagnostic(level: &str, body: &str) -> Self {
let level = sanitize_preview(level);
let body = sanitize_preview(&redact_sensitive_text(body));
let normalized = level.trim().to_ascii_lowercase();
let kind = match normalized.as_str() {
"error" => TranscriptEntryKind::Error,
"info" | "warn" | "warning" => TranscriptEntryKind::Diagnostic {
level: level.trim().to_string(),
},
_ => TranscriptEntryKind::LegacyDiagnostic,
};
let rendered = format!("{level}: {body}");
Self::from_parts(kind, rendered, body)
}
pub(crate) fn hook_diagnostic(message: &str) -> Self {
Self::prefixed(TranscriptEntryKind::HookDiagnostic, "hook: ", message)
}
pub(crate) fn provider_context(rendered: String) -> Self {
Self::unprefixed(TranscriptEntryKind::ProviderContextInjection, rendered)
}
pub(crate) fn subdir_instruction(rendered: String) -> Self {
Self::unprefixed(TranscriptEntryKind::SubdirInstruction, rendered)
}
fn prefixed(kind: TranscriptEntryKind, prefix: &str, body: &str) -> Self {
let body = sanitize_preview(body);
Self::from_parts(kind, format!("{prefix}{body}"), body)
}
fn unprefixed(kind: TranscriptEntryKind, rendered: String) -> Self {
let rendered = sanitize_preview(&rendered);
Self::from_parts(kind, rendered.clone(), rendered)
}
pub(crate) fn from_legacy(raw: String) -> Self {
let rendered = sanitize_preview(&raw);
if let Some(body) = rendered
.strip_prefix("you [automatic]: ")
.map(str::to_owned)
{
return Self::from_parts(
TranscriptEntryKind::UserPrompt { automatic: true },
rendered,
body,
);
}
if let Some(body) = rendered.strip_prefix("you: ").map(str::to_owned) {
return Self::from_parts(
TranscriptEntryKind::UserPrompt { automatic: false },
rendered,
body,
);
}
if let Some(body) = rendered.strip_prefix("bash: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Bash, rendered, body);
}
if let Some(body) = rendered.strip_prefix("assistant: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Assistant, rendered, body);
}
if let Some(body) = rendered.strip_prefix("thinking: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Thinking, rendered, body);
}
if let Some(body) = rendered.strip_prefix("tool: ").map(str::to_owned) {
let data = legacy_tool_data(&body);
return Self::tool(body, data);
}
if let Some(body) = rendered.strip_prefix("error: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Error, rendered, body);
}
if let Some(body) = rendered.strip_prefix("session: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Session, rendered, body);
}
if let Some(body) = split_once_inline_separator(&rendered)
.filter(|(prefix, _)| *prefix == "session")
.map(|(_, body)| body.to_owned())
{
return Self::from_parts(TranscriptEntryKind::Session, rendered, body);
}
if let Some(body) = rendered.strip_prefix("compact: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Compaction, rendered, body);
}
if let Some(body) = rendered.strip_prefix("hook: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::HookDiagnostic, rendered, body);
}
if let Some(body) = rendered
.strip_prefix("canceled: ")
.or_else(|| rendered.strip_prefix("cancelled: "))
.map(str::to_owned)
{
return Self::from_parts(TranscriptEntryKind::Canceled, rendered, body);
}
if let Some((level, body)) = rendered
.split_once(": ")
.map(|(level, body)| (level.to_owned(), body.to_owned()))
{
let normalized = level.trim().to_ascii_lowercase();
if normalized == "error" {
return Self::from_parts(TranscriptEntryKind::Error, rendered, body);
}
if matches!(normalized.as_str(), "info" | "warn" | "warning") {
return Self::from_parts(
TranscriptEntryKind::Diagnostic {
level: level.trim().to_string(),
},
rendered,
body,
);
}
}
Self::from_parts(
TranscriptEntryKind::LegacyDiagnostic,
rendered.clone(),
rendered,
)
}
pub(crate) fn append_assistant_text(&mut self, text: &str) {
debug_assert!(self.kind.is_assistant());
self.body.push_str(text);
self.rendered.push_str(text);
}
pub(crate) fn append_thinking_text(&mut self, text: &str) {
debug_assert!(self.kind.is_thinking());
self.body.push_str(text);
self.rendered.push_str(text);
}
}
impl From<String> for TranscriptEntry {
fn from(value: String) -> Self {
Self::from_legacy(value)
}
}
impl From<&str> for TranscriptEntry {
fn from(value: &str) -> Self {
Self::from_legacy(value.to_string())
}
}
impl std::ops::Deref for TranscriptEntry {
type Target = str;
fn deref(&self) -> &Self::Target {
self.rendered()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub(crate) struct TranscriptRowId(usize);
impl TranscriptRowId {
pub(crate) fn index(self) -> usize {
self.0
}
}
impl std::borrow::Borrow<usize> for TranscriptRowId {
fn borrow(&self) -> &usize {
&self.0
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct TranscriptEntries {
entries: VecDeque<TranscriptEntry>,
index_base: TranscriptRowId,
timestamps: HashMap<TranscriptRowId, chrono::DateTime<chrono::Local>>,
activity_links: HashMap<TranscriptRowId, crate::tui::transcript_cards::TranscriptActivityLink>,
revisions: std::cell::RefCell<HashMap<TranscriptRowId, u64>>,
revision: std::cell::Cell<u64>,
}
impl TranscriptEntries {
pub(crate) fn index_base(&self) -> usize {
self.index_base.0
}
pub(crate) fn absolute_index(&self, local_index: usize) -> Option<usize> {
(local_index < self.len()).then(|| self.index_base.0 + local_index)
}
pub(crate) fn local_index(&self, absolute_index: usize) -> Option<usize> {
absolute_index
.checked_sub(self.index_base.0)
.filter(|index| *index < self.len())
}
pub(crate) fn timestamp(&self, local_index: usize) -> Option<chrono::DateTime<chrono::Local>> {
self.absolute_index(local_index)
.and_then(|id| self.timestamps.get(&id))
.copied()
}
pub(crate) fn set_timestamp(
&mut self,
absolute_index: usize,
timestamp: chrono::DateTime<chrono::Local>,
) {
if self.local_index(absolute_index).is_some() {
self.timestamps
.insert(TranscriptRowId(absolute_index), timestamp);
self.mark_changed(absolute_index);
}
}
pub(crate) fn activity_links(
&self,
) -> &HashMap<TranscriptRowId, crate::tui::transcript_cards::TranscriptActivityLink> {
&self.activity_links
}
pub(crate) fn link_activity(
&mut self,
absolute_index: usize,
link: crate::tui::transcript_cards::TranscriptActivityLink,
) {
if self.local_index(absolute_index).is_some() {
self.activity_links
.insert(TranscriptRowId(absolute_index), link);
self.mark_changed(absolute_index);
}
}
pub(crate) fn mark_changed(&self, absolute_index: usize) {
if self.local_index(absolute_index).is_some() {
let revision = self.revision.get().wrapping_add(1);
self.revision.set(revision);
self.revisions
.borrow_mut()
.insert(TranscriptRowId(absolute_index), revision);
}
}
pub(crate) fn entry_revision(&self, local_index: usize) -> u64 {
self.absolute_index(local_index)
.and_then(|id| self.revisions.borrow().get(&TranscriptRowId(id)).copied())
.unwrap_or(0)
}
fn retain_metadata(&mut self) {
let start = self.index_base.0;
let end = start.saturating_add(self.len());
self.timestamps.retain(|id, _| (start..end).contains(&id.0));
self.activity_links
.retain(|id, _| (start..end).contains(&id.0));
self.revisions
.get_mut()
.retain(|id, _| (start..end).contains(&id.0));
}
#[cfg(test)]
pub(crate) fn timestamps(&self) -> &HashMap<TranscriptRowId, chrono::DateTime<chrono::Local>> {
&self.timestamps
}
#[cfg(test)]
pub(crate) fn set_index_base_for_test(&mut self, base: usize) {
self.index_base = TranscriptRowId(base);
self.retain_metadata();
}
pub(crate) fn len(&self) -> usize {
self.entries.len()
}
pub(crate) fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(crate) fn clear(&mut self) {
*self = Self::default();
}
pub(crate) fn push_back<T>(&mut self, entry: T)
where
T: Into<TranscriptEntry>,
{
self.push_back_bounded(entry);
}
pub(crate) fn push_back_bounded<T>(&mut self, entry: T) -> usize
where
T: Into<TranscriptEntry>,
{
self.entries.push_back(entry.into());
self.truncate()
}
pub(crate) fn extend<T, I>(&mut self, entries: I)
where
T: Into<TranscriptEntry>,
I: IntoIterator<Item = T>,
{
for entry in entries {
self.push_back(entry);
}
}
pub(crate) fn get(&self, index: usize) -> Option<&TranscriptEntry> {
self.entries.get(index)
}
pub(crate) fn get_mut(&mut self, index: usize) -> Option<&mut TranscriptEntry> {
if let Some(id) = self.absolute_index(index) {
self.mark_changed(id);
}
self.entries.get_mut(index)
}
pub(crate) fn back_entry(&self) -> Option<&TranscriptEntry> {
self.entries.back()
}
#[cfg(test)]
pub(crate) fn contains(&self, rendered: &str) -> bool {
self.iter().any(|entry| entry == rendered)
}
#[cfg(test)]
pub(crate) fn front(&self) -> Option<&String> {
self.entries.front().map(TranscriptEntry::rendered_string)
}
pub(crate) fn back(&self) -> Option<&String> {
self.entries.back().map(TranscriptEntry::rendered_string)
}
pub(crate) fn pop_back(&mut self) -> Option<TranscriptEntry> {
let entry = self.entries.pop_back();
self.retain_metadata();
entry
}
pub(crate) fn iter_entries(
&self,
) -> impl DoubleEndedIterator<Item = &TranscriptEntry> + ExactSizeIterator + '_ {
self.entries.iter()
}
pub(crate) fn iter(&self) -> impl DoubleEndedIterator<Item = &String> + ExactSizeIterator + '_ {
self.entries.iter().map(TranscriptEntry::rendered_string)
}
#[cfg(test)]
pub(crate) fn join(&self, separator: &str) -> String {
self.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(separator)
}
pub(crate) fn replace<T>(&mut self, index: usize, entry: T) -> Option<TranscriptEntry>
where
T: Into<TranscriptEntry>,
{
self.get_mut(index)
.map(|current| std::mem::replace(current, entry.into()))
}
fn truncate(&mut self) -> usize {
let drained = self.entries.len().saturating_sub(MAX_TRANSCRIPT_LINES);
for _ in 0..drained {
self.entries.pop_front();
}
self.index_base.0 = self.index_base.0.saturating_add(drained);
if drained > 0 {
self.retain_metadata();
}
drained
}
}
impl TranscriptEntry {
fn rendered_string(&self) -> &String {
&self.rendered
}
}
impl<'a> IntoIterator for &'a TranscriptEntries {
type Item = &'a String;
type IntoIter = std::iter::Map<
std::collections::vec_deque::Iter<'a, TranscriptEntry>,
fn(&TranscriptEntry) -> &String,
>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter().map(TranscriptEntry::rendered_string)
}
}
impl<T> From<Vec<T>> for TranscriptEntries
where
T: Into<TranscriptEntry>,
{
fn from(entries: Vec<T>) -> Self {
let mut transcript = Self::default();
transcript.extend(entries);
transcript
}
}
impl<T> From<VecDeque<T>> for TranscriptEntries
where
T: Into<TranscriptEntry>,
{
fn from(entries: VecDeque<T>) -> Self {
let mut transcript = Self::default();
transcript.extend(entries);
transcript
}
}
impl PartialEq for TranscriptEntries {
fn eq(&self, other: &Self) -> bool {
self.iter_entries().eq(other.iter_entries())
}
}
impl Eq for TranscriptEntries {}
impl<T> PartialEq<Vec<T>> for TranscriptEntries
where
T: AsRef<str>,
{
fn eq(&self, other: &Vec<T>) -> bool {
self.len() == other.len()
&& self
.iter()
.zip(other)
.all(|(left, right)| left == right.as_ref())
}
}
impl Index<usize> for TranscriptEntries {
type Output = String;
fn index(&self, index: usize) -> &Self::Output {
self.entries[index].rendered_string()
}
}
pub(crate) fn push_transcript(transcript: &mut TranscriptEntries, entry: TranscriptEntry) -> usize {
transcript.push_back_bounded(entry)
}
pub(crate) fn push_error_transcript(transcript: &mut TranscriptEntries, message: &str) -> usize {
let message = compact_error_message(&sanitize_preview(message));
if message.is_empty() {
return 0;
}
let entry = TranscriptEntry::error(&message);
if transcript
.back_entry()
.is_some_and(|last| last.rendered() == entry.rendered())
{
return 0;
}
transcript.push_back_bounded(entry)
}
fn compact_error_message(message: &str) -> String {
message
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.take(3)
.collect::<Vec<_>>()
.join(PADDED_INLINE_SEPARATOR)
}
pub(crate) fn reconcile_assistant_text(
transcript: &mut TranscriptEntries,
text: &str,
active_index: Option<usize>,
) -> (usize, Option<usize>) {
let text = sanitize_preview(text);
if text.trim().is_empty() {
return (0, active_index);
}
let final_entry = TranscriptEntry::assistant(&text);
if let Some(index) = active_index
&& let Some(entry) = transcript.get_mut(index)
&& entry.kind().is_assistant()
{
reconcile_entry_body(entry, &text, final_entry);
return (0, Some(index));
}
if let Some(index) = latest_current_segment_assistant_entry(transcript)
&& let Some(entry) = transcript.get_mut(index)
&& entry.kind().is_assistant()
&& body_matches(entry.body(), &text)
{
reconcile_entry_body(entry, &text, final_entry);
return (0, Some(index));
}
let original_index = transcript.len();
let drained = transcript.push_back_bounded(final_entry);
let index = original_index
.checked_sub(drained)
.filter(|index| *index < transcript.len());
(drained, index)
}
pub(crate) fn body_matches(streamed: &str, text: &str) -> bool {
text.starts_with(streamed) || streamed.ends_with(text) || streamed == text
}
fn reconcile_entry_body(entry: &mut TranscriptEntry, text: &str, final_entry: TranscriptEntry) {
if text.starts_with(entry.body()) || !entry.body().ends_with(text) {
*entry = final_entry;
}
}
pub(crate) fn latest_current_segment_assistant_entry(
transcript: &TranscriptEntries,
) -> Option<usize> {
for (index, entry) in transcript.iter_entries().enumerate().rev() {
if !entry.kind().is_assistant() && !entry.kind().is_thinking() {
break;
}
if entry.kind().is_assistant() {
return Some(index);
}
}
None
}
pub(crate) fn latest_current_segment_thinking_entry(
transcript: &TranscriptEntries,
) -> Option<usize> {
for (index, entry) in transcript.iter_entries().enumerate().rev() {
if !entry.kind().is_assistant() && !entry.kind().is_thinking() {
break;
}
if entry.kind().is_thinking() {
return Some(index);
}
}
None
}
#[derive(Debug, Default)]
pub(crate) struct StreamingControlSanitizer {
state: ControlState,
}
#[derive(Debug, Default)]
enum ControlState {
#[default]
Normal,
Escape,
Csi,
String {
escape: bool,
},
}
impl StreamingControlSanitizer {
pub(crate) fn push(&mut self, text: &str) -> String {
let mut out = String::new();
for ch in text.chars() {
match &mut self.state {
ControlState::Normal => {
if ch == '\u{1b}' {
self.state = ControlState::Escape;
} else if !ch.is_control() || matches!(ch, '\n' | '\t' | '\r') {
out.push(ch);
}
}
ControlState::Escape => {
self.state = match ch {
'[' => ControlState::Csi,
']' | 'P' | '^' | '_' | 'X' => ControlState::String { escape: false },
_ => ControlState::Normal,
};
}
ControlState::Csi => {
if ('@'..='~').contains(&ch) {
self.state = ControlState::Normal;
}
}
ControlState::String { escape } => {
if ch == '\u{7}' {
self.state = ControlState::Normal;
} else if *escape {
self.state = if ch == '\\' {
ControlState::Normal
} else if ch == '\u{1b}' {
ControlState::String { escape: true }
} else {
ControlState::String { escape: false }
};
} else if ch == '\u{1b}' {
*escape = true;
}
}
}
}
out
}
pub(crate) fn reset(&mut self) {
self.state = ControlState::Normal;
}
}
pub(crate) fn sanitize_preview(text: &str) -> String {
redact_sensitive_text(strip_control_sequences(text).as_str())
}
fn strip_control_sequences(text: &str) -> String {
let mut out = String::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
if ch.is_control() && !matches!(ch, '\n' | '\t' | '\r') {
continue;
}
out.push(ch);
continue;
}
match chars.peek().copied() {
Some('[') => {
chars.next();
consume_csi(&mut chars);
}
Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
chars.next();
consume_string_control(&mut chars);
}
Some(ch) if ('@'..='_').contains(&ch) => {
chars.next();
}
Some(_) => {
chars.next();
}
None => {}
}
}
out
}
fn consume_csi(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
for ch in chars.by_ref() {
if ('@'..='~').contains(&ch) {
break;
}
}
}
fn consume_string_control(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
while let Some(ch) = chars.next() {
if ch == '\u{7}' {
break;
}
if ch == '\u{1b}' && chars.peek().copied() == Some('\\') {
chars.next();
break;
}
}
}
pub(crate) fn ratatui_wrapped_visual_rows(text: &str, wrap_width: u16) -> usize {
let lines = text
.split('\n')
.map(|line| Line::from(Span::raw(line.to_string())))
.collect::<Vec<_>>();
ratatui_wrapped_line_rows(lines, wrap_width)
}
pub(crate) fn ratatui_wrapped_line_rows(lines: Vec<Line<'static>>, wrap_width: u16) -> usize {
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.line_count(wrap_width.max(1))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct VisualRow {
start: usize,
end: usize,
columns: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct VisualUnit {
start: usize,
end: usize,
columns: usize,
}
pub(crate) fn text_position_for_visual_cell(
text: &str,
row: usize,
column: usize,
wrap_width: u16,
) -> Option<crate::tui::selection::TextPosition> {
let rows = visual_row_ranges(text, wrap_width);
let selected = rows.get(row).or_else(|| rows.last())?;
let byte = byte_index_for_row_column(text, *selected, column);
Some(crate::tui::selection::TextPosition::new(byte, row, column))
}
pub(crate) fn text_position_for_visual_cell_with_leading_columns(
text: &str,
leading_columns: usize,
row: usize,
column: usize,
wrap_width: u16,
) -> Option<crate::tui::selection::TextPosition> {
if leading_columns == 0 {
return text_position_for_visual_cell(text, row, column, wrap_width);
}
let rows = visual_row_ranges_with_leading_columns(text, leading_columns, wrap_width);
let selected = rows.get(row).or_else(|| rows.last())?;
let byte =
byte_index_for_row_column_with_leading_columns(text, leading_columns, *selected, column);
Some(crate::tui::selection::TextPosition::new(byte, row, column))
}
fn visual_row_ranges(text: &str, wrap_width: u16) -> Vec<VisualRow> {
let wrap_width = usize::from(wrap_width.max(1));
let mut rows = Vec::new();
let mut line_start = 0usize;
let mut saw = false;
for segment in text.split_inclusive('\n') {
saw = true;
let line = segment.strip_suffix('\n').unwrap_or(segment);
push_visual_line_rows(
text,
line_start,
line_start + line.len(),
wrap_width,
&mut rows,
);
line_start += segment.len();
if segment.ends_with('\n') && line_start == text.len() {
rows.push(VisualRow {
start: line_start,
end: line_start,
columns: 0,
});
}
}
if !saw {
rows.push(VisualRow {
start: 0,
end: 0,
columns: 0,
});
} else if line_start < text.len() {
push_visual_line_rows(text, line_start, text.len(), wrap_width, &mut rows);
}
rows
}
fn visual_row_ranges_with_leading_columns(
text: &str,
leading_columns: usize,
wrap_width: u16,
) -> Vec<VisualRow> {
let wrap_width = usize::from(wrap_width.max(1));
let mut rows = Vec::new();
let mut line_start = 0usize;
let mut saw = false;
for segment in text.split_inclusive('\n') {
saw = true;
let line = segment.strip_suffix('\n').unwrap_or(segment);
let leading = if line_start == 0 { leading_columns } else { 0 };
push_visual_line_rows_with_leading_columns(
text,
line_start,
line_start + line.len(),
leading_columns,
leading,
wrap_width,
&mut rows,
);
line_start += segment.len();
if segment.ends_with('\n') && line_start == text.len() {
rows.push(VisualRow {
start: leading_columns + line_start,
end: leading_columns + line_start,
columns: 0,
});
}
}
if !saw {
push_visual_line_rows_with_leading_columns(
text,
0,
0,
leading_columns,
leading_columns,
wrap_width,
&mut rows,
);
} else if line_start < text.len() {
push_visual_line_rows_with_leading_columns(
text,
line_start,
text.len(),
leading_columns,
0,
wrap_width,
&mut rows,
);
}
rows
}
fn push_visual_line_rows(
text: &str,
line_start: usize,
line_end: usize,
wrap_width: usize,
rows: &mut Vec<VisualRow>,
) {
if line_start == line_end {
rows.push(VisualRow {
start: line_start,
end: line_end,
columns: 0,
});
return;
}
let mut line_width = 0usize;
let mut word_width = 0usize;
let mut whitespace_width = 0usize;
let mut pending_line: Vec<VisualUnit> = Vec::new();
let mut pending_word: Vec<VisualUnit> = Vec::new();
let mut pending_whitespace: VecDeque<VisualUnit> = VecDeque::new();
let mut non_whitespace_previous = false;
for (offset, grapheme) in text[line_start..line_end].grapheme_indices(true) {
if grapheme.chars().any(char::is_control) {
continue;
}
let start = line_start + offset;
let unit = VisualUnit {
start,
end: start + grapheme.len(),
columns: UnicodeWidthStr::width(grapheme),
};
let is_whitespace = grapheme.chars().all(char::is_whitespace);
let word_found = non_whitespace_previous && is_whitespace;
let untrimmed_overflow = pending_line.is_empty()
&& word_width
.saturating_add(whitespace_width)
.saturating_add(unit.columns)
> wrap_width;
if word_found || untrimmed_overflow {
line_width = line_width.saturating_add(whitespace_width);
pending_line.extend(pending_whitespace.drain(..));
whitespace_width = 0;
line_width = line_width.saturating_add(word_width);
pending_line.append(&mut pending_word);
word_width = 0;
}
let line_full = line_width >= wrap_width;
let pending_word_overflow = unit.columns > 0
&& line_width
.saturating_add(whitespace_width)
.saturating_add(word_width)
>= wrap_width;
if line_full || pending_word_overflow {
push_units_as_row(&pending_line, rows);
let mut remaining_width = wrap_width.saturating_sub(line_width);
pending_line.clear();
line_width = 0;
while let Some(front) = pending_whitespace.front().copied() {
if front.columns > remaining_width {
break;
}
whitespace_width = whitespace_width.saturating_sub(front.columns);
remaining_width = remaining_width.saturating_sub(front.columns);
pending_whitespace.pop_front();
}
if is_whitespace && pending_whitespace.is_empty() {
non_whitespace_previous = false;
continue;
}
}
if is_whitespace {
whitespace_width = whitespace_width.saturating_add(unit.columns);
pending_whitespace.push_back(unit);
} else {
word_width = word_width.saturating_add(unit.columns);
pending_word.push(unit);
}
non_whitespace_previous = !is_whitespace;
}
pending_line.extend(pending_whitespace);
pending_line.extend(pending_word);
if pending_line.is_empty() {
rows.push(VisualRow {
start: line_start,
end: line_end,
columns: 0,
});
} else {
push_units_as_row(&pending_line, rows);
}
}
fn push_visual_line_rows_with_leading_columns(
text: &str,
line_start: usize,
line_end: usize,
virtual_offset: usize,
leading_columns: usize,
wrap_width: usize,
rows: &mut Vec<VisualRow>,
) {
if line_start == line_end && leading_columns == 0 {
rows.push(VisualRow {
start: virtual_offset + line_start,
end: virtual_offset + line_end,
columns: 0,
});
return;
}
let mut line_width = 0usize;
let mut word_width = 0usize;
let mut whitespace_width = 0usize;
let mut pending_line: Vec<VisualUnit> = Vec::new();
let mut pending_word: Vec<VisualUnit> = Vec::new();
let mut pending_whitespace: VecDeque<VisualUnit> = VecDeque::new();
let mut non_whitespace_previous = false;
for index in 0..leading_columns {
push_visual_unit(
VisualUnit {
start: index,
end: index + 1,
columns: 1,
},
true,
wrap_width,
&mut line_width,
&mut word_width,
&mut whitespace_width,
&mut pending_line,
&mut pending_word,
&mut pending_whitespace,
&mut non_whitespace_previous,
rows,
);
}
for (offset, grapheme) in text[line_start..line_end].grapheme_indices(true) {
if grapheme.chars().any(char::is_control) {
continue;
}
let start = virtual_offset + line_start + offset;
push_visual_unit(
VisualUnit {
start,
end: start + grapheme.len(),
columns: UnicodeWidthStr::width(grapheme),
},
grapheme.chars().all(char::is_whitespace),
wrap_width,
&mut line_width,
&mut word_width,
&mut whitespace_width,
&mut pending_line,
&mut pending_word,
&mut pending_whitespace,
&mut non_whitespace_previous,
rows,
);
}
pending_line.extend(pending_whitespace);
pending_line.extend(pending_word);
if pending_line.is_empty() {
rows.push(VisualRow {
start: virtual_offset + line_start,
end: virtual_offset + line_end,
columns: 0,
});
} else {
push_units_as_row(&pending_line, rows);
}
}
#[allow(clippy::too_many_arguments)]
fn push_visual_unit(
unit: VisualUnit,
is_whitespace: bool,
wrap_width: usize,
line_width: &mut usize,
word_width: &mut usize,
whitespace_width: &mut usize,
pending_line: &mut Vec<VisualUnit>,
pending_word: &mut Vec<VisualUnit>,
pending_whitespace: &mut VecDeque<VisualUnit>,
non_whitespace_previous: &mut bool,
rows: &mut Vec<VisualRow>,
) {
let word_found = *non_whitespace_previous && is_whitespace;
let untrimmed_overflow = pending_line.is_empty()
&& word_width
.saturating_add(*whitespace_width)
.saturating_add(unit.columns)
> wrap_width;
if word_found || untrimmed_overflow {
*line_width = line_width.saturating_add(*whitespace_width);
pending_line.extend(pending_whitespace.drain(..));
*whitespace_width = 0;
*line_width = line_width.saturating_add(*word_width);
pending_line.append(pending_word);
*word_width = 0;
}
let line_full = *line_width >= wrap_width;
let pending_word_overflow = unit.columns > 0
&& line_width
.saturating_add(*whitespace_width)
.saturating_add(*word_width)
>= wrap_width;
if line_full || pending_word_overflow {
push_units_as_row(pending_line, rows);
let mut remaining_width = wrap_width.saturating_sub(*line_width);
pending_line.clear();
*line_width = 0;
while let Some(front) = pending_whitespace.front().copied() {
if front.columns > remaining_width {
break;
}
*whitespace_width = whitespace_width.saturating_sub(front.columns);
remaining_width = remaining_width.saturating_sub(front.columns);
pending_whitespace.pop_front();
}
if is_whitespace && pending_whitespace.is_empty() {
*non_whitespace_previous = false;
return;
}
}
if is_whitespace {
*whitespace_width = whitespace_width.saturating_add(unit.columns);
pending_whitespace.push_back(unit);
} else {
*word_width = word_width.saturating_add(unit.columns);
pending_word.push(unit);
}
*non_whitespace_previous = !is_whitespace;
}
fn push_units_as_row(units: &[VisualUnit], rows: &mut Vec<VisualRow>) {
if let (Some(first), Some(last)) = (units.first(), units.last()) {
rows.push(VisualRow {
start: first.start,
end: last.end,
columns: units.iter().map(|unit| unit.columns).sum(),
});
}
}
fn byte_index_for_row_column_with_leading_columns(
text: &str,
leading_columns: usize,
row: VisualRow,
column: usize,
) -> usize {
let target = column.min(row.columns);
if target == 0 {
return row.start.saturating_sub(leading_columns).min(text.len());
}
let mut width = 0usize;
for index in 0..leading_columns {
if index < row.start || index >= row.end {
continue;
}
let next_width = width.saturating_add(1);
if next_width > target {
return 0;
}
width = next_width;
if width == target {
return (index + 1).saturating_sub(leading_columns).min(text.len());
}
}
for (offset, grapheme) in text.grapheme_indices(true) {
let virtual_start = leading_columns + offset;
if virtual_start < row.start || virtual_start >= row.end {
continue;
}
if grapheme.chars().any(char::is_control) {
continue;
}
let next_width = width.saturating_add(UnicodeWidthStr::width(grapheme));
if next_width > target {
return offset;
}
width = next_width;
if width == target {
return offset + grapheme.len();
}
}
row.end.saturating_sub(leading_columns).min(text.len())
}
fn byte_index_for_row_column(text: &str, row: VisualRow, column: usize) -> usize {
let target = column.min(row.columns);
if target == 0 {
return row.start;
}
let mut width = 0usize;
for (offset, grapheme) in text[row.start..row.end].grapheme_indices(true) {
if grapheme.chars().any(char::is_control) {
continue;
}
let next_width = width.saturating_add(UnicodeWidthStr::width(grapheme));
if next_width > target {
return row.start + offset;
}
width = next_width;
if width == target {
return row.start + offset + grapheme.len();
}
}
row.end
}
pub(crate) fn usize_to_u16_saturating(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
}
#[cfg(test)]
mod visual_row_tests {
use super::*;
#[test]
fn visual_row_ranges_filters_tabs_like_ratatui() {
for text in ["a\tb", "\tab", "a\t b", "a\tb\tc", "a\u{0007}\tb"] {
for width in 1..=6 {
assert_eq!(
visual_row_ranges(text, width).len(),
ratatui_wrapped_visual_rows(text, width),
"text={text:?} width={width}"
);
}
}
}
#[test]
fn text_position_for_visual_cell_filters_tabs_like_ratatui() {
let text = "a\tb";
assert_eq!(
visual_row_ranges(text, 2).len(),
ratatui_wrapped_visual_rows(text, 2)
);
assert_eq!(
text_position_for_visual_cell(text, 0, 0, 2).unwrap().byte,
0
);
assert_eq!(
text_position_for_visual_cell(text, 0, 1, 2).unwrap().byte,
1
);
assert_eq!(
text_position_for_visual_cell(text, 0, 2, 2).unwrap().byte,
text.len()
);
}
#[test]
fn text_position_with_leading_columns_matches_padded_text() {
for text in ["alpha beta", "alpha\nbeta", "a界界e\u{0301}z", "a\tb", ""] {
for leading_columns in 1..=5 {
let padded = format!("{}{}", " ".repeat(leading_columns), text);
for width in 1..=8 {
for row in 0..=8 {
for column in 0..=8 {
let padded = text_position_for_visual_cell(&padded, row, column, width)
.map(|position| position.byte.saturating_sub(leading_columns));
let virtual_position =
text_position_for_visual_cell_with_leading_columns(
text,
leading_columns,
row,
column,
width,
)
.map(|position| position.byte);
assert_eq!(
virtual_position, padded,
"text={text:?} leading={leading_columns} width={width} row={row} column={column}"
);
}
}
}
}
}
}
#[test]
fn text_position_for_visual_cell_keeps_grapheme_boundaries() {
let text = "a界界e\u{0301}z";
for column in 0..=10 {
let position = text_position_for_visual_cell(text, 0, column, 40).unwrap();
assert!(
text.is_char_boundary(position.byte),
"byte {} must be utf8 boundary",
position.byte
);
assert!(
position.byte == 0
|| text.grapheme_indices(true).any(|(index, grapheme)| {
index == position.byte || index + grapheme.len() == position.byte
})
|| position.byte == text.len(),
"byte {} must be grapheme boundary in {text:?}",
position.byte
);
}
assert_eq!(
visual_row_ranges(text, 4).len(),
ratatui_wrapped_visual_rows(text, 4)
);
}
#[test]
fn oversized_graphemes_remain_addressable_at_width_one() {
for text in ["界", "e\u{0301}"] {
let rows = visual_row_ranges(text, 1);
assert_eq!(rows.len(), 1, "text={text:?}");
assert_eq!(rows[0].start, 0);
assert_eq!(rows[0].end, text.len());
assert_eq!(rows[0].columns, UnicodeWidthStr::width(text));
let position = text_position_for_visual_cell(text, 0, 0, 1).unwrap();
assert_eq!(position.byte, 0);
let end = text_position_for_visual_cell(text, 0, rows[0].columns, 1).unwrap();
assert_eq!(end.byte, text.len());
}
}
}
#[cfg(test)]
mod sanitizer_tests {
use super::sanitize_preview;
#[test]
fn sanitizer_strips_osc52_osc8_and_window_title() {
let input = concat!(
"safe\tspace\n",
"\u{1b}]52;c;c2VjcmV0\u{7}",
"\u{1b}]8;;https://example.test\u{1b}\\link\u{1b}]8;;\u{1b}\\",
"\u{1b}]0;secret title\u{7}",
"done"
);
let sanitized = sanitize_preview(input);
assert_eq!(sanitized, "safe\tspace\nlinkdone");
}
#[test]
fn sanitizer_strips_dcs_pm_apc_sos_and_single_char_escape() {
let input = concat!(
"a",
"\u{1b}Pignored\u{1b}\\",
"b",
"\u{1b}^ignored\u{7}",
"c",
"\u{1b}_ignored\u{7}",
"d",
"\u{1b}Xignored\u{7}",
"e",
"\u{1b}7",
"f"
);
assert_eq!(sanitize_preview(input), "abcdef");
}
}
#[cfg(test)]
mod typed_entry_tests {
use super::*;
#[test]
fn row_metadata_follows_replacement_front_pruning_and_tail_removal() {
let mut store = TranscriptEntries::default();
store.push_back(TranscriptEntry::assistant("draft"));
let timestamp = chrono::Local::now();
store.set_timestamp(0, timestamp);
store.link_activity(
0,
crate::tui::transcript_cards::TranscriptActivityLink {
activity_id: crate::output::ActivityId::new("first"),
tool_name: "read".to_string(),
},
);
let revision = store.entry_revision(0);
store.replace(0, TranscriptEntry::assistant("final"));
assert_eq!(store.timestamp(0), Some(timestamp));
assert_eq!(store.activity_links().len(), 1);
assert!(store.entry_revision(0) > revision);
for _ in 0..MAX_TRANSCRIPT_LINES {
store.push_back(TranscriptEntry::assistant("next"));
}
assert_eq!(store.len(), MAX_TRANSCRIPT_LINES);
assert_eq!(store.index_base(), 1);
assert!(store.timestamps.is_empty());
assert!(store.activity_links.is_empty());
assert!(store.revisions.borrow().is_empty());
let last = store.absolute_index(store.len() - 1).unwrap();
store.set_timestamp(last, timestamp);
store.link_activity(
last,
crate::tui::transcript_cards::TranscriptActivityLink {
activity_id: crate::output::ActivityId::new("tail"),
tool_name: "read".to_string(),
},
);
store.pop_back();
assert!(store.timestamps.is_empty());
assert!(store.activity_links.is_empty());
assert!(store.revisions.borrow().is_empty());
}
#[test]
fn legacy_conversion_preserves_automatic_prompt_provenance() {
let automatic = TranscriptEntry::from_legacy("you [automatic]: continue".to_string());
assert_eq!(
automatic.kind(),
&TranscriptEntryKind::UserPrompt { automatic: true }
);
assert_eq!(automatic.body(), "continue");
let ordinary = TranscriptEntry::from_legacy("you: continue".to_string());
assert_eq!(
ordinary.kind(),
&TranscriptEntryKind::UserPrompt { automatic: false }
);
}
#[test]
fn transcript_equality_includes_entry_kind() {
let rendered = "same rendered text".to_string();
let user = TranscriptEntry::from_parts(
TranscriptEntryKind::UserPrompt { automatic: false },
rendered.clone(),
rendered.clone(),
);
let diagnostic = TranscriptEntry::from_parts(
TranscriptEntryKind::LegacyDiagnostic,
rendered.clone(),
rendered,
);
assert_ne!(
TranscriptEntries::from(vec![user]),
TranscriptEntries::from(vec![diagnostic])
);
}
#[test]
fn semantic_constructors_keep_bodies_without_prefix_reparsing() {
let tool = TranscriptEntry::tool(
"tool: nested body".to_string(),
TranscriptToolData {
identity: TranscriptToolIdentity::Named {
name: "read".to_string(),
status: Some(crate::output::ActivityStatus::Success),
},
children: Vec::new(),
},
);
assert_eq!(tool.rendered(), "tool: tool: nested body");
assert_eq!(tool.body(), "tool: nested body");
assert_eq!(tool.kind(), &TranscriptEntryKind::Tool);
let data = tool.tool_data().unwrap();
assert_eq!(data.identity.name(), "read");
assert_eq!(
data.identity.status(),
Some(crate::output::ActivityStatus::Success)
);
let diagnostic = TranscriptEntry::diagnostic("info: nested level", "diagnostic body");
assert_eq!(diagnostic.rendered(), "info: nested level: diagnostic body");
assert_eq!(diagnostic.body(), "diagnostic body");
assert_eq!(diagnostic.kind(), &TranscriptEntryKind::LegacyDiagnostic);
}
#[test]
fn streaming_entry_mutation_keeps_assistant_kind() {
let mut entry = TranscriptEntry::assistant("draft");
entry.append_assistant_text(" update");
assert_eq!(entry.rendered(), "assistant: draft update");
assert_eq!(entry.body(), "draft update");
assert_eq!(entry.kind(), &TranscriptEntryKind::Assistant);
}
}