use crate::{
sanitize::{
char_display_width, sanitize_title, sanitize_with_policy, str_display_width, SanitizePolicy,
},
widget::TranscriptItem,
};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
const MAX_TRANSCRIPT_SOURCE_LINES: usize = 20_000;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TranscriptRow {
pub role: String,
pub text: String,
pub muted: bool,
pub continuation: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TranscriptView {
pub rows: Vec<TranscriptRow>,
pub total_rows: usize,
pub visible_rows: usize,
pub max_scroll: usize,
pub scroll: usize,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TranscriptViewport {
pub width_px: f32,
pub height_px: f32,
pub glyph_width_px: f32,
pub line_height_px: f32,
pub scroll_lines: usize,
}
impl TranscriptViewport {
pub fn new(width_px: f32, height_px: f32) -> Self {
Self {
width_px,
height_px,
glyph_width_px: 9.5,
line_height_px: 22.0,
scroll_lines: 0,
}
}
pub fn with_metrics(mut self, glyph_width_px: f32, line_height_px: f32) -> Self {
self.glyph_width_px = finite_positive(glyph_width_px, 1.0);
self.line_height_px = finite_positive(line_height_px, 1.0);
self
}
pub fn with_scroll(mut self, scroll_lines: usize) -> Self {
self.scroll_lines = scroll_lines;
self
}
pub fn columns(self) -> usize {
let width = finite_non_negative(self.width_px);
let glyph_width = finite_positive(self.glyph_width_px, 9.5);
(width / glyph_width).floor().clamp(12.0, 10_000.0) as usize
}
pub fn rows_visible(self) -> usize {
let height = finite_non_negative(self.height_px);
let line_height = finite_positive(self.line_height_px, 22.0);
(height / line_height).floor().clamp(1.0, 10_000.0) as usize
}
pub fn materialize(self, items: &[TranscriptItem]) -> TranscriptView {
let columns = self.columns();
let prepared = items
.iter()
.map(|item| PreparedTranscriptItem::new(item, columns))
.collect::<Vec<_>>();
let total_rows = prepared.iter().fold(0usize, |total, item| {
total.saturating_add(count_item_rows(item, columns))
});
let visible_rows = self.rows_visible();
let max_scroll = total_rows.saturating_sub(visible_rows);
let scroll = self.scroll_lines.min(max_scroll);
let end = total_rows.saturating_sub(scroll);
let start = end.saturating_sub(visible_rows);
let mut rows = Vec::with_capacity(visible_rows.min(total_rows));
let mut row_index = 0usize;
for item in &prepared {
collect_item_rows(item, columns, start, end, &mut row_index, &mut rows);
if row_index >= end {
break;
}
}
TranscriptView {
rows,
total_rows,
visible_rows,
max_scroll,
scroll,
}
}
}
struct PreparedTranscriptItem {
role: String,
content: String,
detail: String,
}
impl PreparedTranscriptItem {
fn new(item: &TranscriptItem, columns: usize) -> Self {
Self {
role: sanitize_title(&item.role, 64),
content: sanitize_transcript_text(&item.content, columns),
detail: sanitize_transcript_text(&item.detail, columns),
}
}
}
fn sanitize_transcript_text(input: &str, columns: usize) -> String {
sanitize_with_policy(
input,
SanitizePolicy {
max_line_width: columns.saturating_mul(8).clamp(columns.max(1), 10_000),
max_lines: MAX_TRANSCRIPT_SOURCE_LINES,
..SanitizePolicy::plain_text()
},
)
.text
}
fn count_item_rows(item: &PreparedTranscriptItem, columns: usize) -> usize {
let content_rows = emit_wrapped_text(&item.content, columns, |_| {});
let detail_rows = if item.detail.trim().is_empty() {
0
} else {
emit_wrapped_text(&item.detail, columns, |_| {})
};
content_rows.saturating_add(detail_rows).saturating_add(1)
}
fn collect_item_rows(
item: &PreparedTranscriptItem,
columns: usize,
start: usize,
end: usize,
row_index: &mut usize,
rows: &mut Vec<TranscriptRow>,
) {
let mut content_index = 0usize;
emit_wrapped_text(&item.content, columns, |text| {
push_visible_row(
rows,
start,
end,
*row_index,
TranscriptRow {
role: if content_index == 0 {
item.role.clone()
} else {
String::new()
},
text,
muted: false,
continuation: content_index > 0,
},
);
content_index += 1;
*row_index = row_index.saturating_add(1);
});
if !item.detail.trim().is_empty() {
emit_wrapped_text(&item.detail, columns, |text| {
push_visible_row(
rows,
start,
end,
*row_index,
TranscriptRow {
role: String::new(),
text,
muted: true,
continuation: true,
},
);
*row_index = row_index.saturating_add(1);
});
}
push_visible_row(
rows,
start,
end,
*row_index,
TranscriptRow {
role: String::new(),
text: String::new(),
muted: true,
continuation: true,
},
);
*row_index = row_index.saturating_add(1);
}
fn push_visible_row(
rows: &mut Vec<TranscriptRow>,
start: usize,
end: usize,
row_index: usize,
row: TranscriptRow,
) {
if (start..end).contains(&row_index) {
rows.push(row);
}
}
fn finite_positive(value: f32, fallback: f32) -> f32 {
if value.is_finite() && value > 0.0 {
value
} else {
fallback
}
}
fn finite_non_negative(value: f32) -> f32 {
if value.is_finite() && value > 0.0 {
value
} else {
0.0
}
}
fn emit_wrapped_text(text: &str, columns: usize, mut emit: impl FnMut(String)) -> usize {
let columns = columns.max(1);
let mut rows = 0usize;
for source_line in text.lines() {
let mut line = String::new();
let mut line_width = 0usize;
for word in source_line.split_whitespace() {
let word_width = str_display_width(word);
if word_width > columns {
if !line.is_empty() {
rows += 1;
emit(line);
line = String::new();
line_width = 0;
}
rows += emit_long_word_chunks(word, columns, &mut emit);
continue;
}
let spacing = if line.is_empty() { 0 } else { 1 };
if line_width + spacing + word_width > columns && !line.is_empty() {
rows += 1;
emit(line);
line = word.to_string();
line_width = word_width;
} else if line.is_empty() {
line.push_str(word);
line_width = word_width;
} else {
line.push(' ');
line.push_str(word);
line_width += 1 + word_width;
}
}
if line.is_empty() {
rows += 1;
emit(String::new());
} else {
rows += 1;
emit(line);
}
}
if rows == 0 {
rows += 1;
emit(String::new());
}
rows
}
fn emit_long_word_chunks(word: &str, columns: usize, emit: &mut impl FnMut(String)) -> usize {
let mut count = 0usize;
let mut chunk = String::new();
let mut width = 0usize;
for ch in word.chars() {
let char_width = char_display_width(ch);
if !chunk.is_empty() && width + char_width > columns {
count += 1;
emit(chunk);
chunk = String::new();
width = 0;
}
chunk.push(ch);
width += char_width;
}
if !chunk.is_empty() {
count += 1;
emit(chunk);
}
count
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transcript_view_clamps_scroll() {
let items = vec![TranscriptItem::assistant(
"one two three four five six seven eight nine ten",
"detail row",
)];
let view = TranscriptViewport::new(80.0, 44.0)
.with_metrics(10.0, 22.0)
.with_scroll(999)
.materialize(&items);
assert_eq!(view.visible_rows, 2);
assert_eq!(view.scroll, view.max_scroll);
assert!(view.total_rows > view.rows.len());
}
#[test]
fn transcript_wraps_long_tokens_to_viewport_columns() {
let viewport = TranscriptViewport::new(80.0, 220.0).with_metrics(10.0, 22.0);
let columns = viewport.columns();
let view = viewport.materialize(&[TranscriptItem::assistant(
"https://example.com/supercalifragilisticexpialidocious",
"",
)]);
assert!(view
.rows
.iter()
.all(|row| str_display_width(&row.text) <= columns));
assert!(view.total_rows > 2);
}
#[test]
fn malformed_viewport_metrics_use_bounded_minimums() {
let viewport = TranscriptViewport {
width_px: f32::INFINITY,
height_px: f32::NAN,
glyph_width_px: 0.0,
line_height_px: 0.0,
scroll_lines: usize::MAX,
};
let view = viewport.materialize(&[TranscriptItem::assistant("hello", "")]);
assert_eq!(viewport.columns(), 12);
assert_eq!(viewport.rows_visible(), 1);
assert_eq!(view.visible_rows, 1);
assert_eq!(view.rows.len(), 1);
}
#[test]
fn transcript_materialization_retains_only_visible_window_for_large_inputs() {
let content = vec!["row"; 10_000].join("\n");
let view = TranscriptViewport::new(120.0, 22.0)
.with_metrics(10.0, 22.0)
.materialize(&[TranscriptItem::assistant(content, "")]);
assert_eq!(view.visible_rows, 1);
assert_eq!(view.rows.len(), 1);
assert!(view.total_rows > 1_000);
}
#[test]
fn transcript_materialization_sanitizes_public_text_fields() {
let item = TranscriptItem {
role: "\x1b[31massistant\u{202e}".to_string(),
content: "safe\x1b[2J\u{200b}\u{2028}next".to_string(),
detail: "detail\x07\u{202e}".to_string(),
};
let view = TranscriptViewport::new(240.0, 220.0).materialize(&[item]);
let joined = view
.rows
.iter()
.map(|row| format!("{}:{}", row.role, row.text))
.collect::<Vec<_>>()
.join("\n");
assert!(!joined.contains('\x1b'));
assert!(!joined.contains('\x07'));
assert!(!joined.contains('\u{202e}'));
assert!(!joined.contains('\u{200b}'));
assert!(joined.contains("assistant"));
assert!(joined.contains("safe"));
assert!(joined.contains("next"));
}
}