use super::*;
pub(in crate::tui::transcript_cards) fn visit_visual_lines<T>(
state: &MissionControlState,
width: u16,
mut visit: impl FnMut(&TranscriptVisualLine, usize) -> Option<T>,
) -> Option<T> {
let width = width.max(1);
if state.transcript.is_empty() {
return visual_lines(state, width)
.iter()
.find_map(|line| visit(line, visual_line_rows(line, width)));
}
for span in project_card_spans(state) {
let block = cached_card_visual_block(state, &span, width);
if let Some(result) = block.visit_lines(0..block.rows, |line, _, rows, _| visit(line, rows))
{
return Some(result);
}
}
visit(
&transcript_bottom_padding_visual_line(width, state.theme),
1,
)
}
pub(crate) fn visual_lines(state: &MissionControlState, width: u16) -> Vec<TranscriptVisualLine> {
visual_lines_with_rows(state, width).0
}
fn visual_lines_with_rows(
state: &MissionControlState,
width: u16,
) -> (Vec<TranscriptVisualLine>, usize) {
let width = width.max(1);
let mut lines = Vec::new();
let mut rows = 0usize;
if state.transcript.is_empty() {
let card = project_cards(state).pop().expect("empty transcript card");
append_wide_card_lines(
state,
&mut lines,
&card,
0,
state.is_transcript_focused(),
width,
None,
);
rows = lines.iter().map(|line| visual_line_rows(line, width)).sum();
} else {
for span in project_card_spans(state) {
let block = cached_card_visual_block(state, &span, width);
rows = rows.saturating_add(block.rows);
block.visit_lines(0..block.rows, |line, _, _, _| {
lines.push(line.clone());
None::<()>
});
}
}
lines.push(transcript_bottom_padding_visual_line(width, state.theme));
rows = rows.saturating_add(1);
annotate_copy_byte_ranges(&mut lines);
(lines, rows)
}
pub(crate) fn cached_visual_lines_ref(
state: &MissionControlState,
width: u16,
) -> Ref<'_, Vec<TranscriptVisualLine>> {
let width = width.max(1);
let generation = state.transcript_visual_generation();
let theme_revision = state.theme_revision();
let active = state.is_transcript_focused();
let needs_refresh = state
.transcript_cache
.borrow()
.visual_lines
.get(&width)
.is_none_or(|cached| {
cached.generation != generation
|| cached.theme_revision != theme_revision
|| cached.active != active
});
if needs_refresh {
let (lines, rows) = visual_lines_with_rows(state, width);
let mut cache = state.transcript_cache.borrow_mut();
cache.touch_visual_width(width);
cache.visual_lines.insert(
width,
transcript::CachedTranscriptVisualLines {
generation,
theme_revision,
active,
lines,
rows,
},
);
} else {
state
.transcript_cache
.borrow_mut()
.touch_visual_width(width);
}
Ref::map(state.transcript_cache.borrow(), |cache| {
&cache
.visual_lines
.get(&width)
.expect("transcript visual lines cached")
.lines
})
}
pub(crate) fn cached_visual_rows(state: &MissionControlState, width: u16) -> usize {
let width = width.max(1);
if state.active_assistant_transcript_index().is_some() {
return project_card_spans(state)
.map(|span| cached_card_visual_block(state, &span, width).rows)
.fold(1usize, usize::saturating_add);
}
let _ = cached_visual_lines_ref(state, width);
state
.transcript_cache
.borrow()
.visual_lines
.get(&width)
.map(|cached| cached.rows)
.unwrap_or_default()
}
pub(crate) fn visible_visual_lines_windowed(
state: &MissionControlState,
width: u16,
visible_start: usize,
visible_rows: u16,
) -> VisibleTranscriptVisualLines {
let width = width.max(1);
let visible_end = visible_start.saturating_add(usize::from(visible_rows));
let stop_row = visible_end.saturating_add(usize::from(TRANSCRIPT_VIEWPORT_OVERSCAN_ROWS));
let mut current_row = 0usize;
let mut visible_lines = Vec::new();
let mut scroll_offset = 0usize;
if state.transcript.is_empty() {
for line in visual_lines(state, width) {
append_visible_visual_line(
line,
width,
visible_start,
visible_end,
&mut current_row,
&mut visible_lines,
&mut scroll_offset,
);
}
return VisibleTranscriptVisualLines {
#[cfg(test)]
has_overflow_above: visible_start > 0,
lines: visible_lines,
scroll_offset,
scrollbar: TranscriptScrollbarMetrics::new(
current_row,
visible_start,
usize::from(visible_rows),
),
};
}
let mut byte_base = 0usize;
let mut copy_line_seen = false;
for span in project_card_spans(state) {
if current_row >= stop_row {
break;
}
let block = cached_card_visual_block(state, &span, width);
byte_base = byte_base.saturating_add(usize::from(copy_line_seen));
block.visit_lines(
visible_start.saturating_sub(current_row)..visible_end.saturating_sub(current_row),
|line, row, _, copy| {
if visible_lines.is_empty() {
scroll_offset = visible_start.saturating_sub(current_row + row);
}
let mut line = line.clone();
line.copy_byte_range =
copy.map(|(start, end)| (byte_base + start, byte_base + end));
visible_lines.push(line);
None::<()>
},
);
byte_base = byte_base.saturating_add(block.copy_bytes());
copy_line_seen = true;
current_row = current_row.saturating_add(block.rows);
}
append_visible_visual_line_with_rows(
transcript_bottom_padding_visual_line(width, state.theme),
1,
visible_start,
visible_end,
&mut current_row,
&mut visible_lines,
&mut scroll_offset,
);
VisibleTranscriptVisualLines {
#[cfg(test)]
has_overflow_above: visible_start > 0,
lines: visible_lines,
scroll_offset,
scrollbar: TranscriptScrollbarMetrics::new(
current_row,
visible_start,
usize::from(visible_rows),
),
}
}
pub(crate) fn visible_anchored_scrollback_visual_lines(
state: &MissionControlState,
width: u16,
visible_rows: u16,
) -> VisibleTranscriptVisualLines {
scrollback_visual_lines(
state,
width,
visible_rows,
state.transcript_scroll_offset(),
true,
)
}
pub(super) fn scrollback_visual_lines(
state: &MissionControlState,
width: u16,
visible_rows: u16,
mut scrollback_rows: usize,
anchor_viewport: bool,
) -> VisibleTranscriptVisualLines {
let width = width.max(1);
let visible_rows = usize::from(visible_rows);
let anchor = state
.transcript_scroll_anchor
.get()
.filter(|_| anchor_viewport && scrollback_rows > 0);
if state.transcript.is_empty() {
return visible_visual_lines_windowed(state, width, 0, visible_rows as u16);
}
let mut index = {
let mut cache = state.transcript_cache.borrow_mut();
let cached_index = cache.history_indices.remove(&width).filter(|index| {
index.generation == state.transcript_visual_generation()
&& index.source_start == state.transcript_absolute_index(0)
&& index.source_len == state.transcript.len()
&& index.theme_revision == state.theme_revision()
&& index.active == state.is_transcript_focused()
&& index.subagent_card_rows == state.subagent_card_rows()
});
if cached_index.is_none() {
cache.scrollbar_rows.remove(&width);
}
cached_index.unwrap_or_else(|| transcript::CachedTranscriptHistoryIndex {
generation: state.transcript_visual_generation(),
source_start: state.transcript_absolute_index(0),
source_len: state.transcript.len(),
theme_revision: state.theme_revision(),
active: state.is_transcript_focused(),
subagent_card_rows: state.subagent_card_rows(),
next_entry: state.transcript.len(),
..Default::default()
})
};
if let Some(anchor) = anchor {
let local_entry = state.transcript_local_index(anchor.entry_index);
let target_entry = local_entry.unwrap_or(0);
extend_history_index(state, width, &mut index, 0, target_entry);
let block_index = index
.entry_indices
.partition_point(|&entry| entry > target_entry);
if let Some(block) = index.blocks.get(block_index) {
let row = if local_entry.is_some() {
anchor.row.min(block.rows.saturating_sub(1))
} else {
0
};
let anchored_scrollback = index.cumulative_rows[block_index]
.saturating_sub(row)
.saturating_sub(visible_rows);
scrollback_rows = if scrollback_rows >= anchor.scrollback_rows {
anchored_scrollback.saturating_add(scrollback_rows - anchor.scrollback_rows)
} else {
anchored_scrollback.saturating_sub(anchor.scrollback_rows - scrollback_rows)
};
}
}
let target_rows = visible_rows
.saturating_add(scrollback_rows)
.saturating_add(usize::from(TRANSCRIPT_VIEWPORT_OVERSCAN_ROWS));
extend_history_index(
state,
width,
&mut index,
target_rows,
state.transcript.len(),
);
let collected_rows = index.cumulative_rows.last().copied().unwrap_or(1);
let reached_start = index.next_entry == 0;
let consumed_source_rows = state.transcript.len().saturating_sub(index.next_entry);
let scrollback_rows = scrollback_rows.min(collected_rows.saturating_sub(visible_rows));
let start = index
.cumulative_rows
.partition_point(|&rows| rows <= scrollback_rows);
let end = index
.cumulative_rows
.partition_point(|&rows| rows < target_rows)
.saturating_add(1)
.min(index.blocks.len());
let window_rows = if end == 0 {
1
} else {
index.cumulative_rows[end - 1]
};
let blocks = index.blocks[start..end].to_vec();
if anchor_viewport && visible_rows > 0 {
let stored_scrollback = scrollback_rows.max(usize::from(
anchor.is_some_and(|anchor| anchor.scrollback_rows > 0),
));
state.transcript_tail_distance.set(stored_scrollback);
let top_from_tail = visible_rows.saturating_add(scrollback_rows);
let block_index = index
.cumulative_rows
.partition_point(|&rows| rows < top_from_tail)
.min(index.blocks.len().saturating_sub(1));
state
.transcript_scroll_anchor
.set(index.entry_indices.get(block_index).and_then(|&entry| {
Some(transcript::TranscriptScrollAnchor {
entry_index: state.transcript_absolute_index(entry)?,
row: index.cumulative_rows[block_index].saturating_sub(top_from_tail),
scrollback_rows: stored_scrollback,
})
}));
}
state
.transcript_cache
.borrow_mut()
.history_indices
.insert(width, index);
let estimated_total_rows = if reached_start {
collected_rows
} else {
collected_rows
.saturating_sub(1)
.saturating_mul(state.transcript.len())
.div_ceil(consumed_source_rows.max(1))
.saturating_add(1)
.max(
visible_rows
.saturating_add(scrollback_rows)
.saturating_add(1),
)
};
let total_rows = {
let mut cache = state.transcript_cache.borrow_mut();
cache.touch_visual_width(width);
let measurement = cache.scrollbar_rows.entry(width).or_insert(
transcript::CachedTranscriptScrollbarRows {
rows: estimated_total_rows,
exact: reached_start,
},
);
if !measurement.exact {
measurement.rows = estimated_total_rows;
measurement.exact = reached_start;
}
measurement.rows
};
let viewport_length = visible_rows;
let position = total_rows
.saturating_sub(viewport_length)
.saturating_sub(scrollback_rows);
let visible_start = window_rows
.saturating_sub(visible_rows)
.saturating_sub(scrollback_rows);
let mut visible = visible_visual_lines_from_cached_blocks(
blocks,
transcript_bottom_padding_visual_line(width, state.theme),
visible_start,
visible_rows as u16,
);
#[cfg(test)]
{
visible.has_overflow_above = !reached_start || visible_start > 0;
}
visible.scrollbar = TranscriptScrollbarMetrics::new(total_rows, position, viewport_length);
visible
}
fn extend_history_index(
state: &MissionControlState,
width: u16,
index: &mut transcript::CachedTranscriptHistoryIndex,
target_rows: usize,
target_entry: usize,
) {
let mut collected_rows = index.cumulative_rows.last().copied().unwrap_or(1);
if collected_rows >= target_rows && index.next_entry <= target_entry {
return;
}
for span in project_card_spans_reverse_from(state, index.next_entry) {
let block = cached_card_visual_block(state, &span, width);
collected_rows = collected_rows.saturating_add(block.rows);
index.next_entry = span.first_entry_index;
index.entry_indices.push(span.first_entry_index);
index.blocks.push(block);
index.cumulative_rows.push(collected_rows);
if collected_rows >= target_rows && index.next_entry <= target_entry {
break;
}
}
}
fn append_visible_visual_line(
line: TranscriptVisualLine,
width: u16,
visible_start: usize,
visible_end: usize,
current_row: &mut usize,
visible_lines: &mut Vec<TranscriptVisualLine>,
scroll_offset: &mut usize,
) {
let rows = visual_line_rows(&line, width);
append_visible_visual_line_with_rows(
line,
rows,
visible_start,
visible_end,
current_row,
visible_lines,
scroll_offset,
);
}
fn append_visible_visual_line_with_rows(
line: TranscriptVisualLine,
rows: usize,
visible_start: usize,
visible_end: usize,
current_row: &mut usize,
visible_lines: &mut Vec<TranscriptVisualLine>,
scroll_offset: &mut usize,
) {
let next_row = current_row.saturating_add(rows);
if next_row <= visible_start {
*current_row = next_row;
return;
}
if *current_row < visible_end {
if visible_lines.is_empty() {
*scroll_offset = visible_start.saturating_sub(*current_row);
}
visible_lines.push(line);
}
*current_row = next_row;
}
fn visible_visual_lines_from_cached_blocks(
blocks: Vec<Arc<transcript::CachedTranscriptVisualBlock>>,
bottom_padding: TranscriptVisualLine,
visible_start: usize,
visible_rows: u16,
) -> VisibleTranscriptVisualLines {
let visible_end = visible_start.saturating_add(usize::from(visible_rows));
let mut current_row = 0usize;
let mut visible_lines = Vec::new();
let mut scroll_offset = 0usize;
for block in blocks.into_iter().rev() {
if current_row >= visible_end {
break;
}
block.visit_lines(
visible_start.saturating_sub(current_row)..visible_end.saturating_sub(current_row),
|line, row, _, _| {
if visible_lines.is_empty() {
scroll_offset = visible_start.saturating_sub(current_row + row);
}
visible_lines.push(line.clone());
None::<()>
},
);
current_row = current_row.saturating_add(block.rows);
}
append_visible_visual_line_with_rows(
bottom_padding,
1,
visible_start,
visible_end,
&mut current_row,
&mut visible_lines,
&mut scroll_offset,
);
VisibleTranscriptVisualLines {
lines: visible_lines,
scroll_offset,
#[cfg(test)]
has_overflow_above: visible_start > 0,
scrollbar: TranscriptScrollbarMetrics {
content_length: current_row,
position: visible_start,
viewport_length: usize::from(visible_rows),
},
}
}
fn cached_card_visual_block(
state: &MissionControlState,
span: &ProjectedTranscriptCard,
width: u16,
) -> Arc<transcript::CachedTranscriptVisualBlock> {
let width = width.max(1);
let entry_index = span.first_entry_index;
let cache_index = state
.transcript_absolute_index(entry_index)
.unwrap_or(entry_index);
let active = state.is_transcript_focused();
let prepend_blank = entry_index > 0;
let theme_revision = state.theme_revision();
let subagent_card_rows = if span.card.role == TranscriptCardRole::SubagentBatch {
state.subagent_card_rows()
} else {
0
};
let previous = {
let mut cache = state.transcript_cache.borrow_mut();
cache.touch_visual_width(width);
if let Some(block) = cache
.visual_blocks
.get(&width)
.and_then(|blocks| blocks.get(&cache_index))
.filter(|block| {
block.content_marker == span.content_marker
&& block.theme_revision == theme_revision
&& block.active == active
&& block.prepend_blank == prepend_blank
&& block.subagent_card_rows == subagent_card_rows
})
{
return Arc::clone(block);
}
if cache
.streaming_reuse
.as_ref()
.is_some_and(|(_, index, _)| *index == cache_index)
{
cache
.streaming_reuse
.take()
.filter(|(old_width, _, block)| {
*old_width == width
&& block.theme_revision == theme_revision
&& block.active == active
&& block.prepend_blank == prepend_blank
&& block.subagent_card_rows == subagent_card_rows
})
.map(|(_, _, block)| block)
} else {
None
}
};
let lines = card_visual_lines_uncached(
state,
&span.card,
entry_index,
active,
width,
prepend_blank,
None,
);
let streaming = streaming::is_windowed(&span.card).then(|| {
StreamingBody::new(
Arc::clone(&span.card),
width,
state.theme,
active,
previous.as_ref().and_then(|block| block.streaming.as_ref()),
)
});
drop(previous);
let line_rows = lines
.iter()
.map(|line| visual_line_rows(line, width))
.collect::<Vec<_>>();
let rows =
line_rows.iter().copied().sum::<usize>() + streaming.as_ref().map_or(0, |body| body.rows);
let block = Arc::new(transcript::CachedTranscriptVisualBlock {
content_marker: span.content_marker,
theme_revision,
active,
prepend_blank,
subagent_card_rows,
lines,
line_rows,
rows,
streaming,
});
state
.transcript_cache
.borrow_mut()
.insert_visual_block(width, cache_index, Arc::clone(&block));
block
}