mod cache;
mod control_sequences;
mod entry;
mod wrap;
pub(crate) use cache::CachedTranscriptCard;
pub(crate) use cache::CachedTranscriptHistoryIndex;
pub(crate) use cache::CachedTranscriptScrollbarRows;
pub(crate) use cache::CachedTranscriptVisualBlock;
pub(crate) use cache::CachedTranscriptVisualLines;
pub(crate) use cache::MAX_STREAMING_CACHE_BYTES;
pub(crate) use cache::TranscriptScrollAnchor;
pub(crate) use cache::TranscriptRenderCache;
pub(crate) use entry::TranscriptEntryKind;
pub(crate) use entry::TranscriptSubagentSummary;
pub(crate) use entry::TranscriptToolData;
pub(crate) use entry::TranscriptToolIdentity;
pub(crate) use control_sequences::StreamingControlSanitizer;
pub(crate) use entry::TranscriptEntry;
pub(crate) use entry::subagent_summary;
pub(crate) use control_sequences::sanitize_preview;
pub(crate) use wrap::ratatui_wrapped_line_rows;
pub(crate) use wrap::ratatui_wrapped_visual_rows;
pub(crate) use wrap::usize_to_u16_saturating;
pub(crate) use wrap::text_position_for_visual_cell_with_leading_columns;
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, 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));
}
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()
}
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)
}
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
}