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};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const MAX_TRANSCRIPT_LINES: usize = 500;
pub(crate) const TRANSCRIPT_TURN_SEPARATOR: &str = "โโ user turn โโ";
#[derive(Debug, Clone)]
pub(crate) struct CachedTranscriptCard {
pub(crate) content_marker: u64,
pub(crate) card: crate::tui::transcript_cards::TranscriptCard,
}
#[derive(Debug, Clone)]
pub(crate) struct CachedTranscriptVisualLines {
pub(crate) generation: u64,
pub(crate) active: bool,
pub(crate) lines: Vec<crate::tui::transcript_cards::TranscriptVisualLine>,
}
#[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, usize>,
pub(crate) cards: BTreeMap<usize, CachedTranscriptCard>,
pub(crate) visual_lines: HashMap<u16, CachedTranscriptVisualLines>,
}
impl TranscriptRenderCache {
pub(crate) fn clear(&mut self) {
self.clear_visual_projection();
self.cards.clear();
}
pub(crate) fn clear_visual_projection(&mut self) {
#[cfg(test)]
{
self.visual_text = None;
}
self.visual_rows.clear();
self.scrollbar_rows.clear();
self.visual_lines.clear();
}
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 mut evicted = false;
for entry_index in entry_indices {
evicted |= self.cards.remove(&entry_index).is_some();
}
if evicted {
self.clear_visual_projection();
}
}
pub(crate) fn evict_front(&mut self, old_base: usize, drained: usize) {
self.evict_cards(old_base..old_base.saturating_add(drained));
self.clear_visual_projection();
}
}
pub(crate) fn push_transcript(transcript: &mut VecDeque<String>, line: String) -> usize {
transcript.push_back(sanitize_preview(&line));
truncate_transcript(transcript)
}
pub(crate) fn push_error_transcript(transcript: &mut VecDeque<String>, message: &str) -> usize {
let message = compact_error_message(&sanitize_preview(message));
if message.is_empty() {
return 0;
}
let line = format!("error: {message}");
if transcript.back().is_some_and(|last| last == &line) {
return 0;
}
transcript.push_back(line);
truncate_transcript(transcript)
}
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 VecDeque<String>,
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_line = format!("assistant: {text}");
if let Some(index) = active_index
&& let Some(line) = transcript.get_mut(index)
&& line.starts_with("assistant: ")
{
reconcile_prefixed_line(line, "assistant: ", &text, final_line);
return (0, Some(index));
}
if let Some(index) = latest_current_segment_entry_for_prefix(transcript, "assistant: ")
&& let Some(line) = transcript.get_mut(index)
&& line.starts_with("assistant: ")
&& prefixed_line_matches(line, "assistant: ", &text)
{
reconcile_prefixed_line(line, "assistant: ", &text, final_line);
return (0, Some(index));
}
transcript.push_back(final_line);
let original_index = transcript.len().saturating_sub(1);
let drained = truncate_transcript(transcript);
let index = original_index
.checked_sub(drained)
.map(|index| index.min(transcript.len().saturating_sub(1)));
(drained, index)
}
pub(crate) fn prefixed_line_matches(line: &str, prefix: &str, text: &str) -> bool {
let streamed = line.trim_start_matches(prefix);
text.starts_with(streamed) || streamed.ends_with(text) || streamed == text
}
fn reconcile_prefixed_line(line: &mut String, prefix: &str, text: &str, final_line: String) {
let streamed = line.trim_start_matches(prefix);
if text.starts_with(streamed) || !streamed.ends_with(text) {
*line = final_line;
}
}
pub(crate) fn latest_current_segment_entry_for_prefix(
transcript: &VecDeque<String>,
prefix: &str,
) -> Option<usize> {
for (index, entry) in transcript.iter().enumerate().rev() {
if !entry.starts_with("assistant: ") && !entry.starts_with("thinking: ") {
break;
}
if entry.starts_with(prefix) {
return Some(index);
}
}
None
}
fn truncate_transcript(transcript: &mut VecDeque<String>) -> usize {
let drain = transcript.len().saturating_sub(MAX_TRANSCRIPT_LINES);
for _ in 0..drain {
transcript.pop_front();
}
drain
}
#[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 transcript_entry_parts(entry: &str) -> Option<(&'static str, &'static str, &str)> {
if let Some(body) = entry.strip_prefix("you: ") {
Some(("โ You", "you", body))
} else if let Some(body) = entry.strip_prefix("bash: ") {
Some(("โ Bash", "bash", body))
} else if let Some(body) = entry.strip_prefix("assistant: ") {
Some(("โฆ Assistant", "assistant", body))
} else if let Some(body) = entry.strip_prefix("thinking: ") {
Some(("โง Thinking", "thinking", body))
} else if let Some(body) = entry.strip_prefix("tool: ") {
Some(("โ Tool", "tool", body))
} else if let Some(body) = entry.strip_prefix("error: ") {
Some(("! Error", "error", body))
} else if let Some(body) = entry.strip_prefix("session: ") {
Some(("โข Session", "session", body))
} else if let Some(body) = split_once_inline_separator(entry)
.filter(|(prefix, _)| *prefix == "session")
.map(|(_, body)| body)
{
Some(("โข Session", "session", body))
} else if let Some(body) = entry.strip_prefix("compact: ") {
Some(("๐ Compaction", "compact", body))
} else {
None
}
}
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::input::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::input::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::input::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::input::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),
};
if unit.columns > wrap_width {
continue;
}
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>,
) {
if unit.columns > wrap_width {
return;
}
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 ratatui_wrapped_visual_rows_matches_paragraph_for_grapheme_fixtures() {
for text in [
"a๐จโ๐ฉโ๐งโ๐ฆb",
"โจ๏ธ input",
"e\u{0301}cho",
"a\tb",
"",
"a\n",
"a\u{0007}b",
] {
for width in 1..=6 {
let expected = Paragraph::new(
text.split('\n')
.map(|line| Line::from(Span::raw(line.to_string())))
.collect::<Vec<_>>(),
)
.wrap(Wrap { trim: false })
.line_count(width);
assert_eq!(
ratatui_wrapped_visual_rows(text, width),
expected,
"text={text:?} width={width}"
);
}
}
}
#[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)
);
}
}
#[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");
}
}